More fixes and optimizations.
This commit is contained in:
parent
1a7c7ec05a
commit
b2ba0c33e9
12 changed files with 1528 additions and 220 deletions
|
|
@ -31,6 +31,10 @@ MAME="$repo/toolchains/cache/mame-mame0264/x68k"
|
|||
TEMPLATE="$SP/x68mame/HUMAN302.XDF"
|
||||
FRAMES="${X68K_BENCH_FRAMES:-120000}"
|
||||
WALL="${X68K_BENCH_WALL:-7200}"
|
||||
# Which plane the built port presents into: must match X68K_PRESENT_TEXT in
|
||||
# src/x68000/hal.c (1 -> text, 0 -> gfx). Only the completion DETECTOR cares;
|
||||
# the hashes are of the surface in RAM and are the same either way.
|
||||
PRESENT_PLANE="${X68K_PRESENT_PLANE:-text}"
|
||||
|
||||
export PATH="$repo/toolchains/x68000/m68k-xelf/bin:$PATH"
|
||||
work=$(mktemp -d -t joey-x68bench.XXXXXX)
|
||||
|
|
@ -92,10 +96,29 @@ local FRAMES = $FRAMES
|
|||
local GREEN_STABLE = 600 -- ~11 s of emulated time held steady
|
||||
local EXIT_SETTLE = 900 -- keypress -> fclose -> back at the Human68k prompt
|
||||
|
||||
-- crtmod 13, 256-colour graphics plane: one 16-bit word per pixel at \$C00000,
|
||||
-- 512 words per row, the 320x200 stage centred at origin (96, 156).
|
||||
-- Where the present puts pixels, which depends on the port's
|
||||
-- X68K_PRESENT_TEXT switch:
|
||||
-- text (default) -- crtmod 12, the four 1bpp TEXT planes at \$E00000, 128
|
||||
-- bytes per row, planes 128 KB apart. A pixel's colour is
|
||||
-- one bit from each plane at the same offset.
|
||||
-- gfx -- crtmod 13, 256-colour GRAPHICS plane at \$C00000, one
|
||||
-- 16-bit word per pixel, 512 words per row.
|
||||
-- The 320x200 stage is centred at origin (96, 156) either way.
|
||||
local presentPlane = "$PRESENT_PLANE"
|
||||
local function stagePixel(px, py)
|
||||
return mem:read_u8(0xC00000 + ((156 + py) * 512 + 96 + px) * 2 + 1)
|
||||
if presentPlane == "gfx" then
|
||||
return mem:read_u8(0xC00000 + ((156 + py) * 512 + 96 + px) * 2 + 1)
|
||||
end
|
||||
local x = 96 + px
|
||||
local byteOff = (156 + py) * 128 + (x >> 3)
|
||||
local bit = 0x80 >> (x & 7)
|
||||
local v = 0
|
||||
for plane = 0, 3 do
|
||||
if (mem:read_u8(0xE00000 + plane * 0x20000 + byteOff) & bit) ~= 0 then
|
||||
v = v | (1 << plane)
|
||||
end
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
local kProbes = { {4,4}, {160,4}, {315,4}, {4,100}, {160,100}, {315,100},
|
||||
|
|
|
|||
181
scripts/verify-amiga-audio.sh
Executable file
181
scripts/verify-amiga-audio.sh
Executable file
|
|
@ -0,0 +1,181 @@
|
|||
#!/usr/bin/env bash
|
||||
# verify-amiga-audio.sh - Amiga audio gate, graded on recorded sound.
|
||||
#
|
||||
# Paula cannot be tapped register-by-register here: fs-uae has no trace
|
||||
# facility and no debugger this harness can drive, so unlike the IIgs and
|
||||
# ST gates (which read the DOC and the YM2149 directly) this one records
|
||||
# what actually came out of the emulator and grades that.
|
||||
#
|
||||
# Capture path: fs-uae plays through OpenAL, OpenAL is pointed at
|
||||
# PulseAudio with ALSOFT_DRIVERS=pulse, and PULSE_SINK sends this ONE
|
||||
# process to a null sink created for the run -- the host's default sink is
|
||||
# never touched, so the gate is safe to run on a desktop that is playing
|
||||
# something else. parec then records that sink's monitor.
|
||||
#
|
||||
# What is graded: the AUDIO example is driven through four phases whose
|
||||
# expected loudness differs by a wide margin.
|
||||
#
|
||||
# music the baked tracker song on entry, loud
|
||||
# paused P pressed, near silence
|
||||
# sfx SPACE during the pause, so ANY sound must be the digital SFX
|
||||
# resumed P again, loud again
|
||||
#
|
||||
# Firing the SFX into a paused song is the point: mixed under the music an
|
||||
# 8-bit one-shot barely moves the total and "did the SFX play" becomes a
|
||||
# judgement call, while against silence it is unambiguous. Coexistence is
|
||||
# still covered, because the music has to come back afterwards on the same
|
||||
# hardware. Measured margins: music ~5700, paused ~240, sfx ~1170.
|
||||
#
|
||||
# Keys are HELD (keydown, pause, keyup), not tapped: the demo tests
|
||||
# jlKeyPressed, an edge predicate polled once a frame, and a make/break
|
||||
# delivered between two polls is never seen.
|
||||
#
|
||||
# --fullscreen=1 and --initial_input_grab=1 are REQUIRED under a WM-less
|
||||
# Xvfb: SDL only accepts keyboard input once its window has focus, no
|
||||
# window manager is there to grant it, and xdotool's XSendEvent route is
|
||||
# ignored by SDL. A fullscreen SDL window takes focus itself, after which
|
||||
# plain XTEST typing lands. Same reasoning as verify-amiga-input.sh.
|
||||
#
|
||||
# Usage: scripts/verify-amiga-audio.sh [example]
|
||||
# example binary name in build/amiga/bin, default Audio
|
||||
# Env: AMIGA_AUDIO_KEEP set to keep the work dir and the WAV
|
||||
# Requires: fs-uae, Xvfb, xdotool, parec/pactl (PulseAudio or PipeWire),
|
||||
# python3, toolchains/emulators/support/kickstart.rom,
|
||||
# build/amiga/bin/<example>
|
||||
set -uo pipefail
|
||||
|
||||
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
prog=${1:-Audio}
|
||||
binDir=$repo/build/amiga/bin
|
||||
kickstart=$repo/toolchains/emulators/support/kickstart.rom
|
||||
grader=$repo/tools/gradeAudioPhases.py
|
||||
label=verify-amiga-audio
|
||||
|
||||
[ -f "$kickstart" ] || { echo "$label: missing $kickstart" >&2; exit 2; }
|
||||
[ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; }
|
||||
for tool in fs-uae Xvfb xdotool parec pactl python3; do
|
||||
command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; }
|
||||
done
|
||||
pactl info >/dev/null 2>&1 || { echo "$label: no PulseAudio/PipeWire server to record from" >&2; exit 2; }
|
||||
|
||||
if [ -d "$binDir/$prog" ]; then
|
||||
progDir=$binDir/$prog
|
||||
elif [ -f "$binDir/$prog" ]; then
|
||||
progDir=""
|
||||
else
|
||||
echo "$label: $binDir/$prog not built (run 'make amiga')" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
work=$(mktemp -d -t joeylib-amaudio.XXXXXX)
|
||||
sinkModule=""
|
||||
display=":$((90 + RANDOM % 9))"
|
||||
xvfbPid=""
|
||||
fsPid=""
|
||||
parecPid=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$parecPid" ] && kill "$parecPid" 2>/dev/null
|
||||
[ -n "$fsPid" ] && kill "$fsPid" 2>/dev/null
|
||||
[ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null
|
||||
# Always unload the sink: leaving null sinks behind on the user's
|
||||
# audio server is the one side effect this gate could have.
|
||||
[ -n "$sinkModule" ] && pactl unload-module "$sinkModule" 2>/dev/null
|
||||
if [ -n "${AMIGA_AUDIO_KEEP:-}" ]; then
|
||||
echo "$label: kept $work" >&2
|
||||
else
|
||||
rm -rf "$work"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Stage the Amiga volume exactly as run-amiga-headless.sh does.
|
||||
mkdir -p "$work/hd/s"
|
||||
if [ -n "$progDir" ]; then
|
||||
cp -r "$progDir/." "$work/hd/"
|
||||
else
|
||||
find "$binDir" -maxdepth 1 -type f -executable -exec cp -t "$work/hd/" {} +
|
||||
[ -d "$binDir/DATA" ] && cp -r "$binDir/DATA" "$work/hd/"
|
||||
fi
|
||||
printf 'stack 32768\n:%s\n' "$prog" > "$work/hd/s/startup-sequence"
|
||||
|
||||
sinkName="joeyAudioGate$$"
|
||||
sinkModule=$(pactl load-module module-null-sink \
|
||||
sink_name="$sinkName" \
|
||||
sink_properties=device.description="$sinkName" 2>/dev/null) || sinkModule=""
|
||||
[ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; }
|
||||
|
||||
Xvfb "$display" -screen 0 800x600x24 -nolisten tcp >/dev/null 2>&1 &
|
||||
xvfbPid=$!
|
||||
sleep 1
|
||||
|
||||
# --fast_memory=0 --slow_memory=0 keeps this a bare A500, the port's floor.
|
||||
DISPLAY="$display" PULSE_SINK="$sinkName" ALSOFT_DRIVERS=pulse SDL_AUDIODRIVER=pulse \
|
||||
fs-uae \
|
||||
--amiga_model=A500 \
|
||||
--hard_drive_0="$work/hd" \
|
||||
--hard_drive_0_label=JOEYLIB \
|
||||
--kickstart_file="$kickstart" \
|
||||
--fullscreen=1 \
|
||||
--initial_input_grab=1 \
|
||||
--fade_out_duration=0 \
|
||||
--fast_memory=0 \
|
||||
--slow_memory=0 \
|
||||
> "$work/fsuae.log" 2>&1 &
|
||||
fsPid=$!
|
||||
|
||||
# Let Kickstart boot and the startup-sequence reach the program.
|
||||
sleep 12
|
||||
if ! kill -0 "$fsPid" 2>/dev/null; then
|
||||
echo "$label: FAIL - fs-uae exited before the program started" >&2
|
||||
tail -5 "$work/fsuae.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
parec --device="$sinkName.monitor" --format=s16le --rate=44100 --channels=2 \
|
||||
--file-format=wav "$work/out.wav" > "$work/parec.log" 2>&1 &
|
||||
parecPid=$!
|
||||
|
||||
win=$(DISPLAY="$display" xdotool search --name -- "FS-UAE" | head -1)
|
||||
if [ -z "$win" ]; then
|
||||
echo "$label: FAIL - no FS-UAE window to send keys to" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPLAY="$display" xdotool windowactivate --sync "$win" 2>/dev/null
|
||||
|
||||
hold() { # $1 = key name
|
||||
DISPLAY="$display" xdotool keydown --window "$win" "$1"
|
||||
sleep 0.4
|
||||
DISPLAY="$display" xdotool keyup --window "$win" "$1"
|
||||
}
|
||||
|
||||
# The phase boundaries below are the timestamps handed to the grader, so
|
||||
# the sleeps and the spec must stay in step.
|
||||
sleep 6 # 0-6s music
|
||||
hold p # -> paused
|
||||
sleep 8 # 6-14s paused
|
||||
for _ in 1 2 3; do # 14-24s sfx into silence
|
||||
hold space
|
||||
sleep 1.5
|
||||
done
|
||||
sleep 4
|
||||
hold p # -> resumed
|
||||
sleep 9 # 24-33s resumed
|
||||
|
||||
kill "$parecPid" 2>/dev/null
|
||||
parecPid=""
|
||||
sleep 1
|
||||
|
||||
[ -s "$work/out.wav" ] || { echo "$label: FAIL - recorded no audio at all" >&2; exit 1; }
|
||||
|
||||
# Thresholds sit far from the measured values (music ~5700, paused ~240,
|
||||
# sfx ~1170) so ordinary mixing jitter cannot flip the verdict.
|
||||
python3 "$grader" "$label" "$work/out.wav" \
|
||||
"music:1:5:2000:" \
|
||||
"paused:8:13::800" \
|
||||
"sfx:16:22:800:" \
|
||||
"resumed:27:32:2000:"
|
||||
status=$?
|
||||
[ -n "${AMIGA_AUDIO_KEEP:-}" ] && cp "$work/out.wav" "$repo/build/amiga/headless-audio.wav" 2>/dev/null
|
||||
exit $status
|
||||
279
scripts/verify-atarist-audio.sh
Executable file
279
scripts/verify-atarist-audio.sh
Executable file
|
|
@ -0,0 +1,279 @@
|
|||
#!/usr/bin/env bash
|
||||
# verify-atarist-audio.sh - Atari ST audio gate, read off the YM2149.
|
||||
#
|
||||
# The ST has ONE sound chip and two software layers competing for it, so
|
||||
# this gate traces every PSG register write and grades the competition
|
||||
# rather than listening to the output. Four things are checked:
|
||||
#
|
||||
# 1. The chip-tracker is alive. jlMusicPlay drives the three PSG voices
|
||||
# through reg 0-5 (12-bit tone period) and reg 8/9/A (4-bit volume),
|
||||
# gated by reg 7. Writes must come from the APPLICATION, not TOS --
|
||||
# the trace carries the pc of every write and TOS lives at $Exxxxx,
|
||||
# which is the discriminator used throughout.
|
||||
#
|
||||
# 2. The digital pump runs at its REAL rate. Sampled sound on the ST is
|
||||
# PWM through channel A's volume nibble: MFP Timer A at 2.4576 MHz /
|
||||
# 200 = 12288 Hz, and src/atarist/audio.c writes reg 8 on every one
|
||||
# of those ticks. Per frame that is 12288/60 = 205 (measured) or
|
||||
# 12288/50 = 246, against the tracker's ONE. The two populations
|
||||
# cannot be confused, so a per-frame count answers both "did the
|
||||
# pump start" and "is the divider right" -- a halved divider would
|
||||
# show ~103 and still sound like something.
|
||||
#
|
||||
# 3. Reg 8 has exactly ONE writer while the pump runs. This is the bug
|
||||
# the gate exists for: the tracker's voice 0 and the PWM pump both
|
||||
# own reg 8, and whoever wrote last wins, so a tracker that keeps
|
||||
# writing it chops the sample into silence. The pc in the trace
|
||||
# makes the writers countable. Each pump run's first and last frame
|
||||
# are excluded: pumpStart and uninstallTimerA legitimately touch
|
||||
# reg 8 from outside the ISR (the latter replays the latched voice).
|
||||
#
|
||||
# 4. They actually OVERLAP. "No collision" is worthless if it was
|
||||
# bought by silencing the music, so the tracker must still be
|
||||
# driving its own channel B inside the pump window.
|
||||
#
|
||||
# Subject: the AUDIO example, which starts the baked tracker song on
|
||||
# entry and fires a digital SFX on SPACE -- precisely the overlap. Note
|
||||
# SPACE is HELD (keydown, wait, keyup) rather than tapped: the demo tests
|
||||
# jlKeyPressed, an edge predicate polled once a frame, and a make/break
|
||||
# delivered between two polls is never seen. A bare `keypress` produces a
|
||||
# run with the tracker alone and no pump at all.
|
||||
#
|
||||
# STAXI is a poor subject here despite exercising both layers: it STOPS
|
||||
# the music while speech plays, so check 4 can never pass on it.
|
||||
#
|
||||
# Usage: scripts/verify-atarist-audio.sh [example]
|
||||
# example PRG base name in build/atarist/bin, default AUDIO
|
||||
# Env: ST_AUDIO_TAPS how many held SPACE presses (default 6)
|
||||
# ST_AUDIO_KEEP set to keep the work dir for inspection
|
||||
# Requires: hatari, toolchains/emulators/support/emutos-512k.img,
|
||||
# build/atarist/bin/<example>.PRG
|
||||
set -uo pipefail
|
||||
|
||||
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
prog=${1:-AUDIO}
|
||||
taps=${ST_AUDIO_TAPS:-6}
|
||||
tos=$repo/toolchains/emulators/support/emutos-512k.img
|
||||
binDir=$repo/build/atarist/bin
|
||||
|
||||
# Space Taxi ships as its own folder; everything else is a bare PRG.
|
||||
if [ -d "$binDir/$prog" ]; then
|
||||
driveDir=$binDir/$prog
|
||||
else
|
||||
driveDir=$binDir
|
||||
fi
|
||||
|
||||
[ -f "$driveDir/$prog.PRG" ] || { echo "verify-atarist-audio: missing $driveDir/$prog.PRG (run 'make atarist')" >&2; exit 2; }
|
||||
[ -f "$tos" ] || { echo "verify-atarist-audio: missing $tos" >&2; exit 2; }
|
||||
|
||||
work=$(mktemp -d -t joeylib-staudio.XXXXXX)
|
||||
hatariPid=""
|
||||
cleanup() {
|
||||
# SIGKILL, by PID. Three things are deliberate here:
|
||||
# * SIGKILL, because Hatari IGNORES SIGTERM -- measured: a plain
|
||||
# kill, and `pkill -x hatari`, both leave it running forever.
|
||||
# * by PID, not by name, so a Hatari the user has open survives.
|
||||
# * no `timeout -s KILL` wrapper, because that would make $! the
|
||||
# timeout and killing THAT orphans Hatari. The trap is the only
|
||||
# thing that needs to work, and it covers EXIT/INT/TERM.
|
||||
# The launch uses process substitution so $! is Hatari itself rather
|
||||
# than the filter at the end of a pipe.
|
||||
[ -n "$hatariPid" ] && { kill -KILL "$hatariPid"; wait "$hatariPid"; } 2>/dev/null
|
||||
[ -n "${ST_AUDIO_KEEP:-}" ] || rm -rf "$work"
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
[ -n "${ST_AUDIO_KEEP:-}" ] && echo "verify-atarist-audio: work dir $work" >&2
|
||||
|
||||
# 4 MB, not the stock 1 MB: STAXI's loader calls 1 MB a fatal asset
|
||||
# failure and this gate is about sound, not about memory.
|
||||
#
|
||||
# The psg_write trace runs ~800 KB/s, so it is filtered as it is produced
|
||||
# -- keeping the raw stream would put hundreds of MB in the work dir.
|
||||
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy hatari \
|
||||
--tos "$tos" \
|
||||
--harddrive "$driveDir" \
|
||||
--gemdos-drive C \
|
||||
--drive-a off --drive-b off \
|
||||
--sound 44100 \
|
||||
--memsize 4 \
|
||||
--fast-forward on \
|
||||
--cmd-fifo "$work/cmd.fifo" \
|
||||
--trace psg_write \
|
||||
--auto "C:\\$prog.PRG" \
|
||||
> >(grep --line-buffered -E '^ym write data' > "$work/ym.log") 2>&1 &
|
||||
hatariPid=$!
|
||||
|
||||
# Hatari CREATES the fifo; a write to one with no reader blocks forever,
|
||||
# so every command goes through a timeout.
|
||||
for _ in $(seq 1 25); do
|
||||
[ -p "$work/cmd.fifo" ] && break
|
||||
sleep 1
|
||||
done
|
||||
if [ ! -p "$work/cmd.fifo" ]; then
|
||||
echo "verify-atarist-audio: FAIL - hatari never created the command fifo" >&2
|
||||
exit 1
|
||||
fi
|
||||
send() { timeout 5 bash -c "echo \"\$1\" > \"$work/cmd.fifo\"" _ "$1"; }
|
||||
|
||||
# Wait for the APPLICATION to write the PSG. TOS writes it too (bell,
|
||||
# floppy seek), and every TOS pc starts with 'e'.
|
||||
waited=0
|
||||
until grep -qE 'pc=[^e]' "$work/ym.log" 2>/dev/null; do
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
if [ "$waited" -ge 40 ]; then
|
||||
echo "verify-atarist-audio: FAIL - $prog never wrote the PSG in ${waited}s (the tracker never started)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! kill -0 "$hatariPid" 2>/dev/null; then
|
||||
echo "verify-atarist-audio: FAIL - Hatari exited early" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Let the tracker settle, then snapshot a pump-free baseline.
|
||||
sleep 8
|
||||
cp "$work/ym.log" "$work/baseline.log"
|
||||
|
||||
# HOLD space so the once-a-frame jlKeyPressed poll can see the edge.
|
||||
i=0
|
||||
while [ "$i" -lt "$taps" ]; do
|
||||
send 'hatari-event keydown 57' || true # $39 = SPACE
|
||||
sleep 1
|
||||
send 'hatari-event keyup 57' || true
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
sleep 3
|
||||
# The braces + wait suppress bash's "Killed" job notice on stderr, which
|
||||
# would otherwise look like a gate error in the output.
|
||||
{ kill -KILL "$hatariPid"; wait "$hatariPid"; } 2>/dev/null
|
||||
hatariPid=""
|
||||
sleep 1
|
||||
|
||||
[ -s "$work/ym.log" ] || { echo "verify-atarist-audio: FAIL - empty PSG trace" >&2; exit 1; }
|
||||
|
||||
python3 - "$work/ym.log" "$work/baseline.log" "$prog" "$taps" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# ym write data reg=0x8 val=0xc video_cyc=8696 60@17 pc=e1197e instr_cycle 16
|
||||
LINE = re.compile(r'^ym write data reg=0x([0-9a-f]+) val=0x([0-9a-f]+) '
|
||||
r'video_cyc=(\d+).*pc=([0-9a-f]+)')
|
||||
|
||||
PUMP_PER_FRAME_MIN = 100 # tracker writes <= 1; the pump writes 205-246
|
||||
PUMP_RATE_LO = 150 # 12288/60 = 205, 12288/50 = 246. Below this the
|
||||
PUMP_RATE_HI = 280 # Timer A divider is wrong, not the sound.
|
||||
STRAY_FRAME_PCT_MAX = 5.0 # start/stop transitions only; a fighting
|
||||
# tracker touches reg 8 EVERY frame.
|
||||
REG_VOL_A = 8
|
||||
REG_MIXER = 7
|
||||
TONE_B_REGS = (2, 3) # channel B period == the tracker's own voice
|
||||
|
||||
|
||||
def perFrameWrites(path):
|
||||
"""{frame: {reg: [pc, ...]}} for APPLICATION writes only."""
|
||||
out = {}
|
||||
frame = 0
|
||||
prev = -1
|
||||
for line in open(path, errors='ignore'):
|
||||
m = LINE.match(line)
|
||||
if m is None:
|
||||
continue
|
||||
reg = int(m.group(1), 16)
|
||||
cyc = int(m.group(3))
|
||||
pc = m.group(4)
|
||||
if cyc < prev: # video_cyc restarts every frame
|
||||
frame += 1
|
||||
prev = cyc
|
||||
if pc.startswith('e'): # TOS, not us
|
||||
continue
|
||||
out.setdefault(frame, {}).setdefault(reg, []).append(pc)
|
||||
return out
|
||||
|
||||
|
||||
def countReg(frames, table, regs):
|
||||
return sum(len(table[f].get(r, ())) for f in frames for r in regs)
|
||||
|
||||
|
||||
full = perFrameWrites(sys.argv[1])
|
||||
base = perFrameWrites(sys.argv[2])
|
||||
prog = sys.argv[3]
|
||||
taps = int(sys.argv[4])
|
||||
if not full:
|
||||
print("verify-atarist-audio: FAIL - no application PSG writes at all", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
frames = sorted(full)
|
||||
fails = []
|
||||
|
||||
# 1. The tracker ran before any SFX overlapped it.
|
||||
baseFrames = sorted(base)
|
||||
baseTone = countReg(baseFrames, base, TONE_B_REGS)
|
||||
baseMixer = countReg(baseFrames, base, (REG_MIXER,))
|
||||
if baseTone == 0 or baseMixer == 0:
|
||||
fails.append(f"the tracker never played: {baseTone} channel-B period writes and "
|
||||
f"{baseMixer} mixer writes before the SFX. jlMusicPlay did not drive the PSG")
|
||||
|
||||
# 2. The pump ran, at the right rate. Group pump frames into contiguous runs
|
||||
# so each SFX can be told from the next.
|
||||
runs = []
|
||||
current = []
|
||||
for f in frames:
|
||||
if len(full[f].get(REG_VOL_A, ())) >= PUMP_PER_FRAME_MIN:
|
||||
current.append(f)
|
||||
elif current:
|
||||
runs.append(current)
|
||||
current = []
|
||||
if current:
|
||||
runs.append(current)
|
||||
|
||||
peak = max((len(full[f].get(REG_VOL_A, ())) for f in frames), default=0)
|
||||
if not runs:
|
||||
fails.append(f"the PWM pump never ran: peak {peak} reg-8 writes in a frame, want "
|
||||
f">= {PUMP_PER_FRAME_MIN}. jlAudioPlaySfx did not start Timer A, so "
|
||||
f"every digital sound on this port is silent")
|
||||
elif not (PUMP_RATE_LO <= peak <= PUMP_RATE_HI):
|
||||
fails.append(f"the pump ran at {peak} reg-8 writes/frame, want {PUMP_RATE_LO}-{PUMP_RATE_HI} "
|
||||
f"(12288 Hz / 50-60 fps). The MFP Timer A divider is wrong, so sampled "
|
||||
f"sound plays at the wrong pitch")
|
||||
|
||||
summary = f"{prog} tracker baseline {baseTone} toneB/{baseMixer} mixer"
|
||||
if runs:
|
||||
# 3 + 4. Inside each run, minus its first and last frame.
|
||||
interior = [f for r in runs for f in (r[1:-1] if len(r) > 2 else [])]
|
||||
writers = {}
|
||||
for f in interior:
|
||||
for pc in full[f].get(REG_VOL_A, ()):
|
||||
writers[pc] = writers.get(pc, 0) + 1
|
||||
ranked = sorted(writers.items(), key=lambda kv: -kv[1])
|
||||
isr = ranked[0][0] if ranked else None
|
||||
strayFrames = sum(1 for f in interior
|
||||
if any(pc != isr for pc in full[f].get(REG_VOL_A, ())))
|
||||
strayPct = (100.0 * strayFrames / len(interior)) if interior else 0.0
|
||||
if strayPct > STRAY_FRAME_PCT_MAX:
|
||||
others = ", ".join(f"pc={pc} x{n}" for pc, n in ranked[1:4])
|
||||
fails.append(f"reg 8 (volume A) has {len(ranked)} writers in {strayPct:.1f}% of the "
|
||||
f"pump's frames: ISR pc={isr} plus {others}. The tracker is overwriting "
|
||||
f"the PWM sample - digital and chiptune are colliding on channel A")
|
||||
|
||||
toneDuring = countReg(interior, full, TONE_B_REGS)
|
||||
if toneDuring == 0:
|
||||
fails.append("the tracker stopped writing its own channel B while the pump ran, so "
|
||||
"the SFX silenced the music instead of mixing with it")
|
||||
|
||||
summary += (f"; pump {len(runs)} runs (expected {taps}) peak {peak} reg8/frame, "
|
||||
f"{len(writers)} reg8 writer(s), stray frames {strayPct:.1f}%, "
|
||||
f"tracker toneB during pump {toneDuring}")
|
||||
else:
|
||||
summary += f"; pump never ran (peak {peak} reg8/frame)"
|
||||
|
||||
print(f"verify-atarist-audio: {summary}")
|
||||
for f in fails:
|
||||
print(f"verify-atarist-audio: {f}", file=sys.stderr)
|
||||
if fails:
|
||||
print("verify-atarist-audio: FAIL", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("verify-atarist-audio: PASS")
|
||||
PY
|
||||
176
scripts/verify-dos-audio.sh
Executable file
176
scripts/verify-dos-audio.sh
Executable file
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env bash
|
||||
# verify-dos-audio.sh - DOS audio gate, graded on recorded sound.
|
||||
#
|
||||
# The DOS HAL drives a Sound Blaster Pro/SB16 with 8237 auto-init DMA fed
|
||||
# by libxmp-lite (src/dos/audio.c). DOSBox 0.74 exposes none of that to a
|
||||
# script -- no register trace, no debugger this harness can drive -- so
|
||||
# unlike the IIgs and ST gates (which read the DOC and the YM2149) this
|
||||
# one records what actually came out and grades it. The Amiga gate works
|
||||
# the same way for the same reason, and both share tools/gradeAudioPhases.py.
|
||||
#
|
||||
# Capture path: DOSBox plays through SDL, SDL is pointed at PulseAudio,
|
||||
# and PULSE_SINK sends this ONE process to a null sink created for the
|
||||
# run, so the host's default sink is never touched and the gate is safe on
|
||||
# a desktop that is playing something else. parec records that sink's
|
||||
# monitor. Note the WAV header is only finalised when parec exits, so it
|
||||
# is stopped and waited for before the file is read.
|
||||
#
|
||||
# What is graded: the AUDIO example is driven through four phases whose
|
||||
# expected loudness differs by a wide margin.
|
||||
#
|
||||
# music the baked tracker song on entry, loud
|
||||
# paused P pressed, near silence
|
||||
# sfx SPACE during the pause, so ANY sound must be the digital SFX
|
||||
# resumed P again, loud again
|
||||
#
|
||||
# Firing the SFX into a paused song is the point: mixed under the music an
|
||||
# 8-bit one-shot barely moves the total, while against silence it is
|
||||
# unambiguous. This also covers the SMIX overlay specifically -- SFX on
|
||||
# DOS mix through libxmp-lite's smix rather than a separate channel, so
|
||||
# "SFX audible while the module player is loaded but paused" is exactly
|
||||
# the path that can break. Measured: music ~3290, paused ~113, sfx ~1341.
|
||||
#
|
||||
# Keys are HELD (keydown, pause, keyup), not tapped: the demo tests
|
||||
# jlKeyPressed, an edge predicate polled once a frame, and a make/break
|
||||
# delivered between two polls is never seen.
|
||||
#
|
||||
# Usage: scripts/verify-dos-audio.sh [example]
|
||||
# example EXE base name in build/dos/bin, default AUDIO
|
||||
# Env: DOS_AUDIO_KEEP set to keep the work dir and the WAV
|
||||
# CYCLES override the conf's 386SX-16 cpu cap
|
||||
# Requires: dosbox, Xvfb, xdotool, parec/pactl (PulseAudio or PipeWire),
|
||||
# python3, build/dos/bin/<example>.EXE
|
||||
set -uo pipefail
|
||||
|
||||
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
prog=${1:-AUDIO}
|
||||
binDir=$repo/build/dos/bin
|
||||
conf=$repo/scripts/dosbox-386sx16.conf
|
||||
grader=$repo/tools/gradeAudioPhases.py
|
||||
label=verify-dos-audio
|
||||
|
||||
[ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; }
|
||||
[ -f "$conf" ] || { echo "$label: missing $conf" >&2; exit 2; }
|
||||
for tool in dosbox Xvfb xdotool parec pactl python3; do
|
||||
command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; }
|
||||
done
|
||||
pactl info >/dev/null 2>&1 || { echo "$label: no PulseAudio/PipeWire server to record from" >&2; exit 2; }
|
||||
|
||||
# Space Taxi ships as its own folder; everything else is a bare EXE.
|
||||
if [ -f "$binDir/$prog/$prog.EXE" ]; then
|
||||
mountDir=$binDir/$prog
|
||||
elif [ -f "$binDir/$prog.EXE" ]; then
|
||||
mountDir=$binDir
|
||||
else
|
||||
echo "$label: $binDir/$prog.EXE not built (run 'make dos')" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
work=$(mktemp -d -t joeylib-dosaudio.XXXXXX)
|
||||
sinkModule=""
|
||||
display=":$((90 + RANDOM % 9))"
|
||||
xvfbPid=""
|
||||
dosPid=""
|
||||
parecPid=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$parecPid" ] && kill "$parecPid" 2>/dev/null
|
||||
[ -n "$dosPid" ] && kill "$dosPid" 2>/dev/null
|
||||
[ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null
|
||||
# Always unload the sink: a leaked null sink on the user's audio
|
||||
# server is the one side effect this gate could have.
|
||||
[ -n "$sinkModule" ] && pactl unload-module "$sinkModule" 2>/dev/null
|
||||
if [ -n "${DOS_AUDIO_KEEP:-}" ]; then
|
||||
echo "$label: kept $work" >&2
|
||||
else
|
||||
rm -rf "$work"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
sinkName="joeyDosGate$$"
|
||||
sinkModule=$(pactl load-module module-null-sink \
|
||||
sink_name="$sinkName" \
|
||||
sink_properties=device.description="$sinkName" 2>/dev/null) || sinkModule=""
|
||||
[ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; }
|
||||
|
||||
Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp >/dev/null 2>&1 &
|
||||
xvfbPid=$!
|
||||
sleep 1
|
||||
|
||||
dosArgs=(-conf "$conf")
|
||||
[ -n "${CYCLES:-}" ] && dosArgs+=(-set "cpu cycles=$CYCLES")
|
||||
dosArgs+=(
|
||||
-c "MOUNT C $mountDir"
|
||||
-c "C:"
|
||||
-c "$prog.EXE"
|
||||
)
|
||||
|
||||
DISPLAY="$display" PULSE_SINK="$sinkName" SDL_AUDIODRIVER=pulse \
|
||||
dosbox "${dosArgs[@]}" > "$work/dosbox.log" 2>&1 &
|
||||
dosPid=$!
|
||||
|
||||
sleep 12
|
||||
if ! kill -0 "$dosPid" 2>/dev/null; then
|
||||
echo "$label: FAIL - DOSBox exited before the program started" >&2
|
||||
tail -5 "$work/dosbox.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
win=$(DISPLAY="$display" xdotool search --name -- "DOSBox" | head -1)
|
||||
if [ -z "$win" ]; then
|
||||
echo "$label: FAIL - no DOSBox window to send keys to" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The title carries the running program, which is a free check that the
|
||||
# EXE actually launched rather than dropping to a DOS prompt.
|
||||
title=$(DISPLAY="$display" xdotool getwindowname "$win" 2>/dev/null)
|
||||
case "$title" in
|
||||
*"$prog"*) ;;
|
||||
*) echo "$label: FAIL - DOSBox is not running $prog (window title: $title)" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
parec --device="$sinkName.monitor" --format=s16le --rate=44100 --channels=2 \
|
||||
--file-format=wav "$work/out.wav" > "$work/parec.log" 2>&1 &
|
||||
parecPid=$!
|
||||
|
||||
DISPLAY="$display" xdotool windowactivate --sync "$win" 2>/dev/null
|
||||
|
||||
hold() { # $1 = key name
|
||||
DISPLAY="$display" xdotool keydown --window "$win" "$1"
|
||||
sleep 0.4
|
||||
DISPLAY="$display" xdotool keyup --window "$win" "$1"
|
||||
}
|
||||
|
||||
# The phase boundaries below are the timestamps handed to the grader, so
|
||||
# the sleeps and the spec must stay in step.
|
||||
sleep 6 # 0-6s music
|
||||
hold p # -> paused
|
||||
sleep 8 # 6-14s paused
|
||||
for _ in 1 2 3; do # 14-24s sfx into silence
|
||||
hold space
|
||||
sleep 1.5
|
||||
done
|
||||
sleep 4
|
||||
hold p # -> resumed
|
||||
sleep 9 # 24-33s resumed
|
||||
|
||||
# parec writes the WAV header lazily; it must exit before the file parses.
|
||||
kill "$parecPid" 2>/dev/null
|
||||
wait "$parecPid" 2>/dev/null
|
||||
parecPid=""
|
||||
|
||||
[ -s "$work/out.wav" ] || { echo "$label: FAIL - recorded no audio at all" >&2; exit 1; }
|
||||
|
||||
# Thresholds sit far from the measured values (music ~3290, paused ~113,
|
||||
# sfx ~1341) so ordinary mixing jitter cannot flip the verdict.
|
||||
python3 "$grader" "$label" "$work/out.wav" \
|
||||
"music:1:5:1500:" \
|
||||
"paused:8:13::600" \
|
||||
"sfx:16:22:700:" \
|
||||
"resumed:27:32:1500:"
|
||||
status=$?
|
||||
[ -n "${DOS_AUDIO_KEEP:-}" ] && cp "$work/out.wav" "$repo/build/dos/headless-audio.wav" 2>/dev/null
|
||||
exit $status
|
||||
|
|
@ -25,6 +25,10 @@ TEMPLATE="$SP/x68mame/HUMAN302.XDF"
|
|||
GOLDEN="${X68K_GOLDEN:-$repo/tests/goldens/uber/iigs.txt}"
|
||||
FRAMES="${X68K_GOLDEN_FRAMES:-60000}"
|
||||
WALL="${X68K_GOLDEN_WALL:-7200}"
|
||||
# Which plane the built port presents into: must match X68K_PRESENT_TEXT in
|
||||
# src/x68000/hal.c (1 -> text, 0 -> gfx). Only the completion DETECTOR cares;
|
||||
# the hashes are of the surface in RAM and are the same either way.
|
||||
PRESENT_PLANE="${X68K_PRESENT_PLANE:-text}"
|
||||
|
||||
export PATH="$repo/toolchains/x68000/m68k-xelf/bin:$PATH"
|
||||
work=$(mktemp -d -t joey-x68gold.XXXXXX)
|
||||
|
|
@ -86,10 +90,29 @@ local FRAMES = $FRAMES
|
|||
local GREEN_STABLE = 600 -- ~11 s of emulated time held steady
|
||||
local EXIT_SETTLE = 900 -- keypress -> fclose -> back at the Human68k prompt
|
||||
|
||||
-- crtmod 13, 256-colour graphics plane: one 16-bit word per pixel at \$C00000,
|
||||
-- 512 words per row, the 320x200 stage centred at origin (96, 156).
|
||||
-- Where the present puts pixels, which depends on the port's
|
||||
-- X68K_PRESENT_TEXT switch:
|
||||
-- text (default) -- crtmod 12, the four 1bpp TEXT planes at \$E00000, 128
|
||||
-- bytes per row, planes 128 KB apart. A pixel's colour is
|
||||
-- one bit from each plane at the same offset.
|
||||
-- gfx -- crtmod 13, 256-colour GRAPHICS plane at \$C00000, one
|
||||
-- 16-bit word per pixel, 512 words per row.
|
||||
-- The 320x200 stage is centred at origin (96, 156) either way.
|
||||
local presentPlane = "$PRESENT_PLANE"
|
||||
local function stagePixel(px, py)
|
||||
return mem:read_u8(0xC00000 + ((156 + py) * 512 + 96 + px) * 2 + 1)
|
||||
if presentPlane == "gfx" then
|
||||
return mem:read_u8(0xC00000 + ((156 + py) * 512 + 96 + px) * 2 + 1)
|
||||
end
|
||||
local x = 96 + px
|
||||
local byteOff = (156 + py) * 128 + (x >> 3)
|
||||
local bit = 0x80 >> (x & 7)
|
||||
local v = 0
|
||||
for plane = 0, 3 do
|
||||
if (mem:read_u8(0xE00000 + plane * 0x20000 + byteOff) & bit) ~= 0 then
|
||||
v = v | (1 << plane)
|
||||
end
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
local kProbes = { {4,4}, {160,4}, {315,4}, {4,100}, {160,100}, {315,100},
|
||||
|
|
|
|||
|
|
@ -61,8 +61,12 @@
|
|||
// whose 4-bit volume reg 0x0A is SHARED with tone voice 2 -- see the
|
||||
// guards in applyYmVoice/applyYmNoise.
|
||||
#define YM_REG_NOISE_PERIOD 6u
|
||||
#define YM_REG_VOL_A 8u
|
||||
#define YM_REG_VOL_B 9u
|
||||
#define YM_REG_VOL_C 10u
|
||||
#define YM_MIX_TONE_A 0x01u
|
||||
#define YM_MIX_TONE_C 0x04u
|
||||
#define YM_MIX_NOISE_A 0x08u
|
||||
#define YM_MIX_NOISE_C 0x20u
|
||||
|
||||
#define MFP_TA_BIT 0x20
|
||||
|
|
@ -114,17 +118,55 @@ static AudioSfxSlotT gSfxSlots[JOEY_AUDIO_SFX_SLOTS];
|
|||
// can't be read back, so this shadow IS the authoritative state.
|
||||
static volatile uint8_t gYmToneMask = 0x3Fu;
|
||||
|
||||
// Supexec'd workers take no parameters of their own, so each request is
|
||||
// stashed here first. Declared up with the module state rather than beside
|
||||
// applyYmVoice because uninstallTimerA replays a voice through them.
|
||||
static volatile uint8_t gYmVoice;
|
||||
static volatile uint16_t gYmFreqHz;
|
||||
static volatile uint8_t gYmAtten;
|
||||
static volatile uint8_t gYmNoisePitch;
|
||||
static volatile uint8_t gYmNoiseAtten;
|
||||
|
||||
// Voice 0's last requested state, latched whether or not it could be applied.
|
||||
// While the PWM pump owns channel A the voice cannot sound, and this is what
|
||||
// gets replayed to bring it back when the pump stops. Starts silent.
|
||||
static volatile uint16_t gVoiceAFreqHz;
|
||||
static volatile uint8_t gVoiceAAtten = 15u;
|
||||
|
||||
// ----- Internal helpers -----
|
||||
|
||||
// Supexec workers defined further down (with the PSG voice cluster)
|
||||
// but needed by the early lifecycle functions.
|
||||
static long ymPsgAllOff(void);
|
||||
static long applyYmVoice(void);
|
||||
|
||||
// Writing a YM register is a SELECT store then a DATA store, and the PWM ISR
|
||||
// writes register 8 on every one of its 12288 ticks a second. If it lands
|
||||
// between the two, the foreground's data byte goes to register 8 -- a click in
|
||||
// the digital output -- and the register it was aimed at never receives it,
|
||||
// which is a wrong or missing note. Nothing arbitrated that, so digital plus
|
||||
// ANY PSG activity corrupted both, on all three channels and not just the one
|
||||
// they share.
|
||||
//
|
||||
// So a foreground write masks interrupts across the pair. It costs a few
|
||||
// cycles on the handful of writes a frame rather than anything per sample,
|
||||
// which matters because the ISR already eats about half the CPU. The ISR
|
||||
// itself needs no such guard: it is the only other writer.
|
||||
static void ymWrite(uint8_t reg, uint8_t val) {
|
||||
uint16_t saved;
|
||||
|
||||
__asm__ volatile ("move.w %%sr,%0\n\tori.w #0x0700,%%sr"
|
||||
: "=d" (saved) : : "memory");
|
||||
*ST_YM_SELECT = reg;
|
||||
*ST_YM_DATA = val;
|
||||
__asm__ volatile ("move.w %0,%%sr" : : "d" (saved) : "memory");
|
||||
}
|
||||
|
||||
|
||||
// Write YM mixer reg 7 from the gYmToneMask shadow, preserving the
|
||||
// TOS I/O-port-direction high bits. Supervisor context required.
|
||||
static void ymWriteMixer(void) {
|
||||
*ST_YM_SELECT = YM_REG_MIXER;
|
||||
*ST_YM_DATA = (uint8_t)(YM_MIXER_IO_DIR | gYmToneMask);
|
||||
ymWrite(YM_REG_MIXER, (uint8_t)(YM_MIXER_IO_DIR | gYmToneMask));
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -148,6 +190,12 @@ static long installTimerA(void) {
|
|||
// by TOS for floppy / keyboard / printer).
|
||||
// reg 8 (channel A volume): start at 0 to avoid a pop at start.
|
||||
//
|
||||
// Channel A ONLY. This used to gate every tone and noise off and zero all
|
||||
// three volumes, so starting a digital sound silenced a playing chiptune
|
||||
// outright -- including channels B and C, which the DAC never touches.
|
||||
// Now B and C keep whatever the music left there and play straight
|
||||
// through, and only voice 0 stands down for the duration.
|
||||
//
|
||||
// Without the mixer setup, whatever state TOS left noise A in
|
||||
// gets gated by our 12 kHz volume writes -- if noise A was on,
|
||||
// a constant volume = constant hiss. Standard PWM-DAC trick is
|
||||
|
|
@ -159,14 +207,9 @@ static long installTimerA(void) {
|
|||
// disable bits over an assumed-safe TOS-default mask. Bit 6 set
|
||||
// (port A output) matches stock TOS; bit 7 set (port B output)
|
||||
// matches the centronics-printer direction TOS configures.
|
||||
gYmToneMask = 0x3Fu; // all tones + noise disabled (PWM-DAC default)
|
||||
ymWriteMixer(); // -> 0xFF: tones+noise off, I/O ports A+B output
|
||||
*ST_YM_SELECT = 8;
|
||||
*ST_YM_DATA = 0; // channel A volume = 0 to avoid a pop at start
|
||||
*ST_YM_SELECT = 9;
|
||||
*ST_YM_DATA = 0; // channel B volume = 0
|
||||
*ST_YM_SELECT = 10;
|
||||
*ST_YM_DATA = 0; // channel C volume = 0
|
||||
gYmToneMask = (uint8_t)(gYmToneMask | YM_MIX_TONE_A | YM_MIX_NOISE_A);
|
||||
ymWriteMixer(); // channel A gated off: its volume reg is now the DAC
|
||||
ymWrite(YM_REG_VOL_A, 0); // channel A volume = 0 to avoid a pop at start
|
||||
|
||||
// MFP Timer A: stop, install our vector, set prescaler 200 + data
|
||||
// 1 (= 2.4576 MHz / 200 = 12288 Hz), then start.
|
||||
|
|
@ -186,11 +229,16 @@ static long uninstallTimerA(void) {
|
|||
(void)Setexc(VEC_MFP_TA, (long)gOldTimerAVec);
|
||||
gOldTimerAVec = NULL;
|
||||
}
|
||||
/* Silence channel A volume so handoff back to TOS is clean (no
|
||||
* residual DC level on the speaker). */
|
||||
*ST_YM_SELECT = 8;
|
||||
*ST_YM_DATA = 0;
|
||||
return 0;
|
||||
// Channel A belongs to the PSG again. Replay voice 0's latched state,
|
||||
// which both clears the DAC's residual level (so the handoff back to TOS
|
||||
// leaves no DC on the speaker) and restores a note the music player set
|
||||
// while the pump owned the channel. Without the replay voice 0 would stay
|
||||
// dead until its next note EVENT, because src/core/music.c only reprograms
|
||||
// a voice when one arrives -- a held note would never come back.
|
||||
gYmVoice = 0u;
|
||||
gYmFreqHz = gVoiceAFreqHz;
|
||||
gYmAtten = gVoiceAAtten;
|
||||
return applyYmVoice();
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -228,8 +276,13 @@ static void pumpStop(void) {
|
|||
if (!gPumpRunning) {
|
||||
return;
|
||||
}
|
||||
Supexec(uninstallTimerA);
|
||||
// Clear the flag BEFORE the Supexec, not after: uninstallTimerA replays
|
||||
// voice 0 through applyYmVoice, and applyYmVoice stands that voice down
|
||||
// for as long as the flag says the DAC owns channel A. Stopping the timer
|
||||
// is the first thing uninstallTimerA does, so the ISR cannot fire in
|
||||
// between and the early clear costs nothing.
|
||||
gPumpRunning = false;
|
||||
Supexec(uninstallTimerA);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -249,15 +302,19 @@ bool jlpAudioInit(void) {
|
|||
|
||||
|
||||
void jlpAudioShutdown(void) {
|
||||
// PSG teardown first, independent of gReady: the tone/noise voice
|
||||
// layer is usable without jlpAudioInit, and the YM latches -- a
|
||||
// live voice keeps sounding after exit unless gated off here.
|
||||
// Pump first, then the PSG: stopping the pump hands channel A back and
|
||||
// replays voice 0, so the all-off has to come after it or a replayed note
|
||||
// would outlive the program. (The all-off still runs independent of
|
||||
// gReady: the tone/noise voice layer is usable without jlpAudioInit, and
|
||||
// the YM latches -- a live voice keeps sounding after exit.)
|
||||
if (gReady) {
|
||||
pumpStop();
|
||||
}
|
||||
Supexec(ymPsgAllOff);
|
||||
|
||||
if (!gReady) {
|
||||
return;
|
||||
}
|
||||
pumpStop();
|
||||
silenceMixBuffer();
|
||||
|
||||
if (gXmpCtx != NULL) {
|
||||
|
|
@ -351,12 +408,6 @@ void jlpAudioTone(uint16_t freqHz) {
|
|||
// YM regs are supervisor-only on the ST, so this is a Supexec block.
|
||||
// We stash the request in static globals (Supexec'd functions have
|
||||
// no parameters of their own).
|
||||
static volatile uint8_t gYmVoice;
|
||||
static volatile uint16_t gYmFreqHz;
|
||||
static volatile uint8_t gYmAtten;
|
||||
static volatile uint8_t gYmNoisePitch;
|
||||
static volatile uint8_t gYmNoiseAtten;
|
||||
// gYmToneMask is the shared YM mixer shadow, defined in module state.
|
||||
|
||||
|
||||
static long applyYmVoice(void) {
|
||||
|
|
@ -368,6 +419,15 @@ static long applyYmVoice(void) {
|
|||
toneReg = (uint8_t)(gYmVoice * 2u); // reg 0/2/4
|
||||
volReg = (uint8_t)(8u + gYmVoice); // reg 8/9/A
|
||||
|
||||
// Voice 0 and the PWM DAC are the same register (reg 8) and the same mixer
|
||||
// gate (bit 0), and they cannot be shared: enabling tone A chops the DAC's
|
||||
// output with a square wave, and the DAC's 12 kHz writes trample the
|
||||
// voice's level. While the pump runs the DAC wins and the voice waits --
|
||||
// jlpAudioVoice has latched its state, and uninstallTimerA replays it.
|
||||
if (gPumpRunning && gYmVoice == 0u) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (gYmFreqHz == 0u || gYmAtten >= 15u) {
|
||||
// Silence: disable tone on this channel (mixer bit 0/1/2 set),
|
||||
// zero volume. Don't touch other channels' bits. Channel C's
|
||||
|
|
@ -377,8 +437,7 @@ static long applyYmVoice(void) {
|
|||
gYmToneMask = (uint8_t)(gYmToneMask | ((uint8_t)1u << gYmVoice));
|
||||
ymWriteMixer();
|
||||
if (gYmVoice != 2u || (gYmToneMask & YM_MIX_NOISE_C) != 0u) {
|
||||
*ST_YM_SELECT = volReg;
|
||||
*ST_YM_DATA = 0;
|
||||
ymWrite(volReg, 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -391,14 +450,11 @@ static long applyYmVoice(void) {
|
|||
period = 0x0FFFu;
|
||||
}
|
||||
|
||||
*ST_YM_SELECT = toneReg;
|
||||
*ST_YM_DATA = (uint8_t)(period & 0xFFu);
|
||||
*ST_YM_SELECT = (uint8_t)(toneReg + 1u);
|
||||
*ST_YM_DATA = (uint8_t)((period >> 8) & 0x0Fu);
|
||||
ymWrite(toneReg, (uint8_t)(period & 0xFFu));
|
||||
ymWrite((uint8_t)(toneReg + 1u), (uint8_t)((period >> 8) & 0x0Fu));
|
||||
|
||||
vol = (uint8_t)(15u - gYmAtten);
|
||||
*ST_YM_SELECT = volReg;
|
||||
*ST_YM_DATA = (uint8_t)(vol & 0x0Fu);
|
||||
ymWrite(volReg, (uint8_t)(vol & 0x0Fu));
|
||||
|
||||
// Enable tone on this channel (clear its bit in mixer).
|
||||
gYmToneMask = (uint8_t)(gYmToneMask & ~((uint8_t)1u << gYmVoice));
|
||||
|
|
@ -418,17 +474,14 @@ static long applyYmNoise(void) {
|
|||
gYmToneMask = (uint8_t)(gYmToneMask | YM_MIX_NOISE_C);
|
||||
ymWriteMixer();
|
||||
if ((gYmToneMask & YM_MIX_TONE_C) != 0u) {
|
||||
*ST_YM_SELECT = YM_REG_VOL_C;
|
||||
*ST_YM_DATA = 0;
|
||||
ymWrite(YM_REG_VOL_C, 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Program period + volume before opening the mixer gate so the
|
||||
// gate never opens onto stale settings.
|
||||
*ST_YM_SELECT = YM_REG_NOISE_PERIOD;
|
||||
*ST_YM_DATA = (uint8_t)(gYmNoisePitch & 0x1Fu);
|
||||
*ST_YM_SELECT = YM_REG_VOL_C;
|
||||
*ST_YM_DATA = (uint8_t)((uint8_t)(15u - gYmNoiseAtten) & 0x0Fu);
|
||||
ymWrite(YM_REG_NOISE_PERIOD, (uint8_t)(gYmNoisePitch & 0x1Fu));
|
||||
ymWrite(YM_REG_VOL_C, (uint8_t)((uint8_t)(15u - gYmNoiseAtten) & 0x0Fu));
|
||||
gYmToneMask = (uint8_t)(gYmToneMask & (uint8_t)~YM_MIX_NOISE_C);
|
||||
ymWriteMixer();
|
||||
return 0;
|
||||
|
|
@ -440,14 +493,13 @@ static long applyYmNoise(void) {
|
|||
// (the chip free-runs with zero CPU) cannot keep sounding after the
|
||||
// program exits.
|
||||
static long ymPsgAllOff(void) {
|
||||
gYmToneMask = 0x3Fu;
|
||||
gYmToneMask = 0x3Fu;
|
||||
ymWriteMixer();
|
||||
*ST_YM_SELECT = 8;
|
||||
*ST_YM_DATA = 0;
|
||||
*ST_YM_SELECT = 9;
|
||||
*ST_YM_DATA = 0;
|
||||
*ST_YM_SELECT = YM_REG_VOL_C;
|
||||
*ST_YM_DATA = 0;
|
||||
ymWrite(YM_REG_VOL_A, 0);
|
||||
ymWrite(YM_REG_VOL_B, 0);
|
||||
ymWrite(YM_REG_VOL_C, 0);
|
||||
// Drop the latched voice-0 note too, or a later pump stop would replay it.
|
||||
gVoiceAAtten = 15u;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -489,12 +541,15 @@ void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) {
|
|||
if (voice >= JOEY_AUDIO_VOICES) {
|
||||
return;
|
||||
}
|
||||
// The PWM-via-volume engine in jlpAudioInit hammers YM reg 8
|
||||
// (channel A volume) at 12 kHz for MOD output. If that IRQ is
|
||||
// running, it will fight the per-voice writes here. AGI does not
|
||||
// call jlAudioInit, so Timer A is not installed and the YM is
|
||||
// ours -- but a future caller mixing modes would hear collisions
|
||||
// on channel A.
|
||||
// Digital output and chip music now coexist. The PWM engine holds channel
|
||||
// A (reg 8) as its DAC for as long as it runs, so voice 0 is latched here
|
||||
// and applied when the pump stops; voices 1 and 2 play throughout. Writes
|
||||
// are atomic against the ISR (see ymWrite), which is what makes the two
|
||||
// safe to run together at all.
|
||||
if (voice == 0u) {
|
||||
gVoiceAFreqHz = freqHz;
|
||||
gVoiceAAtten = atten;
|
||||
}
|
||||
gYmVoice = voice;
|
||||
gYmFreqHz = freqHz;
|
||||
gYmAtten = atten;
|
||||
|
|
|
|||
|
|
@ -449,19 +449,11 @@ void stageDirtyClearAll(void) {
|
|||
memset(gStageMinWord, STAGE_DIRTY_CLEAN_MIN, SURFACE_HEIGHT);
|
||||
memset(gStageMaxWord, STAGE_DIRTY_CLEAN_MAX, SURFACE_HEIGHT);
|
||||
#endif
|
||||
#if defined(JOEYLIB_PLATFORM_IIGS)
|
||||
// NOT memset: this runs after every present, and llvm-mos lowers a
|
||||
// memset to a far-called byte loop (~30 cyc/byte) -- the same trap
|
||||
// the band fill above avoids. Open-coding the 25 stores was worth
|
||||
// ~800 cycles a present, which was the whole gain of the group map.
|
||||
{
|
||||
uint8_t grp;
|
||||
|
||||
for (grp = 0u; grp < STAGE_DIRTY_GROUPS; grp++) {
|
||||
gStageDirtyGroup[grp] = 0u;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// The IIgs group bytes are NOT cleared here any more:
|
||||
// iigsStageBandsClearAll clears each one as it clears that group's rows,
|
||||
// because it has to read them anyway to decide whether those rows need
|
||||
// clearing at all. Folding it in there saves this loop (~800 cycles of
|
||||
// every present) outright.
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3231,14 +3231,15 @@ isvsRet:
|
|||
; ([dp],y) for an operand that fits the direct page, so we cannot use a
|
||||
; 16-bit bss symbol as the pointer slot. Instead, faithful to the spec's
|
||||
; "pointer arg0 that IS used" recipe, we repoint D at a bank-0 scratch
|
||||
; struct (dpxlScratch): store pixels (A:X) at dpxlScratch+0..2 and use
|
||||
; [pix],y with pix=0. Because D no longer overlays the stack frame, the
|
||||
; struct. That struct is the HARDWARE STACK, not a bss slot -- see the
|
||||
; prologue note. We push pixels (A:X) and point D at the pushed copy, then
|
||||
; use [pix],y with pix=0. Because D no longer overlays the stack frame, the
|
||||
; x,y,nibble args are read stack-relative.
|
||||
; No PHB/PLB: DBR is never written here (PERF-AUDIT #53), so the frame
|
||||
; is php(+1)+phd(+2)=+3. On JSL entry x@4,s y@6,s nib@8,s; after the 3
|
||||
; push bytes: x@7,s y@9,s nib@11,s.
|
||||
; pix=0 (D-relative, = dpxlScratch+0)
|
||||
; xx=7,s yy=9,s nib=11,s
|
||||
; No PHB/PLB: DBR is never written here (PERF-AUDIT #53), so the frame is
|
||||
; php(+1)+phd(+2)+phx(+2)+pha(+2)=+7. On JSL entry x@4,s y@6,s nib@8,s;
|
||||
; after the 7 push bytes: x@11,s y@13,s nib@15,s.
|
||||
; pix=0 (D-relative, = the pushed far pointer)
|
||||
; xx=11,s yy=13,s nib=15,s
|
||||
; pix is a 3-byte far pointer (A low16 + X bank byte); we store A then X
|
||||
; as words -> a harmless 4th filler byte in the 4-byte scratch.
|
||||
; ====================================================================
|
||||
|
|
@ -3246,7 +3247,7 @@ isvsRet:
|
|||
.section .text.iigsDrawPixelInner,"ax"
|
||||
.globl iigsDrawPixelInner
|
||||
iigsDrawPixelInner:
|
||||
pix = 0 ; pixels far ptr (D+0..2 = dpxlScratch)
|
||||
pix = 0 ; pixels far ptr (D+0..2 = stacked copy)
|
||||
|
||||
php
|
||||
phd
|
||||
|
|
@ -3254,27 +3255,27 @@ pix = 0 ; pixels far ptr (D+0..2 = dpxlScra
|
|||
.a16
|
||||
.i16
|
||||
|
||||
; Stash the pixels far pointer into the DP scratch via D-RELATIVE stores
|
||||
; (AFTER tcd): a plain `sta dpxlScratch` writes the LOAD bank (DBR) while
|
||||
; [pix],y reads through D, which is ALWAYS bank 0 -- the two disagreed and the
|
||||
; plot pointer was garbage. phx/pha preserve pix (A:X survive php/phd)
|
||||
; across the `lda #dpxlScratch` that clobbers A; pulled back so the N,s arg
|
||||
; offsets below keep their values.
|
||||
phx ; save pix bank
|
||||
pha ; save pix offset
|
||||
lda #dpxlScratch
|
||||
tcd ; D = dpxlScratch (bank 0); [pix],y == [0],y
|
||||
pla ; pix offset
|
||||
sta pix+0 ; D-relative -> bank0:dpxlScratch+0
|
||||
pla ; pix bank
|
||||
sta pix+2 ; D-relative -> bank0:dpxlScratch+2
|
||||
; STACK D-frame, not `lda #dpxlScratch; tcd`. The direct page is ALWAYS bank
|
||||
; 0, but dpxlScratch is a BSS symbol whose address is an offset in the LOAD
|
||||
; bank, so D landed on bank 0 at that offset -- memory this program does not
|
||||
; own. Stores and the [pix],y read agreed with each other, so it looked right
|
||||
; until the link layout moved that offset somewhere bank 0 would not hold it.
|
||||
; The hardware stack IS bank 0. Same fix as the tile routines and
|
||||
; iigsDrawLineInner. The 4 pushed bytes stay for the life of the call, so the
|
||||
; N,s offsets below are 4 higher than the JSL-entry figures and the exit drops
|
||||
; them before PLD.
|
||||
phx ; pix high16 (bank) -> 3,s
|
||||
pha ; pix low16 (offset) -> 1,s
|
||||
tsc
|
||||
inc a ; A = SP+1
|
||||
tcd ; D -> stacked pix; [pix],y == [0],y
|
||||
|
||||
; Compute byte offset = y*160 + (x>>1) into A. Fold the LUT row directly
|
||||
; into the add (adc gRowOffsetLut,x) -- no scratch round-trip (#53).
|
||||
lda 9,s ; y
|
||||
lda 13,s ; y
|
||||
asl a ; A = y*2 (LUT byte offset)
|
||||
tax
|
||||
lda 7,s ; x
|
||||
lda 11,s ; x
|
||||
lsr a ; A = x >> 1
|
||||
clc
|
||||
adc gRowOffsetLut,x ; A = x>>1 + y*160
|
||||
|
|
@ -3283,12 +3284,12 @@ pix = 0 ; pixels far ptr (D+0..2 = dpxlScra
|
|||
sep #0x20 ; M=8
|
||||
.a8
|
||||
|
||||
lda 7,s ; A = x low byte (parity in LSB)
|
||||
lda 11,s ; A = x low byte (parity in LSB)
|
||||
and #1
|
||||
bne dpxlOdd
|
||||
|
||||
; Even x -> high nibble.
|
||||
lda 11,s ; nibble
|
||||
lda 15,s ; nibble
|
||||
asl a
|
||||
asl a
|
||||
asl a
|
||||
|
|
@ -3302,7 +3303,7 @@ pix = 0 ; pixels far ptr (D+0..2 = dpxlScra
|
|||
|
||||
dpxlOdd:
|
||||
; Odd x -> low nibble.
|
||||
lda 11,s ; nibble
|
||||
lda 15,s ; nibble
|
||||
and #0x0F
|
||||
sta dpxlNibPart
|
||||
lda [pix],y
|
||||
|
|
@ -3315,6 +3316,8 @@ dpxlDone:
|
|||
.a16
|
||||
.a8
|
||||
.i8
|
||||
pla ; drop the stacked pix far pointer
|
||||
pla ; (4 bytes -- see the prologue note)
|
||||
pld
|
||||
plp
|
||||
rtl
|
||||
|
|
@ -3411,10 +3414,6 @@ dpxmDone:
|
|||
.globl dpxlNibPart
|
||||
dpxlNibPart:
|
||||
.zero 1
|
||||
.section .bss.dpxlScratch,"aw"
|
||||
.globl dpxlScratch
|
||||
dpxlScratch:
|
||||
.zero 4
|
||||
|
||||
|
||||
; ====================================================================
|
||||
|
|
@ -3432,14 +3431,14 @@ dpxlScratch:
|
|||
; ABI: clang cdecl. pixels arrives in A:X (dereferenced via [pix],y);
|
||||
; x0,y0,x1,y1,nibble on the stack. Same hybrid as DrawPixel: llvm-mc
|
||||
; cannot encode [dp],y from a 16-bit bss symbol, so we repoint D at a
|
||||
; bank-0 scratch struct (dlnScratch), stash pixels (A:X) at
|
||||
; dlnScratch+0..2, and use [pix],y with pix=0 in the loop. The five
|
||||
; word args are copied into DRAWPRIMS scratch up front (stack-relative),
|
||||
; so the loop never touches the stack frame again.
|
||||
; Prologue pushes: php(+1)+phb(+1)+phd(+2)=+4. On JSL entry x0@4,s
|
||||
; y0@6,s x1@8,s y1@10,s nib@12,s; after the 4 pushes:
|
||||
; x0@8,s y0@10,s x1@12,s y1@14,s nib@16,s
|
||||
; pix=0 (D-relative, = dlnScratch+0)
|
||||
; bank-0 struct -- and that struct is the HARDWARE STACK, not a bss slot
|
||||
; (see the prologue note). We push pixels (A:X) and use [pix],y with pix=0
|
||||
; in the loop. The five word args are copied into DRAWPRIMS scratch up front
|
||||
; (stack-relative), so the loop never touches the stack frame again.
|
||||
; Prologue pushes: php(+1)+phb(+1)+phd(+2)+phx(+2)+pha(+2)=+8. On JSL entry
|
||||
; x0@4,s y0@6,s x1@8,s y1@10,s nib@12,s; after the 8 pushes:
|
||||
; x0@12,s y0@14,s x1@16,s y1@18,s nib@20,s
|
||||
; pix=0 (D-relative, = the pushed far pointer)
|
||||
; ====================================================================
|
||||
|
||||
.section .text.iigsDrawLineInner,"ax"
|
||||
|
|
@ -3454,26 +3453,33 @@ pix = 0 ; pixels far ptr (D+0..2 = bank0 sc
|
|||
.a16
|
||||
.i16
|
||||
|
||||
; Stash the pixels far pointer via D-RELATIVE stores (AFTER tcd): a plain
|
||||
; `sta dlnScratch` writes the LOAD bank (DBR) while [pix],y reads through
|
||||
; D, which is ALWAYS bank 0 -- the two disagreed, so every plot went
|
||||
; through a garbage bank-0 pointer (PERF-AUDIT.md #83: wrong pixels
|
||||
; always; #86: a layout shift let the plots stomp the dln* walk state,
|
||||
; hanging the Bresenham). Identical fix to iigsDrawPixelInner /
|
||||
; iigsDrawCircleInner; phx/pha keep the N,s arg offsets unchanged.
|
||||
phx ; save pix bank
|
||||
pha ; save pix offset
|
||||
lda #dlnScratch
|
||||
tcd ; D = dlnScratch (bank 0); [pix],y == [0],y
|
||||
pla
|
||||
sta pix+0 ; D-relative -> bank0 scratch+0
|
||||
pla
|
||||
sta pix+2 ; D-relative -> bank0 scratch+2
|
||||
; Build the pix D-frame on the STACK (bank 0) -- the same fix the tile
|
||||
; routines already carry. `lda #dlnScratch; tcd` was NOT safe: the direct
|
||||
; page is ALWAYS bank 0, but dlnScratch is a BSS symbol whose address is an
|
||||
; offset in the LOAD bank, so D landed on bank 0 at that offset -- somebody
|
||||
; else's memory. The stores and the [pix],y read agreed with each other, so
|
||||
; the routine looked correct, but those four bytes lived in whatever bank 0
|
||||
; keeps at that address; once the link layout put it in the $D000+ language
|
||||
; card region the pointer stopped surviving the round trip and every plot
|
||||
; wrote into nowhere. That is what killed the corner-to-corner diagonal
|
||||
; (0,0)-(319,199): a perfect Bresenham walk, 320 plots issued, zero pixels
|
||||
; changed. The hardware stack IS bank 0, so a stack D-frame always matches.
|
||||
;
|
||||
; A = pix low16, X = pix high16. After the two pushes D+0 is the offset and
|
||||
; D+2 the bank, so `pix = 0` still names the whole far pointer, and the two
|
||||
; stores the old code needed are gone. The 4 bytes STAY on the stack for the
|
||||
; life of the call, so every N,s argument offset below is 4 higher than the
|
||||
; header's JSL-entry figures, and dlnExit drops them before PLD.
|
||||
phx ; pix high16 (bank) -> 3,s
|
||||
pha ; pix low16 (offset) -> 1,s
|
||||
tsc
|
||||
inc a ; A = SP+1
|
||||
tcd ; D -> stacked pix; [pix],y == [0],y
|
||||
|
||||
; dx = |x1 - x0|, sx = sign(x1 - x0)
|
||||
lda 12,s ; x1
|
||||
lda 16,s ; x1
|
||||
sec
|
||||
sbc 8,s ; x0
|
||||
sbc 12,s ; x0
|
||||
bpl dlnDxPos
|
||||
eor #0xFFFF
|
||||
clc
|
||||
|
|
@ -3489,9 +3495,9 @@ dlnDxPos:
|
|||
dlnDxDone:
|
||||
|
||||
; dy = -|y1 - y0|, sy = sign(y1 - y0). Bresenham uses negative dy.
|
||||
lda 14,s ; y1
|
||||
lda 18,s ; y1
|
||||
sec
|
||||
sbc 10,s ; y0
|
||||
sbc 14,s ; y0
|
||||
bpl dlnDyPos
|
||||
sta dlnDy ; A is negative; that's dy with the sign already
|
||||
lda #0xFFFF
|
||||
|
|
@ -3514,15 +3520,15 @@ dlnDyDone:
|
|||
sta dlnErr
|
||||
|
||||
; Copy x0, y0 to running state slots.
|
||||
lda 8,s ; x0
|
||||
lda 12,s ; x0
|
||||
sta dlnX
|
||||
lda 10,s ; y0
|
||||
lda 14,s ; y0
|
||||
sta dlnY
|
||||
|
||||
; Cache x1, y1 so the loop's compare is fast.
|
||||
lda 12,s ; x1
|
||||
lda 16,s ; x1
|
||||
sta dlnX1
|
||||
lda 14,s ; y1
|
||||
lda 18,s ; y1
|
||||
sta dlnY1
|
||||
|
||||
; Precompute both 16-bit nibble OR-values ONCE (stays in M=16 the
|
||||
|
|
@ -3532,7 +3538,7 @@ dlnDyDone:
|
|||
; preserved by the mask + zero OR-high-byte, so the result is identical
|
||||
; to the old 8-bit single-byte RMW. dlnNibHi/dlnNibLo have low byte =
|
||||
; (nib<<4) / nib and high byte = 0.
|
||||
lda 16,s ; nibble
|
||||
lda 20,s ; nibble
|
||||
and #0x000F
|
||||
sta dlnNibLo ; low byte = nib, high byte = 0
|
||||
asl a
|
||||
|
|
@ -3631,6 +3637,8 @@ dlnSkipY:
|
|||
brl dlnLoop
|
||||
|
||||
dlnExit:
|
||||
pla ; drop the stacked pix far pointer
|
||||
pla ; (4 bytes -- see the prologue note)
|
||||
.a8
|
||||
.i8
|
||||
pld
|
||||
|
|
@ -3691,10 +3699,6 @@ dlnNibHi:
|
|||
.globl dlnTmp
|
||||
dlnTmp:
|
||||
.zero 2
|
||||
.section .bss.dlnScratch,"aw"
|
||||
.globl dlnScratch
|
||||
dlnScratch:
|
||||
.zero 4
|
||||
|
||||
; ===== ported: circles =====
|
||||
; ================================================================
|
||||
|
|
@ -3709,25 +3713,25 @@ dlnScratch:
|
|||
;
|
||||
; ABI (llvm-mos w65816 cdecl):
|
||||
; pix -> arg0 in A:X (A=offset low16, X low byte=bank). USED via
|
||||
; [pix],y, so it must live in the direct page. We stash it
|
||||
; into bank-0 scratch dcPixPtr (offset@+0, bank@+2) BEFORE
|
||||
; setting D, then point D at dcPixPtr so the original
|
||||
; `[pix],y` (pix equ 0 -> [D+0],y) loads are byte-identical.
|
||||
; [pix],y, so it must live in the direct page. We PUSH it
|
||||
; (offset@1,s bank@3,s) and point D at the pushed copy, so the
|
||||
; original `[pix],y` (pix equ 0 -> [D+0],y) loads are
|
||||
; byte-identical. The stack is bank 0; a bss address is not.
|
||||
; php/phb/phd do not touch A/X, so A:X still hold pix when
|
||||
; we capture them after the pushes.
|
||||
; cx,cy,r,nibble -> on the stack. On JSL entry cx@4,s cy@6,s r@8,s
|
||||
; nib@10,s. After php(+1)+phb(+1)+phd(+2)=+4: cx@8,s
|
||||
; cy@10,s r@12,s nib@14,s. S never moves in the body
|
||||
; nib@10,s. After php+phb+phd+phx+pha (+8): cx@12,s
|
||||
; cy@14,s r@16,s nib@18,s. S never moves in the body
|
||||
; (no pushes in the loop), so these N,s reads stay valid.
|
||||
; They are stack reads because D hosts the pix pointer.
|
||||
; ================================================================
|
||||
.section .text.iigsDrawCircleInner,"ax"
|
||||
.globl iigsDrawCircleInner
|
||||
pix = 0 ; D-relative: D points at dcPixPtr
|
||||
dcArgCx = 8 ; cx @ 8,s after +4 prologue
|
||||
dcArgCy = 10 ; cy @ 10,s
|
||||
dcArgR = 12 ; r @ 12,s
|
||||
dcArgNib = 14 ; nibble @ 14,s
|
||||
pix = 0 ; D-relative: D points at the stacked far ptr
|
||||
dcArgCx = 12 ; cx @ 12,s after php+phb+phd+phx+pha
|
||||
dcArgCy = 14 ; cy @ 14,s
|
||||
dcArgR = 16 ; r @ 16,s
|
||||
dcArgNib = 18 ; nibble @ 18,s
|
||||
iigsDrawCircleInner:
|
||||
php
|
||||
phb
|
||||
|
|
@ -3736,22 +3740,20 @@ iigsDrawCircleInner:
|
|||
.a16
|
||||
.i16
|
||||
|
||||
; Stash the pix far pointer (still in A:X) into bank-0 scratch and
|
||||
; point D at it so [pix],y works unchanged.
|
||||
; Store pix into the DP scratch via D-RELATIVE stores (AFTER tcd): a
|
||||
; plain `sta dcPixPtr` writes the LOAD bank (DBR) while [pix],y reads
|
||||
; through D, which is ALWAYS bank 0 -- the two disagreed, so the plot
|
||||
; pointer was garbage and nothing drew. phx/pha preserve pix across the
|
||||
; `lda #dcPixPtr` that clobbers A, then are pulled back so the N,s arg
|
||||
; offsets below do not shift.
|
||||
phx ; save pix bank
|
||||
pha ; save pix offset
|
||||
lda #dcPixPtr
|
||||
tcd ; D = &dcPixPtr (bank 0); [pix],y == [0],y
|
||||
pla ; pix offset
|
||||
sta pix+0 ; D-relative -> bank0:dcPixPtr+0
|
||||
pla ; pix bank
|
||||
sta pix+2 ; D-relative -> bank0:dcPixPtr+2
|
||||
; STACK D-frame. The direct page is ALWAYS bank 0, but dcPixPtr is a BSS
|
||||
; symbol whose address is an offset in the LOAD bank, so `lda #dcPixPtr; tcd`
|
||||
; aimed D at bank 0 at that offset -- memory this program does not own. The
|
||||
; D-relative stores and the [pix],y reads agreed with each other, so it
|
||||
; looked correct until the link layout moved that offset out of reach. The
|
||||
; hardware stack IS bank 0, so park the far pointer there. Same fix as the
|
||||
; tile routines and iigsDrawLineInner. The 4 pushed bytes live for the whole
|
||||
; call, so the arg offsets above are 4 higher than the JSL-entry figures and
|
||||
; the exit drops them before PLD.
|
||||
phx ; pix high16 (bank) -> 3,s
|
||||
pha ; pix low16 (offset) -> 1,s
|
||||
tsc
|
||||
inc a ; A = SP+1
|
||||
tcd ; D -> stacked pointer; [pix],y == [0],y
|
||||
|
||||
; Cache the nibble in 8-bit form: dcNibLo = nib, dcNibHi = nib<<4.
|
||||
lda dcArgNib, s
|
||||
|
|
@ -4074,6 +4076,8 @@ dcErrGT:
|
|||
dcExit:
|
||||
.a8
|
||||
.i8
|
||||
pla ; drop the stacked far pointer
|
||||
pla ; (4 bytes -- see the prologue note)
|
||||
pld
|
||||
plb
|
||||
plp
|
||||
|
|
@ -4097,23 +4101,23 @@ dcExit:
|
|||
; software-multiply helper for a runtime color (PERF-AUDIT #41).
|
||||
;
|
||||
; ABI (llvm-mos w65816 cdecl):
|
||||
; pix -> arg0 in A:X. USED via [fpix],y, so stashed into bank-0
|
||||
; scratch fcPixPtr (offset@+0, bank@+2) and D pointed at
|
||||
; it; fpix equ 0 -> [D+0],y.
|
||||
; pix -> arg0 in A:X. USED via [fpix],y, so PUSHED (offset@1,s
|
||||
; bank@3,s) with D pointed at the pushed copy; fpix equ 0 ->
|
||||
; [D+0],y. The stack is bank 0; a bss address is not.
|
||||
; cx,cy,r,nibble -> on the stack. JSL entry cx@4,s cy@6,s r@8,s
|
||||
; nibble@10,s. After php+phb+phd (+4): cx@8,s cy@10,s
|
||||
; r@12,s nibble@14,s. S is constant in the outer body;
|
||||
; nibble@10,s. After php+phb+phd+phx+pha (+8): cx@12,s
|
||||
; cy@14,s r@16,s nibble@18,s. S is constant in the outer body;
|
||||
; the jsr-called helpers (fcMul16/fcDoSpan) only touch
|
||||
; scratch globals + [fpix],y, never N,s, so the inner
|
||||
; jsr-pushed return PC never disturbs an arg read.
|
||||
; ================================================================
|
||||
.section .text.iigsFillCircleInner,"ax"
|
||||
.globl iigsFillCircleInner
|
||||
fpix = 0 ; D-relative: D points at fcPixPtr
|
||||
fcArgCx = 8 ; cx @ 8,s after +4 prologue
|
||||
fcArgCy = 10 ; cy @ 10,s
|
||||
fcArgR = 12 ; r @ 12,s
|
||||
fcArgFill = 14 ; nibble @ 14,s
|
||||
fpix = 0 ; D-relative: D points at the stacked far ptr
|
||||
fcArgCx = 12 ; cx @ 12,s after php+phb+phd+phx+pha
|
||||
fcArgCy = 14 ; cy @ 14,s
|
||||
fcArgR = 16 ; r @ 16,s
|
||||
fcArgFill = 18 ; nibble @ 18,s
|
||||
iigsFillCircleInner:
|
||||
php
|
||||
phb
|
||||
|
|
@ -4122,18 +4126,20 @@ iigsFillCircleInner:
|
|||
.a16
|
||||
.i16
|
||||
|
||||
; Stash the pix far pointer via D-RELATIVE stores (AFTER tcd) -- a plain
|
||||
; `sta fcPixPtr` writes the LOAD bank (DBR) while [fpix],y reads through
|
||||
; D = bank 0 (the #83/#86 garbage-plot-pointer bug; same fix as
|
||||
; iigsDrawPixelInner). phx/pha keep the N,s arg offsets unchanged.
|
||||
phx ; save pix bank
|
||||
pha ; save pix offset
|
||||
lda #fcPixPtr
|
||||
tcd ; D = fcPixPtr (bank 0); [fpix],y == [0],y
|
||||
pla
|
||||
sta 0 ; D-relative -> bank0 scratch+0 (fpix lo16)
|
||||
pla
|
||||
sta 2 ; D-relative -> bank0 scratch+2 (bank byte)
|
||||
; STACK D-frame. The direct page is ALWAYS bank 0, but fcPixPtr is a BSS
|
||||
; symbol whose address is an offset in the LOAD bank, so `lda #fcPixPtr; tcd`
|
||||
; aimed D at bank 0 at that offset -- memory this program does not own. The
|
||||
; D-relative stores and the [fpix],y reads agreed with each other, so it
|
||||
; looked correct until the link layout moved that offset out of reach. The
|
||||
; hardware stack IS bank 0, so park the far pointer there. Same fix as the
|
||||
; tile routines and iigsDrawLineInner. The 4 pushed bytes live for the whole
|
||||
; call, so the arg offsets above are 4 higher than the JSL-entry figures and
|
||||
; the exit drops them before PLD.
|
||||
phx ; fpix high16 (bank) -> 3,s
|
||||
pha ; fpix low16 (offset) -> 1,s
|
||||
tsc
|
||||
inc a ; A = SP+1
|
||||
tcd ; D -> stacked pointer; [fpix],y == [0],y
|
||||
|
||||
; Derive the fill bytes from the raw nibble arg (#41).
|
||||
; fcFillByte = doubled byte (full byte fills + low nibble), e.g. $33.
|
||||
|
|
@ -4248,6 +4254,8 @@ fcContinue:
|
|||
fcExit:
|
||||
.a8
|
||||
.i8
|
||||
pla ; drop the stacked far pointer
|
||||
pla ; (4 bytes -- see the prologue note)
|
||||
pld
|
||||
plb
|
||||
plp
|
||||
|
|
@ -4290,7 +4298,7 @@ fcMulSkip:
|
|||
; trailing bytes.
|
||||
;
|
||||
; Inputs: fcLeftCol, fcRightCol, fcSpanRowBase (M=16 values).
|
||||
; Trashes A, X, Y, P. Preserves D and B (relies on D = &fcPixPtr).
|
||||
; Trashes A, X, Y, P. Preserves D and B (relies on D = the pushed fpix).
|
||||
; ----------------------------------------------------------------
|
||||
fcDoSpan:
|
||||
.a16
|
||||
|
|
@ -4433,17 +4441,15 @@ fcSbFull:
|
|||
|
||||
|
||||
; ================================================================
|
||||
; Scratch slots (bank-0 BSS). dcSavedCol is kept for byte-faithful
|
||||
; parity with the original DRAWPRIMS layout even though the inlined
|
||||
; plots no longer round-trip through it. dcPixPtr / fcPixPtr hold the
|
||||
; stashed arg0 far pointer for the [pix],y / [fpix],y direct-page
|
||||
; indirect-long loads.
|
||||
; Scratch slots (bank-0 BSS). The old dcPixPtr / fcPixPtr / dpxlScratch /
|
||||
; dlnScratch slots are gone: those held the arg0 far pointer for the
|
||||
; [pix],y / [fpix],y indirect-long loads, and a BSS address cannot be
|
||||
; loaded into D (the direct page is always bank 0, the symbol is a LOAD-bank
|
||||
; offset). Each routine now parks that pointer on the stack instead, which
|
||||
; IS bank 0. dcSavedCol went with them -- the inlined plots stopped
|
||||
; round-tripping through it long ago.
|
||||
; ================================================================
|
||||
|
||||
.section .bss.dcPixPtr,"aw"
|
||||
.globl dcPixPtr
|
||||
dcPixPtr:
|
||||
.zero 4
|
||||
|
||||
.section .bss.dcX,"aw"
|
||||
.globl dcX
|
||||
|
|
@ -4490,15 +4496,7 @@ dcRowXP:
|
|||
dcRowXN:
|
||||
.zero 2
|
||||
|
||||
.section .bss.dcSavedCol,"aw"
|
||||
.globl dcSavedCol
|
||||
dcSavedCol:
|
||||
.zero 2
|
||||
|
||||
.section .bss.fcPixPtr,"aw"
|
||||
.globl fcPixPtr
|
||||
fcPixPtr:
|
||||
.zero 4
|
||||
|
||||
.section .bss.fcX,"aw"
|
||||
.globl fcX
|
||||
|
|
@ -5403,25 +5401,77 @@ mdrExit:
|
|||
; ====================================================================
|
||||
.section .text.iigsStageBands,"ax"
|
||||
.globl iigsStageBandsClearAll
|
||||
; GROUP-SKIPPED 2026-09-25. The blind version rewrote all 400 band bytes plus
|
||||
; the 25 group bytes after EVERY present -- 200 word stores at ~12 cyc plus the
|
||||
; C group loop, about 1.14 ms of the 2.20 ms an IDLE present cost, spent
|
||||
; clearing what was already clear. The group map makes that avoidable: it is a
|
||||
; conservative hint, so a group byte of ZERO guarantees all eight of its rows
|
||||
; were never widened and their bands still hold the CLEAN sentinels. Only dirty
|
||||
; groups pay. This also clears the group bytes, which the C side no longer does.
|
||||
;
|
||||
; Skipping can only ever leave a band marked dirty (costing one redundant row
|
||||
; copy next present); it can never clear a band that should have stayed dirty,
|
||||
; so it cannot produce a stale pixel. Measured hash-neutral against the
|
||||
; unmodified build, op for op.
|
||||
;
|
||||
; Twelve group PAIRS are walked as 16-bit reads (groups 0..23 = rows 0..191);
|
||||
; group 24 (rows 192..199) is tested on its own, because a 13th pair read would
|
||||
; run one byte past the 25-byte group array and clear band bytes past row 199.
|
||||
iigsStageBandsClearAll:
|
||||
php
|
||||
rep #0x30
|
||||
.a16
|
||||
.i16
|
||||
ldx #198
|
||||
ldx #22
|
||||
isbcPair:
|
||||
lda gStageDirtyGroup,x
|
||||
beq isbcPairNext ; 16 rows untouched: already CLEAN
|
||||
stz gStageDirtyGroup,x
|
||||
txa
|
||||
asl ; group byte offset * 8 = first row
|
||||
asl
|
||||
asl
|
||||
tay
|
||||
lda #0xFFFF ; CLEAN_MIN in both bytes
|
||||
isbcMinLoop:
|
||||
sta gStageMinWord,x
|
||||
dex
|
||||
dex
|
||||
bpl isbcMinLoop
|
||||
ldx #198
|
||||
sta gStageMinWord+0,y
|
||||
sta gStageMinWord+2,y
|
||||
sta gStageMinWord+4,y
|
||||
sta gStageMinWord+6,y
|
||||
sta gStageMinWord+8,y
|
||||
sta gStageMinWord+10,y
|
||||
sta gStageMinWord+12,y
|
||||
sta gStageMinWord+14,y
|
||||
lda #0 ; CLEAN_MAX in both bytes
|
||||
isbcMaxLoop:
|
||||
sta gStageMaxWord,x
|
||||
sta gStageMaxWord+0,y
|
||||
sta gStageMaxWord+2,y
|
||||
sta gStageMaxWord+4,y
|
||||
sta gStageMaxWord+6,y
|
||||
sta gStageMaxWord+8,y
|
||||
sta gStageMaxWord+10,y
|
||||
sta gStageMaxWord+12,y
|
||||
sta gStageMaxWord+14,y
|
||||
isbcPairNext:
|
||||
dex
|
||||
dex
|
||||
bpl isbcMaxLoop
|
||||
bpl isbcPair
|
||||
sep #0x20
|
||||
.a8
|
||||
lda gStageDirtyGroup+24
|
||||
beq isbcDone
|
||||
stz gStageDirtyGroup+24
|
||||
rep #0x20
|
||||
.a16
|
||||
lda #0xFFFF
|
||||
sta gStageMinWord+192
|
||||
sta gStageMinWord+194
|
||||
sta gStageMinWord+196
|
||||
sta gStageMinWord+198
|
||||
lda #0
|
||||
sta gStageMaxWord+192
|
||||
sta gStageMaxWord+194
|
||||
sta gStageMaxWord+196
|
||||
sta gStageMaxWord+198
|
||||
isbcDone:
|
||||
plp
|
||||
rtl
|
||||
|
||||
|
|
|
|||
338
src/x68000/hal.c
338
src/x68000/hal.c
|
|
@ -42,6 +42,7 @@
|
|||
// palette this port needs for SCB per-band palettes. There is no 320x200 mode on
|
||||
// this machine, so the stage is centred and letterboxed.
|
||||
#define X68K_CRTMOD_512_256 13
|
||||
#define X68K_CRTMOD_512_16 12 // 512x512, 16 colours, 31 kHz -- text plane
|
||||
|
||||
// _iocs_bitsns key-group numbers that carry the keys jlKeyE cares about.
|
||||
#define X68K_KEYGROUP_COUNT 15
|
||||
|
|
@ -53,6 +54,31 @@
|
|||
// MC68901 MFP general-purpose I/O. Bit 4 is the CRTC's V-DISP line.
|
||||
#define X68K_MFP_GPIP ((volatile uint8_t *)0xE88001L)
|
||||
#define X68K_GPIP_VDISP 0x10u
|
||||
|
||||
// Which plane jlpPresent writes. 1 = the TEXT plane at $E00000, which is
|
||||
// PLANAR at 128 bytes per row -- the stage's own layout -- so a present is a
|
||||
// per-row copy per plane with no pixel conversion at all. 0 = the 256-colour
|
||||
// GRAPHICS plane, where a pixel owns a whole 16-bit GVRAM word and every eight
|
||||
// pixels cost sixteen table reads to assemble. Measured on the text plane:
|
||||
// 0.5 bytes of traffic per pixel against GVRAM's 2, and no gather.
|
||||
//
|
||||
// The graphics path is kept because it is the one that needs no raster
|
||||
// interrupt to honour the SCB: the 256-entry palette holds all sixteen
|
||||
// 16-colour palettes at once, so (scb << 4) | nibble picks the band by value.
|
||||
// The text plane has ONE 16-entry palette, so per-band palettes need the
|
||||
// beam-chasing interrupt below.
|
||||
#ifndef X68K_PRESENT_TEXT
|
||||
#define X68K_PRESENT_TEXT 1
|
||||
#endif
|
||||
|
||||
// CRTC register 9: the raster the compare interrupt fires on. It does not
|
||||
// reach the CPU directly -- it drives MFP GPIP6 (channel 14, vector $4E,
|
||||
// exception address $138), which is the vector _CRTCRAS installs into.
|
||||
#define X68K_CRTC_RASTER ((volatile uint16_t *)0xE80012L)
|
||||
#define X68K_MFP_IERA ((volatile uint8_t *)0xE88007L) // reg 3
|
||||
#define X68K_MFP_IMRA ((volatile uint8_t *)0xE88013L) // reg 9
|
||||
#define X68K_MFP_GPIP6 0x40u // channel 14
|
||||
#define X68K_RASTER_VEC_ADDR 0x00000138ul // vector $4E
|
||||
#define X68K_VBL_SPIN_LIMIT 2000000ul
|
||||
#define X68K_TEE_SPIN_LIMIT 200000ul
|
||||
|
||||
|
|
@ -159,12 +185,17 @@ static bool gTickHooked = false;
|
|||
static uint32_t tickHzFromMfp(void);
|
||||
uint32_t jlpMillisElapsed(void);
|
||||
uint16_t jlpFrameHz(void);
|
||||
static void buildSpreadTables(void);
|
||||
static void spreadSetPalBase(uint8_t palBase);
|
||||
static void calibrateTickHz(void);
|
||||
static void installTickIsr(void);
|
||||
|
||||
#if X68K_PRESENT_TEXT
|
||||
static void clearTextPlane(void);
|
||||
static void disarmRaster(void);
|
||||
#endif
|
||||
#if !X68K_PRESENT_TEXT
|
||||
static void buildSpreadTables(void);
|
||||
static void spreadSetPalBase(uint8_t palBase);
|
||||
static void uploadGfxPalette(const jlSurfaceT *src);
|
||||
#endif
|
||||
|
||||
// serial.c: drain the tiny (~64 B) RSDRV receive ring into the port's soft ring. Called from the
|
||||
// long-running paths below (present / VBL wait / input poll) so mid-frame serial bytes are never
|
||||
|
|
@ -188,6 +219,27 @@ bool jlpInit(const jlConfigT *config) {
|
|||
// if we were already supervisor -- in which case do not switch back.
|
||||
gPrevSsp = _dos_super(0);
|
||||
gPrevCrtMode = _iocs_crtmod(-1); // -1 queries without changing
|
||||
#if X68K_PRESENT_TEXT
|
||||
// 16 colours, because the present writes the four 1bpp TEXT planes directly
|
||||
// and that is what they are. _G_CLR_ON clears the GRAPHICS plane so it
|
||||
// cannot show through underneath, and then the text plane is wiped to get
|
||||
// rid of Human68k's desktop -- which the graphics-plane path could only
|
||||
// leave bleeding over the frame (a reg[2] &= ~0x20 write to hide it did NOT
|
||||
// take on MAME). Presenting INTO the text plane makes that bleed go away by
|
||||
// construction: we own every pixel of it.
|
||||
// Mode 13, NOT the 16-colour mode 12 you might expect for a 4bpp text
|
||||
// plane. MEASURED: crtmod 12 costs about 45% of ALL CPU throughput --
|
||||
// jlKeyDown fell 63728 -> 34624 ops/sec and jlMouseX 108679 -> 60071, and
|
||||
// those touch nothing but RAM, so it is the mode and not the drawing. Mode
|
||||
// 13 keeps full speed and displays the text plane anyway: the desktop
|
||||
// "bleeding over our frame" that this port used to document as a cosmetic
|
||||
// wart was the text plane being composited ON TOP, which is exactly what
|
||||
// we now want. _G_CLR_ON blacks the graphics plane underneath and the wipe
|
||||
// takes the desktop away.
|
||||
_iocs_crtmod(X68K_CRTMOD_512_256);
|
||||
_iocs_g_clr_on();
|
||||
clearTextPlane();
|
||||
#else
|
||||
_iocs_crtmod(X68K_CRTMOD_512_256);
|
||||
_iocs_g_clr_on(); // clear graphics + enable the plane
|
||||
// crtmod 13 leaves the video controller in 65536-colour DIRECT mode (reg[0] & 3 == 3). This port
|
||||
|
|
@ -199,6 +251,7 @@ bool jlpInit(const jlConfigT *config) {
|
|||
// plane (the Drv0-3 + kana/romaji desktop that _iocs_crtmod leaves on) bleeds over our frame. A
|
||||
// reg[2] &= ~0x20 write to hide it did NOT take on MAME (the desktop stayed visible), so that cosmetic
|
||||
// bleed is left as a follow-up; it does not affect the graphic layers we draw into.
|
||||
#endif
|
||||
// NOTE: installing vdispHandler via _iocs_vdispst HANGS the machine --
|
||||
// tested, no serial output at all, so it wedges before main() gets going.
|
||||
// IOCS does NOT wrap the handler: _VDISPST writes the pointer straight into
|
||||
|
|
@ -233,6 +286,9 @@ void jlpShutdown(void) {
|
|||
__asm__ volatile ("move.w %0,%%sr" :: "d" (sr));
|
||||
gTickHooked = false;
|
||||
}
|
||||
#if X68K_PRESENT_TEXT
|
||||
disarmRaster();
|
||||
#endif
|
||||
if (gPrevCrtMode >= 0) {
|
||||
_iocs_crtmod(gPrevCrtMode);
|
||||
}
|
||||
|
|
@ -360,6 +416,154 @@ static void calibrateTickHz(void) {
|
|||
|
||||
// ----- SCB per-band palette --------------------------------------------------
|
||||
|
||||
#if X68K_PRESENT_TEXT
|
||||
// The text plane has one 16-entry palette, so honouring the SCB means changing
|
||||
// it as the beam scans. A band is a RUN of scanlines sharing an SCB value, and
|
||||
// only transitions matter: one CRTC raster interrupt per transition, which for
|
||||
// a single-palette screen (Space Taxi reports one band) is none at all.
|
||||
//
|
||||
// SCB values are freely reusable, and reuse does NOT reduce the interrupt
|
||||
// count: an A-B-A-B screen uses two palettes but still needs a reload at every
|
||||
// change, because the hardware palette was overwritten in between. The cost is
|
||||
// the number of TRANSITIONS, worst case one per scanline.
|
||||
#define X68K_MAX_BANDS SURFACE_HEIGHT
|
||||
|
||||
extern void x68kRasterIsr(void); // src/x68000/x68kRasterIsr.s
|
||||
|
||||
// Every palette pre-converted to the hardware's word format, so the interrupt
|
||||
// service copies words and does no arithmetic. Rebuilt only when a palette
|
||||
// changes.
|
||||
static uint16_t gScbPal[SURFACE_PALETTE_COUNT][SURFACE_COLORS_PER_PALETTE];
|
||||
static uint16_t gBandLine[X68K_MAX_BANDS]; // display line the band starts on
|
||||
static uint8_t gBandPal[X68K_MAX_BANDS]; // which palette it selects
|
||||
static uint16_t gBandCount;
|
||||
static volatile uint16_t gBandNext; // service cursor
|
||||
static bool gRasterArmed;
|
||||
static uint32_t gRasterOldVec; // $138 before we took it
|
||||
|
||||
|
||||
// Interrupt service, called from the raw shim in x68kRasterIsr.s with every
|
||||
// register already saved -- which is what lets this be ordinary C. Loads the
|
||||
// current band's palette and re-points the compare register at the next band,
|
||||
// wrapping to band 0 for the next frame.
|
||||
void x68kRasterService(void) {
|
||||
const uint16_t *pal;
|
||||
uint16_t band;
|
||||
uint16_t col;
|
||||
|
||||
band = gBandNext;
|
||||
if (band >= gBandCount) {
|
||||
band = 0u;
|
||||
}
|
||||
pal = gScbPal[gBandPal[band]];
|
||||
for (col = 0u; col < SURFACE_COLORS_PER_PALETTE; col++) {
|
||||
X68K_TEXT_PALETTE[col] = pal[col];
|
||||
}
|
||||
band = (uint16_t)((band + 1u < gBandCount) ? (band + 1u) : 0u);
|
||||
gBandNext = band;
|
||||
*X68K_CRTC_RASTER = gBandLine[band];
|
||||
}
|
||||
|
||||
|
||||
// Quantise every palette once into the form the service copies.
|
||||
static void buildScbPalettes(const jlSurfaceT *src) {
|
||||
uint16_t pal;
|
||||
uint16_t col;
|
||||
|
||||
for (pal = 0u; pal < SURFACE_PALETTE_COUNT; pal++) {
|
||||
for (col = 0u; col < SURFACE_COLORS_PER_PALETTE; col++) {
|
||||
gScbPal[pal][col] = x68kColorFromRgb12(src->palette[pal][col]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// One band per RUN of equal SCB values. Lines are stored already offset into
|
||||
// display space, because that is what the compare register wants.
|
||||
static void buildScbBands(const jlSurfaceT *src) {
|
||||
uint16_t line;
|
||||
uint8_t idx;
|
||||
uint8_t prev;
|
||||
|
||||
gBandCount = 0u;
|
||||
prev = 0xFFu;
|
||||
for (line = 0u; line < SURFACE_HEIGHT; line++) {
|
||||
idx = src->scb[line];
|
||||
if (idx >= SURFACE_PALETTE_COUNT) {
|
||||
idx = 0u;
|
||||
}
|
||||
if (idx != prev) {
|
||||
if (gBandCount < X68K_MAX_BANDS) {
|
||||
gBandLine[gBandCount] = (uint16_t)(line + X68K_ORIGIN_Y);
|
||||
gBandPal[gBandCount] = idx;
|
||||
gBandCount++;
|
||||
}
|
||||
prev = idx;
|
||||
}
|
||||
}
|
||||
if (gBandCount == 0u) { // no line claimed a palette
|
||||
gBandLine[0] = X68K_ORIGIN_Y;
|
||||
gBandPal[0] = 0u;
|
||||
gBandCount = 1u;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// A single-palette screen needs no interrupt: load it once and leave the
|
||||
// hardware alone. Anything else arms the compare on band 0 and lets the
|
||||
// service walk the list.
|
||||
// Mask GPIP6 and hand $138 back. This MUST run before the program exits: the
|
||||
// handler lives in this program's RAM, so leaving the vector pointing at it
|
||||
// sends the next raster interrupt into freed memory -- the same dangling-ISR
|
||||
// trap this port already fixed for the Timer C and SCC RX vectors.
|
||||
static void disarmRaster(void) {
|
||||
uint16_t sr;
|
||||
|
||||
if (!gRasterArmed) {
|
||||
return;
|
||||
}
|
||||
__asm__ volatile ("move.w %%sr,%0\n\tori.w #0x0700,%%sr" : "=d" (sr));
|
||||
*X68K_MFP_IMRA = (uint8_t)(*X68K_MFP_IMRA & (uint8_t)~X68K_MFP_GPIP6);
|
||||
*X68K_MFP_IERA = (uint8_t)(*X68K_MFP_IERA & (uint8_t)~X68K_MFP_GPIP6);
|
||||
if (gRasterOldVec != 0ul) {
|
||||
static volatile uintptr_t addr = X68K_RASTER_VEC_ADDR;
|
||||
*(volatile uint32_t *)addr = gRasterOldVec;
|
||||
}
|
||||
__asm__ volatile ("move.w %0,%%sr" :: "d" (sr));
|
||||
gRasterArmed = false;
|
||||
}
|
||||
|
||||
|
||||
static void applyScbBands(void) {
|
||||
uint16_t col;
|
||||
|
||||
if (gBandCount <= 1u) {
|
||||
disarmRaster();
|
||||
for (col = 0u; col < SURFACE_COLORS_PER_PALETTE; col++) {
|
||||
X68K_TEXT_PALETTE[col] = gScbPal[gBandPal[0]][col];
|
||||
}
|
||||
return;
|
||||
}
|
||||
gBandNext = 0u;
|
||||
if (!gRasterArmed) {
|
||||
// _CRTCRAS takes the handler and the line, and owns enabling GPIP6 in
|
||||
// the MFP. The handler must be the raw shim, never a C function.
|
||||
// Remember the old vector first so shutdown can put it back.
|
||||
{
|
||||
static volatile uintptr_t addr = X68K_RASTER_VEC_ADDR;
|
||||
gRasterOldVec = *(volatile uint32_t *)addr;
|
||||
}
|
||||
(void)_iocs_crtcras(x68kRasterIsr, (int)gBandLine[0]);
|
||||
gRasterArmed = true;
|
||||
} else {
|
||||
*X68K_CRTC_RASTER = gBandLine[0];
|
||||
}
|
||||
}
|
||||
#endif // X68K_PRESENT_TEXT
|
||||
|
||||
#if !X68K_PRESENT_TEXT
|
||||
// ----- GRAPHICS-plane present support (only when X68K_PRESENT_TEXT is 0) -----
|
||||
|
||||
// Load all 16 palettes into the 256-entry graphics palette: entry p*16+c is
|
||||
// palette p colour c, so a pixel byte of (p << 4) | c selects it directly.
|
||||
// Rebuilt only when a palette actually changes; the SCB flag is deliberately
|
||||
|
|
@ -459,6 +663,9 @@ static void spreadSetPalBase(uint8_t palBase) {
|
|||
}
|
||||
|
||||
|
||||
#endif // !X68K_PRESENT_TEXT
|
||||
|
||||
|
||||
// ----- Present --------------------------------------------------------------
|
||||
|
||||
// Copy the surface's four planes into the TEXT PLANE at $E00000.
|
||||
|
|
@ -471,6 +678,130 @@ static void spreadSetPalBase(uint8_t palBase) {
|
|||
// measured wait states punish, so that 4x cut is the win.
|
||||
//
|
||||
// Dirty rows only, and only the marked band within a row.
|
||||
#if X68K_PRESENT_TEXT
|
||||
// Wipe the whole visible text plane once at start-up. Human68k leaves its
|
||||
// desktop there, and we only ever write the centred stage rectangle, so
|
||||
// without this the surround keeps the desktop. Long stores; one-time cost.
|
||||
static void clearTextPlane(void) {
|
||||
uint16_t plane;
|
||||
uint16_t i;
|
||||
|
||||
for (plane = 0u; plane < X68K_BITPLANES; plane++) {
|
||||
volatile uint32_t *p = (volatile uint32_t *)(X68K_TVRAM_PLANE0
|
||||
+ ((uint32_t)plane * X68K_TVRAM_PLANE_STEP));
|
||||
for (i = 0u; i < (uint16_t)(X68K_DISPLAY_H * (X68K_TVRAM_STRIDE / 4u)); i++) {
|
||||
p[i] = 0ul;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Copy one row of one plane into the text plane. Both ends sit at the same
|
||||
// offset from a 4-aligned base -- the stage plane's row stride is 40 and the
|
||||
// text plane's is 128, both multiples of 4, and the centring offset
|
||||
// X68K_ORIGIN_BYTE is 12 -- so once the leading bytes are stepped off, the rest
|
||||
// moves a long at a time with no shifting. Inline rather than memcpy because a
|
||||
// full present is 800 of these calls and the call alone was costing more than
|
||||
// the bytes.
|
||||
static inline __attribute__((always_inline))
|
||||
void x68kCopyRowToText(volatile uint8_t *dst, const uint8_t *srcRow, uint16_t count) {
|
||||
while (count != 0u && (((uintptr_t)dst & 3u) != 0u)) {
|
||||
*dst++ = *srcRow++;
|
||||
count--;
|
||||
}
|
||||
while (count >= 4u) {
|
||||
*(volatile uint32_t *)dst = *(const uint32_t *)srcRow;
|
||||
dst += 4;
|
||||
srcRow += 4;
|
||||
count -= 4u;
|
||||
}
|
||||
while (count != 0u) {
|
||||
*dst++ = *srcRow++;
|
||||
count--;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Present to the TEXT plane. The stage and the text plane are both four 1bpp
|
||||
// planes, so this is a copy -- no pixel conversion, no table, 0.5 bytes of bus
|
||||
// traffic per pixel. The SCB is honoured by the raster interrupt above rather
|
||||
// than by a per-pixel palette index.
|
||||
void jlpPresent(const jlSurfaceT *src) {
|
||||
X68kPlanarT *pd;
|
||||
const uint8_t *sp;
|
||||
uint16_t g;
|
||||
uint16_t y;
|
||||
uint16_t yEnd;
|
||||
uint16_t plane;
|
||||
uint16_t firstByte;
|
||||
uint16_t rowBytes;
|
||||
uint16_t blitRows = 0;
|
||||
|
||||
// Four rows of dirty band read as ONE long. A clean row's minWord is the
|
||||
// 0xFF CLEAN sentinel and any touched row's is <= STAGE_DIRTY_FULL_MAX, so
|
||||
// four clean rows read exactly 0xFFFFFFFF and the whole group skips. This
|
||||
// is the trick src/dos/hal.c already used and this port was missing:
|
||||
// visiting all two hundred rows one at a time cost roughly 0.9 ms of EVERY
|
||||
// present, dirty or not. A failing group still runs the per-row clean test,
|
||||
// so the rows written are byte-identical to the plain walk.
|
||||
typedef uint32_t __attribute__((may_alias)) BandGroupT;
|
||||
const BandGroupT *minGroups = (const BandGroupT *)(const void *)gStageMinWord;
|
||||
|
||||
if (src == NULL) {
|
||||
return;
|
||||
}
|
||||
vdispPoll(); // keep the frame counter honest: see the note on vdispPoll
|
||||
pd = x68kSurfacePlanar(src);
|
||||
if (pd == NULL) {
|
||||
return;
|
||||
}
|
||||
// gStagePaletteDirty and gStageScbDirty live in core/surface.c; consume
|
||||
// both here the way src/iigs/hal.c does. A palette change alters what the
|
||||
// bands load, so it forces the band apply too.
|
||||
if (gStagePaletteDirty) {
|
||||
buildScbPalettes(src);
|
||||
gStagePaletteDirty = false;
|
||||
gStageScbDirty = true;
|
||||
}
|
||||
if (gStageScbDirty) {
|
||||
buildScbBands(src);
|
||||
applyScbBands();
|
||||
gStageScbDirty = false;
|
||||
}
|
||||
x68kSerialPump();
|
||||
for (g = 0u; g < (uint16_t)(SURFACE_HEIGHT / 4u); g++) {
|
||||
if (minGroups[g] == 0xFFFFFFFFul) {
|
||||
continue;
|
||||
}
|
||||
yEnd = (uint16_t)(g * 4u + 4u);
|
||||
for (y = (uint16_t)(g * 4u); y < yEnd; y++) {
|
||||
if (!STAGE_DIRTY_ROW_TOUCHED(y) || STAGE_DIRTY_ROW_CLEAN(y)) {
|
||||
continue;
|
||||
}
|
||||
// Same pump cadence as the graphics path: a blitted row is line
|
||||
// time, so keep the 64-byte RSDRV ring from overflowing.
|
||||
if ((blitRows & 1u) == 0u) {
|
||||
x68kSerialPump();
|
||||
}
|
||||
blitRows++;
|
||||
// Bands are 16-bit stage words = 4 pixels = 2 bytes of a plane.
|
||||
firstByte = (uint16_t)(gStageMinWord[y] >> 1);
|
||||
rowBytes = (uint16_t)((gStageMaxWord[y] >> 1) - firstByte + 1u);
|
||||
if ((uint16_t)(firstByte + rowBytes) > X68K_BYTES_PER_ROW) {
|
||||
rowBytes = (uint16_t)(X68K_BYTES_PER_ROW - firstByte);
|
||||
}
|
||||
for (plane = 0u; plane < X68K_BITPLANES; plane++) {
|
||||
sp = pd->planes[plane] + ((uint16_t)y * X68K_BYTES_PER_ROW) + firstByte;
|
||||
x68kCopyRowToText((volatile uint8_t *)(X68K_TVRAM_PLANE0
|
||||
+ ((uint32_t)plane * X68K_TVRAM_PLANE_STEP)
|
||||
+ x68kTvramOffset(0u, (uint16_t)(y + X68K_ORIGIN_Y))
|
||||
+ X68K_ORIGIN_BYTE + firstByte),
|
||||
sp, rowBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
void jlpPresent(const jlSurfaceT *src) {
|
||||
X68kPlanarT *pd;
|
||||
volatile uint32_t *wordDst;
|
||||
|
|
@ -576,6 +907,7 @@ void jlpPresent(const jlSurfaceT *src) {
|
|||
}
|
||||
}
|
||||
|
||||
#endif // X68K_PRESENT_TEXT
|
||||
|
||||
// ----- Input ----------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -242,19 +242,52 @@ uint16_t jlpSerialRead(uint8_t *buf, uint16_t max) {
|
|||
}
|
||||
|
||||
|
||||
// The Z8530's register pointer is ONE register for the whole chip, shared by
|
||||
// both channels, and selecting a register is a separate access from using it.
|
||||
// So any two-access sequence has to be atomic or the two accesses belong to
|
||||
// different transactions: the interrupting write lands as DATA for whatever
|
||||
// register the interrupted code had selected, and the interrupted code's own
|
||||
// data write then goes to WR0 as a command.
|
||||
//
|
||||
// Both IOCS traps below do such a sequence on channel A, and our own channel A
|
||||
// receive ISR writes the control port to drop the interrupt-under-service. An
|
||||
// arriving byte can therefore land between a trap's pointer write and its data
|
||||
// access -- corrupting a receiver register AND losing the IUS reset, which
|
||||
// wedges the SCC's interrupt daisy chain so no further byte ever interrupts.
|
||||
// Masking around each trap closes that window.
|
||||
//
|
||||
// The mask goes around each CALL and not around the spin loop: holding it for
|
||||
// a whole byte time would be the receive latency the ISR exists to avoid. One
|
||||
// trap is a few microseconds, far inside a byte at any supported baud.
|
||||
//
|
||||
// The two INTERRUPT handlers cannot collide with each other -- IOCS's channel B
|
||||
// mouse handler and our channel A handler are the same 68000 interrupt level,
|
||||
// so taking one masks the other -- and serial.c's own direct RR2 read in
|
||||
// sccIsrInstall masks already. What is left is IOCS's internals interrupting
|
||||
// each other, which is the ROM's business and not reachable from here.
|
||||
uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) {
|
||||
uint16_t n;
|
||||
uint32_t guard;
|
||||
uint16_t sr;
|
||||
int ready;
|
||||
|
||||
for (n = 0u; n < len; n++) {
|
||||
guard = 0ul;
|
||||
while (_iocs_osns232c() == 0) {
|
||||
for (;;) {
|
||||
sr = x68kIrqMask();
|
||||
ready = _iocs_osns232c();
|
||||
x68kIrqRestore(sr);
|
||||
if (ready != 0) {
|
||||
break;
|
||||
}
|
||||
guard++;
|
||||
if (guard > X68K_TX_SPIN_LIMIT) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
sr = x68kIrqMask();
|
||||
_iocs_out232c((int)buf[n]);
|
||||
x68kIrqRestore(sr);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
|
|
|||
36
src/x68000/x68kRasterIsr.s
Normal file
36
src/x68000/x68kRasterIsr.s
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
| Sharp X68000 SCB raster interrupt.
|
||||
|
|
||||
| Per-band palettes need the palette changed as the beam scans, because the
|
||||
| text plane has ONE 16-entry hardware palette and the bands need different
|
||||
| ones at the same time. The CRTC's raster compare (register 9 at $E80012)
|
||||
| provides the trigger.
|
||||
|
|
||||
| It does NOT raise a CPU interrupt of its own: it drives the MFP's GPIP6 line,
|
||||
| which is MFP channel 14, vector $40|14 = $4E, exception address $138. That is
|
||||
| exactly the vector _CRTCRAS installs into, and IOCS does NOT wrap the
|
||||
| handler -- the pointer goes straight into the vector. So this must be a RAW
|
||||
| interrupt routine. A C function (RTS, and free to clobber d0/d1/a0/a1) can
|
||||
| never be valid there; the port already proved that by hanging the machine
|
||||
| with one on $134 (see the _VDISPST note in hal.c).
|
||||
|
|
||||
| This shim saves EVERY register, calls the C service, restores, drops the
|
||||
| MFP's in-service bit for channel 14, and returns with RTE. Saving everything
|
||||
| is exactly what lets the service side be an ordinary C function.
|
||||
|
|
||||
| GAS m68k syntax, ELF target -- symbols carry NO leading underscore.
|
||||
|
||||
.equ MFP_ISRA, 0xE8800F | MFP reg 7, in-service A
|
||||
.equ ISRA_NOT_G6, 0xBF | clear bit 6 (channel 14), keep the rest
|
||||
|
||||
.text
|
||||
.even
|
||||
.globl x68kRasterIsr
|
||||
|
||||
x68kRasterIsr:
|
||||
movem.l %d0-%d7/%a0-%a6,%sp@-
|
||||
jsr x68kRasterService
|
||||
movem.l %sp@+,%d0-%d7/%a0-%a6
|
||||
| Ack LAST: the in-service bit holds off another channel-14
|
||||
| interrupt while the service reprograms the compare register.
|
||||
move.b #ISRA_NOT_G6,MFP_ISRA
|
||||
rte
|
||||
128
tools/gradeAudioPhases.py
Executable file
128
tools/gradeAudioPhases.py
Executable file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Grade a recorded WAV against expected loudness phases.
|
||||
|
||||
Shared by scripts/verify-amiga-audio.sh and scripts/verify-dos-audio.sh.
|
||||
Both ports emit sound through hardware no emulator here will let us tap
|
||||
register by register (Paula via fs-uae, a Sound Blaster via DOSBox), so
|
||||
those gates record what actually came out and grade it. The IIgs and ST
|
||||
gates read their chips directly and do not need this.
|
||||
|
||||
The scripts drive the AUDIO example through four phases whose expected
|
||||
loudness differs by a wide margin, which is what makes recorded audio
|
||||
gradeable at all rather than a judgement call:
|
||||
|
||||
music the baked tracker song, loud
|
||||
paused P pressed: near silence
|
||||
sfx SPACE during the pause, so ANY sound is the digital SFX
|
||||
resumed P again: loud once more
|
||||
|
||||
Firing the SFX into a paused song is the whole trick. Mixed under the
|
||||
music an 8-bit one-shot barely moves the total, but against silence it is
|
||||
unambiguous -- and it still proves the two layers coexist, because the
|
||||
music comes back afterwards on the same hardware.
|
||||
|
||||
Usage:
|
||||
gradeAudioPhases.py <label> <file.wav> <phase> [<phase> ...]
|
||||
|
||||
phase = name:startSec:endSec:minRms:maxRms
|
||||
minRms or maxRms may be empty to skip that bound.
|
||||
|
||||
Exit code:
|
||||
0 = every phase inside its bounds
|
||||
1 = at least one phase out of bounds, or the file is unusable
|
||||
2 = usage error
|
||||
"""
|
||||
|
||||
import sys
|
||||
import wave
|
||||
|
||||
|
||||
def readMono(path):
|
||||
"""-> (samples as a list of floats, sample rate). Averages channels."""
|
||||
with wave.open(path, 'rb') as w:
|
||||
channels = w.getnchannels()
|
||||
width = w.getsampwidth()
|
||||
rate = w.getframerate()
|
||||
count = w.getnframes()
|
||||
raw = w.readframes(count)
|
||||
if width != 2:
|
||||
raise ValueError(f"expected 16-bit samples, got {width * 8}-bit")
|
||||
import array
|
||||
data = array.array('h')
|
||||
data.frombytes(raw[:(len(raw) // (2 * channels)) * 2 * channels])
|
||||
if channels == 1:
|
||||
return data, rate
|
||||
mono = array.array('d', [0.0]) * (len(data) // channels)
|
||||
for i in range(len(mono)):
|
||||
total = 0
|
||||
base = i * channels
|
||||
for c in range(channels):
|
||||
total += data[base + c]
|
||||
mono[i] = total / channels
|
||||
return mono, rate
|
||||
|
||||
|
||||
def rms(samples, rate, startSec, endSec):
|
||||
a = int(startSec * rate)
|
||||
b = min(int(endSec * rate), len(samples))
|
||||
if b <= a:
|
||||
return None
|
||||
total = 0.0
|
||||
for i in range(a, b):
|
||||
total += samples[i] * samples[i]
|
||||
return (total / (b - a)) ** 0.5
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 4:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
label = argv[1]
|
||||
path = argv[2]
|
||||
specs = argv[3:]
|
||||
|
||||
try:
|
||||
samples, rate = readMono(path)
|
||||
except Exception as exc: # noqa: BLE001 - reported, not handled
|
||||
print(f"{label}: FAIL - cannot read {path}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
duration = len(samples) / float(rate)
|
||||
if duration < 1.0:
|
||||
print(f"{label}: FAIL - recorded only {duration:.2f}s of audio; the capture "
|
||||
f"never attached to the emulator's output", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
fails = []
|
||||
parts = []
|
||||
for spec in specs:
|
||||
bits = spec.split(':')
|
||||
if len(bits) != 5:
|
||||
print(f"{label}: bad phase spec {spec!r}", file=sys.stderr)
|
||||
return 2
|
||||
name, t0, t1, lo, hi = bits
|
||||
level = rms(samples, rate, float(t0), float(t1))
|
||||
if level is None:
|
||||
fails.append(f"phase '{name}' ({t0}-{t1}s) is past the end of a "
|
||||
f"{duration:.1f}s recording")
|
||||
continue
|
||||
parts.append(f"{name}={level:.0f}")
|
||||
if lo and level < float(lo):
|
||||
fails.append(f"phase '{name}' RMS {level:.0f} is below {lo}: expected sound "
|
||||
f"here and got near-silence")
|
||||
if hi and level > float(hi):
|
||||
fails.append(f"phase '{name}' RMS {level:.0f} is above {hi}: expected near-silence "
|
||||
f"here and got sound")
|
||||
|
||||
print(f"{label}: {duration:.1f}s recorded, RMS " + " ".join(parts))
|
||||
for f in fails:
|
||||
print(f"{label}: {f}", file=sys.stderr)
|
||||
if fails:
|
||||
print(f"{label}: FAIL", file=sys.stderr)
|
||||
return 1
|
||||
print(f"{label}: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
||||
Loading…
Add table
Reference in a new issue