More fixes and optimizations.

This commit is contained in:
Scott Duensing 2026-09-26 19:54:15 -05:00
parent 1a7c7ec05a
commit b2ba0c33e9
12 changed files with 1528 additions and 220 deletions

View file

@ -31,6 +31,10 @@ MAME="$repo/toolchains/cache/mame-mame0264/x68k"
TEMPLATE="$SP/x68mame/HUMAN302.XDF" TEMPLATE="$SP/x68mame/HUMAN302.XDF"
FRAMES="${X68K_BENCH_FRAMES:-120000}" FRAMES="${X68K_BENCH_FRAMES:-120000}"
WALL="${X68K_BENCH_WALL:-7200}" 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" export PATH="$repo/toolchains/x68000/m68k-xelf/bin:$PATH"
work=$(mktemp -d -t joey-x68bench.XXXXXX) 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 GREEN_STABLE = 600 -- ~11 s of emulated time held steady
local EXIT_SETTLE = 900 -- keypress -> fclose -> back at the Human68k prompt 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, -- Where the present puts pixels, which depends on the port's
-- 512 words per row, the 320x200 stage centred at origin (96, 156). -- 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) local function stagePixel(px, py)
if presentPlane == "gfx" then
return mem:read_u8(0xC00000 + ((156 + py) * 512 + 96 + px) * 2 + 1) 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 end
local kProbes = { {4,4}, {160,4}, {315,4}, {4,100}, {160,100}, {315,100}, local kProbes = { {4,4}, {160,4}, {315,4}, {4,100}, {160,100}, {315,100},

181
scripts/verify-amiga-audio.sh Executable file
View 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
View 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
View 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

View file

@ -25,6 +25,10 @@ TEMPLATE="$SP/x68mame/HUMAN302.XDF"
GOLDEN="${X68K_GOLDEN:-$repo/tests/goldens/uber/iigs.txt}" GOLDEN="${X68K_GOLDEN:-$repo/tests/goldens/uber/iigs.txt}"
FRAMES="${X68K_GOLDEN_FRAMES:-60000}" FRAMES="${X68K_GOLDEN_FRAMES:-60000}"
WALL="${X68K_GOLDEN_WALL:-7200}" 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" export PATH="$repo/toolchains/x68000/m68k-xelf/bin:$PATH"
work=$(mktemp -d -t joey-x68gold.XXXXXX) 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 GREEN_STABLE = 600 -- ~11 s of emulated time held steady
local EXIT_SETTLE = 900 -- keypress -> fclose -> back at the Human68k prompt 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, -- Where the present puts pixels, which depends on the port's
-- 512 words per row, the 320x200 stage centred at origin (96, 156). -- 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) local function stagePixel(px, py)
if presentPlane == "gfx" then
return mem:read_u8(0xC00000 + ((156 + py) * 512 + 96 + px) * 2 + 1) 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 end
local kProbes = { {4,4}, {160,4}, {315,4}, {4,100}, {160,100}, {315,100}, local kProbes = { {4,4}, {160,4}, {315,4}, {4,100}, {160,100}, {315,100},

View file

@ -61,8 +61,12 @@
// whose 4-bit volume reg 0x0A is SHARED with tone voice 2 -- see the // whose 4-bit volume reg 0x0A is SHARED with tone voice 2 -- see the
// guards in applyYmVoice/applyYmNoise. // guards in applyYmVoice/applyYmNoise.
#define YM_REG_NOISE_PERIOD 6u #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_REG_VOL_C 10u
#define YM_MIX_TONE_A 0x01u
#define YM_MIX_TONE_C 0x04u #define YM_MIX_TONE_C 0x04u
#define YM_MIX_NOISE_A 0x08u
#define YM_MIX_NOISE_C 0x20u #define YM_MIX_NOISE_C 0x20u
#define MFP_TA_BIT 0x20 #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. // can't be read back, so this shadow IS the authoritative state.
static volatile uint8_t gYmToneMask = 0x3Fu; 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 ----- // ----- Internal helpers -----
// Supexec workers defined further down (with the PSG voice cluster) // Supexec workers defined further down (with the PSG voice cluster)
// but needed by the early lifecycle functions. // but needed by the early lifecycle functions.
static long ymPsgAllOff(void); 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 // Write YM mixer reg 7 from the gYmToneMask shadow, preserving the
// TOS I/O-port-direction high bits. Supervisor context required. // TOS I/O-port-direction high bits. Supervisor context required.
static void ymWriteMixer(void) { static void ymWriteMixer(void) {
*ST_YM_SELECT = YM_REG_MIXER; ymWrite(YM_REG_MIXER, (uint8_t)(YM_MIXER_IO_DIR | gYmToneMask));
*ST_YM_DATA = (uint8_t)(YM_MIXER_IO_DIR | gYmToneMask);
} }
@ -148,6 +190,12 @@ static long installTimerA(void) {
// by TOS for floppy / keyboard / printer). // by TOS for floppy / keyboard / printer).
// reg 8 (channel A volume): start at 0 to avoid a pop at start. // 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 // Without the mixer setup, whatever state TOS left noise A in
// gets gated by our 12 kHz volume writes -- if noise A was on, // gets gated by our 12 kHz volume writes -- if noise A was on,
// a constant volume = constant hiss. Standard PWM-DAC trick is // 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 // disable bits over an assumed-safe TOS-default mask. Bit 6 set
// (port A output) matches stock TOS; bit 7 set (port B output) // (port A output) matches stock TOS; bit 7 set (port B output)
// matches the centronics-printer direction TOS configures. // matches the centronics-printer direction TOS configures.
gYmToneMask = 0x3Fu; // all tones + noise disabled (PWM-DAC default) gYmToneMask = (uint8_t)(gYmToneMask | YM_MIX_TONE_A | YM_MIX_NOISE_A);
ymWriteMixer(); // -> 0xFF: tones+noise off, I/O ports A+B output ymWriteMixer(); // channel A gated off: its volume reg is now the DAC
*ST_YM_SELECT = 8; ymWrite(YM_REG_VOL_A, 0); // channel A volume = 0 to avoid a pop at start
*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
// MFP Timer A: stop, install our vector, set prescaler 200 + data // MFP Timer A: stop, install our vector, set prescaler 200 + data
// 1 (= 2.4576 MHz / 200 = 12288 Hz), then start. // 1 (= 2.4576 MHz / 200 = 12288 Hz), then start.
@ -186,11 +229,16 @@ static long uninstallTimerA(void) {
(void)Setexc(VEC_MFP_TA, (long)gOldTimerAVec); (void)Setexc(VEC_MFP_TA, (long)gOldTimerAVec);
gOldTimerAVec = NULL; gOldTimerAVec = NULL;
} }
/* Silence channel A volume so handoff back to TOS is clean (no // Channel A belongs to the PSG again. Replay voice 0's latched state,
* residual DC level on the speaker). */ // which both clears the DAC's residual level (so the handoff back to TOS
*ST_YM_SELECT = 8; // leaves no DC on the speaker) and restores a note the music player set
*ST_YM_DATA = 0; // while the pump owned the channel. Without the replay voice 0 would stay
return 0; // 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) { if (!gPumpRunning) {
return; 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; gPumpRunning = false;
Supexec(uninstallTimerA);
} }
@ -249,15 +302,19 @@ bool jlpAudioInit(void) {
void jlpAudioShutdown(void) { void jlpAudioShutdown(void) {
// PSG teardown first, independent of gReady: the tone/noise voice // Pump first, then the PSG: stopping the pump hands channel A back and
// layer is usable without jlpAudioInit, and the YM latches -- a // replays voice 0, so the all-off has to come after it or a replayed note
// live voice keeps sounding after exit unless gated off here. // 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); Supexec(ymPsgAllOff);
if (!gReady) { if (!gReady) {
return; return;
} }
pumpStop();
silenceMixBuffer(); silenceMixBuffer();
if (gXmpCtx != NULL) { 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. // 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 // We stash the request in static globals (Supexec'd functions have
// no parameters of their own). // 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) { static long applyYmVoice(void) {
@ -368,6 +419,15 @@ static long applyYmVoice(void) {
toneReg = (uint8_t)(gYmVoice * 2u); // reg 0/2/4 toneReg = (uint8_t)(gYmVoice * 2u); // reg 0/2/4
volReg = (uint8_t)(8u + gYmVoice); // reg 8/9/A 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) { if (gYmFreqHz == 0u || gYmAtten >= 15u) {
// Silence: disable tone on this channel (mixer bit 0/1/2 set), // Silence: disable tone on this channel (mixer bit 0/1/2 set),
// zero volume. Don't touch other channels' bits. Channel C's // 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)); gYmToneMask = (uint8_t)(gYmToneMask | ((uint8_t)1u << gYmVoice));
ymWriteMixer(); ymWriteMixer();
if (gYmVoice != 2u || (gYmToneMask & YM_MIX_NOISE_C) != 0u) { if (gYmVoice != 2u || (gYmToneMask & YM_MIX_NOISE_C) != 0u) {
*ST_YM_SELECT = volReg; ymWrite(volReg, 0);
*ST_YM_DATA = 0;
} }
return 0; return 0;
} }
@ -391,14 +450,11 @@ static long applyYmVoice(void) {
period = 0x0FFFu; period = 0x0FFFu;
} }
*ST_YM_SELECT = toneReg; ymWrite(toneReg, (uint8_t)(period & 0xFFu));
*ST_YM_DATA = (uint8_t)(period & 0xFFu); ymWrite((uint8_t)(toneReg + 1u), (uint8_t)((period >> 8) & 0x0Fu));
*ST_YM_SELECT = (uint8_t)(toneReg + 1u);
*ST_YM_DATA = (uint8_t)((period >> 8) & 0x0Fu);
vol = (uint8_t)(15u - gYmAtten); vol = (uint8_t)(15u - gYmAtten);
*ST_YM_SELECT = volReg; ymWrite(volReg, (uint8_t)(vol & 0x0Fu));
*ST_YM_DATA = (uint8_t)(vol & 0x0Fu);
// Enable tone on this channel (clear its bit in mixer). // Enable tone on this channel (clear its bit in mixer).
gYmToneMask = (uint8_t)(gYmToneMask & ~((uint8_t)1u << gYmVoice)); gYmToneMask = (uint8_t)(gYmToneMask & ~((uint8_t)1u << gYmVoice));
@ -418,17 +474,14 @@ static long applyYmNoise(void) {
gYmToneMask = (uint8_t)(gYmToneMask | YM_MIX_NOISE_C); gYmToneMask = (uint8_t)(gYmToneMask | YM_MIX_NOISE_C);
ymWriteMixer(); ymWriteMixer();
if ((gYmToneMask & YM_MIX_TONE_C) != 0u) { if ((gYmToneMask & YM_MIX_TONE_C) != 0u) {
*ST_YM_SELECT = YM_REG_VOL_C; ymWrite(YM_REG_VOL_C, 0);
*ST_YM_DATA = 0;
} }
return 0; return 0;
} }
// Program period + volume before opening the mixer gate so the // Program period + volume before opening the mixer gate so the
// gate never opens onto stale settings. // gate never opens onto stale settings.
*ST_YM_SELECT = YM_REG_NOISE_PERIOD; ymWrite(YM_REG_NOISE_PERIOD, (uint8_t)(gYmNoisePitch & 0x1Fu));
*ST_YM_DATA = (uint8_t)(gYmNoisePitch & 0x1Fu); ymWrite(YM_REG_VOL_C, (uint8_t)((uint8_t)(15u - gYmNoiseAtten) & 0x0Fu));
*ST_YM_SELECT = YM_REG_VOL_C;
*ST_YM_DATA = (uint8_t)((uint8_t)(15u - gYmNoiseAtten) & 0x0Fu);
gYmToneMask = (uint8_t)(gYmToneMask & (uint8_t)~YM_MIX_NOISE_C); gYmToneMask = (uint8_t)(gYmToneMask & (uint8_t)~YM_MIX_NOISE_C);
ymWriteMixer(); ymWriteMixer();
return 0; return 0;
@ -442,12 +495,11 @@ static long applyYmNoise(void) {
static long ymPsgAllOff(void) { static long ymPsgAllOff(void) {
gYmToneMask = 0x3Fu; gYmToneMask = 0x3Fu;
ymWriteMixer(); ymWriteMixer();
*ST_YM_SELECT = 8; ymWrite(YM_REG_VOL_A, 0);
*ST_YM_DATA = 0; ymWrite(YM_REG_VOL_B, 0);
*ST_YM_SELECT = 9; ymWrite(YM_REG_VOL_C, 0);
*ST_YM_DATA = 0; // Drop the latched voice-0 note too, or a later pump stop would replay it.
*ST_YM_SELECT = YM_REG_VOL_C; gVoiceAAtten = 15u;
*ST_YM_DATA = 0;
return 0; return 0;
} }
@ -489,12 +541,15 @@ void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) {
if (voice >= JOEY_AUDIO_VOICES) { if (voice >= JOEY_AUDIO_VOICES) {
return; return;
} }
// The PWM-via-volume engine in jlpAudioInit hammers YM reg 8 // Digital output and chip music now coexist. The PWM engine holds channel
// (channel A volume) at 12 kHz for MOD output. If that IRQ is // A (reg 8) as its DAC for as long as it runs, so voice 0 is latched here
// running, it will fight the per-voice writes here. AGI does not // and applied when the pump stops; voices 1 and 2 play throughout. Writes
// call jlAudioInit, so Timer A is not installed and the YM is // are atomic against the ISR (see ymWrite), which is what makes the two
// ours -- but a future caller mixing modes would hear collisions // safe to run together at all.
// on channel A. if (voice == 0u) {
gVoiceAFreqHz = freqHz;
gVoiceAAtten = atten;
}
gYmVoice = voice; gYmVoice = voice;
gYmFreqHz = freqHz; gYmFreqHz = freqHz;
gYmAtten = atten; gYmAtten = atten;

View file

@ -449,19 +449,11 @@ void stageDirtyClearAll(void) {
memset(gStageMinWord, STAGE_DIRTY_CLEAN_MIN, SURFACE_HEIGHT); memset(gStageMinWord, STAGE_DIRTY_CLEAN_MIN, SURFACE_HEIGHT);
memset(gStageMaxWord, STAGE_DIRTY_CLEAN_MAX, SURFACE_HEIGHT); memset(gStageMaxWord, STAGE_DIRTY_CLEAN_MAX, SURFACE_HEIGHT);
#endif #endif
#if defined(JOEYLIB_PLATFORM_IIGS) // The IIgs group bytes are NOT cleared here any more:
// NOT memset: this runs after every present, and llvm-mos lowers a // iigsStageBandsClearAll clears each one as it clears that group's rows,
// memset to a far-called byte loop (~30 cyc/byte) -- the same trap // because it has to read them anyway to decide whether those rows need
// the band fill above avoids. Open-coding the 25 stores was worth // clearing at all. Folding it in there saves this loop (~800 cycles of
// ~800 cycles a present, which was the whole gain of the group map. // every present) outright.
{
uint8_t grp;
for (grp = 0u; grp < STAGE_DIRTY_GROUPS; grp++) {
gStageDirtyGroup[grp] = 0u;
}
}
#endif
} }

View file

@ -3231,14 +3231,15 @@ isvsRet:
; ([dp],y) for an operand that fits the direct page, so we cannot use a ; ([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 ; 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 ; "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 ; struct. That struct is the HARDWARE STACK, not a bss slot -- see the
; [pix],y with pix=0. Because D no longer overlays the stack frame, 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. ; x,y,nibble args are read stack-relative.
; No PHB/PLB: DBR is never written here (PERF-AUDIT #53), so the frame ; No PHB/PLB: DBR is never written here (PERF-AUDIT #53), so the frame is
; is php(+1)+phd(+2)=+3. On JSL entry x@4,s y@6,s nib@8,s; after the 3 ; php(+1)+phd(+2)+phx(+2)+pha(+2)=+7. On JSL entry x@4,s y@6,s nib@8,s;
; push bytes: x@7,s y@9,s nib@11,s. ; after the 7 push bytes: x@11,s y@13,s nib@15,s.
; pix=0 (D-relative, = dpxlScratch+0) ; pix=0 (D-relative, = the pushed far pointer)
; xx=7,s yy=9,s nib=11,s ; 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 ; 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. ; as words -> a harmless 4th filler byte in the 4-byte scratch.
; ==================================================================== ; ====================================================================
@ -3246,7 +3247,7 @@ isvsRet:
.section .text.iigsDrawPixelInner,"ax" .section .text.iigsDrawPixelInner,"ax"
.globl iigsDrawPixelInner .globl iigsDrawPixelInner
iigsDrawPixelInner: iigsDrawPixelInner:
pix = 0 ; pixels far ptr (D+0..2 = dpxlScratch) pix = 0 ; pixels far ptr (D+0..2 = stacked copy)
php php
phd phd
@ -3254,27 +3255,27 @@ pix = 0 ; pixels far ptr (D+0..2 = dpxlScra
.a16 .a16
.i16 .i16
; Stash the pixels far pointer into the DP scratch via D-RELATIVE stores ; STACK D-frame, not `lda #dpxlScratch; tcd`. The direct page is ALWAYS bank
; (AFTER tcd): a plain `sta dpxlScratch` writes the LOAD bank (DBR) while ; 0, but dpxlScratch is a BSS symbol whose address is an offset in the LOAD
; [pix],y reads through D, which is ALWAYS bank 0 -- the two disagreed and the ; bank, so D landed on bank 0 at that offset -- memory this program does not
; plot pointer was garbage. phx/pha preserve pix (A:X survive php/phd) ; own. Stores and the [pix],y read agreed with each other, so it looked right
; across the `lda #dpxlScratch` that clobbers A; pulled back so the N,s arg ; until the link layout moved that offset somewhere bank 0 would not hold it.
; offsets below keep their values. ; The hardware stack IS bank 0. Same fix as the tile routines and
phx ; save pix bank ; iigsDrawLineInner. The 4 pushed bytes stay for the life of the call, so the
pha ; save pix offset ; N,s offsets below are 4 higher than the JSL-entry figures and the exit drops
lda #dpxlScratch ; them before PLD.
tcd ; D = dpxlScratch (bank 0); [pix],y == [0],y phx ; pix high16 (bank) -> 3,s
pla ; pix offset pha ; pix low16 (offset) -> 1,s
sta pix+0 ; D-relative -> bank0:dpxlScratch+0 tsc
pla ; pix bank inc a ; A = SP+1
sta pix+2 ; D-relative -> bank0:dpxlScratch+2 tcd ; D -> stacked pix; [pix],y == [0],y
; Compute byte offset = y*160 + (x>>1) into A. Fold the LUT row directly ; 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). ; 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) asl a ; A = y*2 (LUT byte offset)
tax tax
lda 7,s ; x lda 11,s ; x
lsr a ; A = x >> 1 lsr a ; A = x >> 1
clc clc
adc gRowOffsetLut,x ; A = x>>1 + y*160 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 sep #0x20 ; M=8
.a8 .a8
lda 7,s ; A = x low byte (parity in LSB) lda 11,s ; A = x low byte (parity in LSB)
and #1 and #1
bne dpxlOdd bne dpxlOdd
; Even x -> high nibble. ; Even x -> high nibble.
lda 11,s ; nibble lda 15,s ; nibble
asl a asl a
asl a asl a
asl a asl a
@ -3302,7 +3303,7 @@ pix = 0 ; pixels far ptr (D+0..2 = dpxlScra
dpxlOdd: dpxlOdd:
; Odd x -> low nibble. ; Odd x -> low nibble.
lda 11,s ; nibble lda 15,s ; nibble
and #0x0F and #0x0F
sta dpxlNibPart sta dpxlNibPart
lda [pix],y lda [pix],y
@ -3315,6 +3316,8 @@ dpxlDone:
.a16 .a16
.a8 .a8
.i8 .i8
pla ; drop the stacked pix far pointer
pla ; (4 bytes -- see the prologue note)
pld pld
plp plp
rtl rtl
@ -3411,10 +3414,6 @@ dpxmDone:
.globl dpxlNibPart .globl dpxlNibPart
dpxlNibPart: dpxlNibPart:
.zero 1 .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); ; 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 ; 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 ; 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 ; bank-0 struct -- and that struct is the HARDWARE STACK, not a bss slot
; dlnScratch+0..2, and use [pix],y with pix=0 in the loop. The five ; (see the prologue note). We push pixels (A:X) and use [pix],y with pix=0
; word args are copied into DRAWPRIMS scratch up front (stack-relative), ; in the loop. The five word args are copied into DRAWPRIMS scratch up front
; so the loop never touches the stack frame again. ; (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 ; Prologue pushes: php(+1)+phb(+1)+phd(+2)+phx(+2)+pha(+2)=+8. On JSL entry
; y0@6,s x1@8,s y1@10,s nib@12,s; after the 4 pushes: ; x0@4,s y0@6,s x1@8,s y1@10,s nib@12,s; after the 8 pushes:
; x0@8,s y0@10,s x1@12,s y1@14,s nib@16,s ; x0@12,s y0@14,s x1@16,s y1@18,s nib@20,s
; pix=0 (D-relative, = dlnScratch+0) ; pix=0 (D-relative, = the pushed far pointer)
; ==================================================================== ; ====================================================================
.section .text.iigsDrawLineInner,"ax" .section .text.iigsDrawLineInner,"ax"
@ -3454,26 +3453,33 @@ pix = 0 ; pixels far ptr (D+0..2 = bank0 sc
.a16 .a16
.i16 .i16
; Stash the pixels far pointer via D-RELATIVE stores (AFTER tcd): a plain ; Build the pix D-frame on the STACK (bank 0) -- the same fix the tile
; `sta dlnScratch` writes the LOAD bank (DBR) while [pix],y reads through ; routines already carry. `lda #dlnScratch; tcd` was NOT safe: the direct
; D, which is ALWAYS bank 0 -- the two disagreed, so every plot went ; page is ALWAYS bank 0, but dlnScratch is a BSS symbol whose address is an
; through a garbage bank-0 pointer (PERF-AUDIT.md #83: wrong pixels ; offset in the LOAD bank, so D landed on bank 0 at that offset -- somebody
; always; #86: a layout shift let the plots stomp the dln* walk state, ; else's memory. The stores and the [pix],y read agreed with each other, so
; hanging the Bresenham). Identical fix to iigsDrawPixelInner / ; the routine looked correct, but those four bytes lived in whatever bank 0
; iigsDrawCircleInner; phx/pha keep the N,s arg offsets unchanged. ; keeps at that address; once the link layout put it in the $D000+ language
phx ; save pix bank ; card region the pointer stopped surviving the round trip and every plot
pha ; save pix offset ; wrote into nowhere. That is what killed the corner-to-corner diagonal
lda #dlnScratch ; (0,0)-(319,199): a perfect Bresenham walk, 320 plots issued, zero pixels
tcd ; D = dlnScratch (bank 0); [pix],y == [0],y ; changed. The hardware stack IS bank 0, so a stack D-frame always matches.
pla ;
sta pix+0 ; D-relative -> bank0 scratch+0 ; A = pix low16, X = pix high16. After the two pushes D+0 is the offset and
pla ; D+2 the bank, so `pix = 0` still names the whole far pointer, and the two
sta pix+2 ; D-relative -> bank0 scratch+2 ; 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) ; dx = |x1 - x0|, sx = sign(x1 - x0)
lda 12,s ; x1 lda 16,s ; x1
sec sec
sbc 8,s ; x0 sbc 12,s ; x0
bpl dlnDxPos bpl dlnDxPos
eor #0xFFFF eor #0xFFFF
clc clc
@ -3489,9 +3495,9 @@ dlnDxPos:
dlnDxDone: dlnDxDone:
; dy = -|y1 - y0|, sy = sign(y1 - y0). Bresenham uses negative dy. ; dy = -|y1 - y0|, sy = sign(y1 - y0). Bresenham uses negative dy.
lda 14,s ; y1 lda 18,s ; y1
sec sec
sbc 10,s ; y0 sbc 14,s ; y0
bpl dlnDyPos bpl dlnDyPos
sta dlnDy ; A is negative; that's dy with the sign already sta dlnDy ; A is negative; that's dy with the sign already
lda #0xFFFF lda #0xFFFF
@ -3514,15 +3520,15 @@ dlnDyDone:
sta dlnErr sta dlnErr
; Copy x0, y0 to running state slots. ; Copy x0, y0 to running state slots.
lda 8,s ; x0 lda 12,s ; x0
sta dlnX sta dlnX
lda 10,s ; y0 lda 14,s ; y0
sta dlnY sta dlnY
; Cache x1, y1 so the loop's compare is fast. ; Cache x1, y1 so the loop's compare is fast.
lda 12,s ; x1 lda 16,s ; x1
sta dlnX1 sta dlnX1
lda 14,s ; y1 lda 18,s ; y1
sta dlnY1 sta dlnY1
; Precompute both 16-bit nibble OR-values ONCE (stays in M=16 the ; 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 ; 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 = ; to the old 8-bit single-byte RMW. dlnNibHi/dlnNibLo have low byte =
; (nib<<4) / nib and high byte = 0. ; (nib<<4) / nib and high byte = 0.
lda 16,s ; nibble lda 20,s ; nibble
and #0x000F and #0x000F
sta dlnNibLo ; low byte = nib, high byte = 0 sta dlnNibLo ; low byte = nib, high byte = 0
asl a asl a
@ -3631,6 +3637,8 @@ dlnSkipY:
brl dlnLoop brl dlnLoop
dlnExit: dlnExit:
pla ; drop the stacked pix far pointer
pla ; (4 bytes -- see the prologue note)
.a8 .a8
.i8 .i8
pld pld
@ -3691,10 +3699,6 @@ dlnNibHi:
.globl dlnTmp .globl dlnTmp
dlnTmp: dlnTmp:
.zero 2 .zero 2
.section .bss.dlnScratch,"aw"
.globl dlnScratch
dlnScratch:
.zero 4
; ===== ported: circles ===== ; ===== ported: circles =====
; ================================================================ ; ================================================================
@ -3709,25 +3713,25 @@ dlnScratch:
; ;
; ABI (llvm-mos w65816 cdecl): ; ABI (llvm-mos w65816 cdecl):
; pix -> arg0 in A:X (A=offset low16, X low byte=bank). USED via ; 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 ; [pix],y, so it must live in the direct page. We PUSH it
; into bank-0 scratch dcPixPtr (offset@+0, bank@+2) BEFORE ; (offset@1,s bank@3,s) and point D at the pushed copy, so the
; setting D, then point D at dcPixPtr so the original ; original `[pix],y` (pix equ 0 -> [D+0],y) loads are
; `[pix],y` (pix equ 0 -> [D+0],y) loads are byte-identical. ; 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 ; php/phb/phd do not touch A/X, so A:X still hold pix when
; we capture them after the pushes. ; 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 ; 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 ; nib@10,s. After php+phb+phd+phx+pha (+8): cx@12,s
; cy@10,s r@12,s nib@14,s. S never moves in the body ; 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. ; (no pushes in the loop), so these N,s reads stay valid.
; They are stack reads because D hosts the pix pointer. ; They are stack reads because D hosts the pix pointer.
; ================================================================ ; ================================================================
.section .text.iigsDrawCircleInner,"ax" .section .text.iigsDrawCircleInner,"ax"
.globl iigsDrawCircleInner .globl iigsDrawCircleInner
pix = 0 ; D-relative: D points at dcPixPtr pix = 0 ; D-relative: D points at the stacked far ptr
dcArgCx = 8 ; cx @ 8,s after +4 prologue dcArgCx = 12 ; cx @ 12,s after php+phb+phd+phx+pha
dcArgCy = 10 ; cy @ 10,s dcArgCy = 14 ; cy @ 14,s
dcArgR = 12 ; r @ 12,s dcArgR = 16 ; r @ 16,s
dcArgNib = 14 ; nibble @ 14,s dcArgNib = 18 ; nibble @ 18,s
iigsDrawCircleInner: iigsDrawCircleInner:
php php
phb phb
@ -3736,22 +3740,20 @@ iigsDrawCircleInner:
.a16 .a16
.i16 .i16
; Stash the pix far pointer (still in A:X) into bank-0 scratch and ; STACK D-frame. The direct page is ALWAYS bank 0, but dcPixPtr is a BSS
; point D at it so [pix],y works unchanged. ; symbol whose address is an offset in the LOAD bank, so `lda #dcPixPtr; tcd`
; Store pix into the DP scratch via D-RELATIVE stores (AFTER tcd): a ; aimed D at bank 0 at that offset -- memory this program does not own. The
; plain `sta dcPixPtr` writes the LOAD bank (DBR) while [pix],y reads ; D-relative stores and the [pix],y reads agreed with each other, so it
; through D, which is ALWAYS bank 0 -- the two disagreed, so the plot ; looked correct until the link layout moved that offset out of reach. The
; pointer was garbage and nothing drew. phx/pha preserve pix across the ; hardware stack IS bank 0, so park the far pointer there. Same fix as the
; `lda #dcPixPtr` that clobbers A, then are pulled back so the N,s arg ; tile routines and iigsDrawLineInner. The 4 pushed bytes live for the whole
; offsets below do not shift. ; call, so the arg offsets above are 4 higher than the JSL-entry figures and
phx ; save pix bank ; the exit drops them before PLD.
pha ; save pix offset phx ; pix high16 (bank) -> 3,s
lda #dcPixPtr pha ; pix low16 (offset) -> 1,s
tcd ; D = &dcPixPtr (bank 0); [pix],y == [0],y tsc
pla ; pix offset inc a ; A = SP+1
sta pix+0 ; D-relative -> bank0:dcPixPtr+0 tcd ; D -> stacked pointer; [pix],y == [0],y
pla ; pix bank
sta pix+2 ; D-relative -> bank0:dcPixPtr+2
; Cache the nibble in 8-bit form: dcNibLo = nib, dcNibHi = nib<<4. ; Cache the nibble in 8-bit form: dcNibLo = nib, dcNibHi = nib<<4.
lda dcArgNib, s lda dcArgNib, s
@ -4074,6 +4076,8 @@ dcErrGT:
dcExit: dcExit:
.a8 .a8
.i8 .i8
pla ; drop the stacked far pointer
pla ; (4 bytes -- see the prologue note)
pld pld
plb plb
plp plp
@ -4097,23 +4101,23 @@ dcExit:
; software-multiply helper for a runtime color (PERF-AUDIT #41). ; software-multiply helper for a runtime color (PERF-AUDIT #41).
; ;
; ABI (llvm-mos w65816 cdecl): ; ABI (llvm-mos w65816 cdecl):
; pix -> arg0 in A:X. USED via [fpix],y, so stashed into bank-0 ; pix -> arg0 in A:X. USED via [fpix],y, so PUSHED (offset@1,s
; scratch fcPixPtr (offset@+0, bank@+2) and D pointed at ; bank@3,s) with D pointed at the pushed copy; fpix equ 0 ->
; it; fpix equ 0 -> [D+0],y. ; [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 ; 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 ; nibble@10,s. After php+phb+phd+phx+pha (+8): cx@12,s
; r@12,s nibble@14,s. S is constant in the outer body; ; cy@14,s r@16,s nibble@18,s. S is constant in the outer body;
; the jsr-called helpers (fcMul16/fcDoSpan) only touch ; the jsr-called helpers (fcMul16/fcDoSpan) only touch
; scratch globals + [fpix],y, never N,s, so the inner ; scratch globals + [fpix],y, never N,s, so the inner
; jsr-pushed return PC never disturbs an arg read. ; jsr-pushed return PC never disturbs an arg read.
; ================================================================ ; ================================================================
.section .text.iigsFillCircleInner,"ax" .section .text.iigsFillCircleInner,"ax"
.globl iigsFillCircleInner .globl iigsFillCircleInner
fpix = 0 ; D-relative: D points at fcPixPtr fpix = 0 ; D-relative: D points at the stacked far ptr
fcArgCx = 8 ; cx @ 8,s after +4 prologue fcArgCx = 12 ; cx @ 12,s after php+phb+phd+phx+pha
fcArgCy = 10 ; cy @ 10,s fcArgCy = 14 ; cy @ 14,s
fcArgR = 12 ; r @ 12,s fcArgR = 16 ; r @ 16,s
fcArgFill = 14 ; nibble @ 14,s fcArgFill = 18 ; nibble @ 18,s
iigsFillCircleInner: iigsFillCircleInner:
php php
phb phb
@ -4122,18 +4126,20 @@ iigsFillCircleInner:
.a16 .a16
.i16 .i16
; Stash the pix far pointer via D-RELATIVE stores (AFTER tcd) -- a plain ; STACK D-frame. The direct page is ALWAYS bank 0, but fcPixPtr is a BSS
; `sta fcPixPtr` writes the LOAD bank (DBR) while [fpix],y reads through ; symbol whose address is an offset in the LOAD bank, so `lda #fcPixPtr; tcd`
; D = bank 0 (the #83/#86 garbage-plot-pointer bug; same fix as ; aimed D at bank 0 at that offset -- memory this program does not own. The
; iigsDrawPixelInner). phx/pha keep the N,s arg offsets unchanged. ; D-relative stores and the [fpix],y reads agreed with each other, so it
phx ; save pix bank ; looked correct until the link layout moved that offset out of reach. The
pha ; save pix offset ; hardware stack IS bank 0, so park the far pointer there. Same fix as the
lda #fcPixPtr ; tile routines and iigsDrawLineInner. The 4 pushed bytes live for the whole
tcd ; D = fcPixPtr (bank 0); [fpix],y == [0],y ; call, so the arg offsets above are 4 higher than the JSL-entry figures and
pla ; the exit drops them before PLD.
sta 0 ; D-relative -> bank0 scratch+0 (fpix lo16) phx ; fpix high16 (bank) -> 3,s
pla pha ; fpix low16 (offset) -> 1,s
sta 2 ; D-relative -> bank0 scratch+2 (bank byte) tsc
inc a ; A = SP+1
tcd ; D -> stacked pointer; [fpix],y == [0],y
; Derive the fill bytes from the raw nibble arg (#41). ; Derive the fill bytes from the raw nibble arg (#41).
; fcFillByte = doubled byte (full byte fills + low nibble), e.g. $33. ; fcFillByte = doubled byte (full byte fills + low nibble), e.g. $33.
@ -4248,6 +4254,8 @@ fcContinue:
fcExit: fcExit:
.a8 .a8
.i8 .i8
pla ; drop the stacked far pointer
pla ; (4 bytes -- see the prologue note)
pld pld
plb plb
plp plp
@ -4290,7 +4298,7 @@ fcMulSkip:
; trailing bytes. ; trailing bytes.
; ;
; Inputs: fcLeftCol, fcRightCol, fcSpanRowBase (M=16 values). ; 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: fcDoSpan:
.a16 .a16
@ -4433,17 +4441,15 @@ fcSbFull:
; ================================================================ ; ================================================================
; Scratch slots (bank-0 BSS). dcSavedCol is kept for byte-faithful ; Scratch slots (bank-0 BSS). The old dcPixPtr / fcPixPtr / dpxlScratch /
; parity with the original DRAWPRIMS layout even though the inlined ; dlnScratch slots are gone: those held the arg0 far pointer for the
; plots no longer round-trip through it. dcPixPtr / fcPixPtr hold the ; [pix],y / [fpix],y indirect-long loads, and a BSS address cannot be
; stashed arg0 far pointer for the [pix],y / [fpix],y direct-page ; loaded into D (the direct page is always bank 0, the symbol is a LOAD-bank
; indirect-long loads. ; 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" .section .bss.dcX,"aw"
.globl dcX .globl dcX
@ -4490,15 +4496,7 @@ dcRowXP:
dcRowXN: dcRowXN:
.zero 2 .zero 2
.section .bss.dcSavedCol,"aw"
.globl dcSavedCol
dcSavedCol:
.zero 2
.section .bss.fcPixPtr,"aw"
.globl fcPixPtr
fcPixPtr:
.zero 4
.section .bss.fcX,"aw" .section .bss.fcX,"aw"
.globl fcX .globl fcX
@ -5403,25 +5401,77 @@ mdrExit:
; ==================================================================== ; ====================================================================
.section .text.iigsStageBands,"ax" .section .text.iigsStageBands,"ax"
.globl iigsStageBandsClearAll .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: iigsStageBandsClearAll:
php php
rep #0x30 rep #0x30
.a16 .a16
.i16 .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 lda #0xFFFF ; CLEAN_MIN in both bytes
isbcMinLoop: sta gStageMinWord+0,y
sta gStageMinWord,x sta gStageMinWord+2,y
dex sta gStageMinWord+4,y
dex sta gStageMinWord+6,y
bpl isbcMinLoop sta gStageMinWord+8,y
ldx #198 sta gStageMinWord+10,y
sta gStageMinWord+12,y
sta gStageMinWord+14,y
lda #0 ; CLEAN_MAX in both bytes lda #0 ; CLEAN_MAX in both bytes
isbcMaxLoop: sta gStageMaxWord+0,y
sta gStageMaxWord,x 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
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 plp
rtl rtl

View file

@ -42,6 +42,7 @@
// palette this port needs for SCB per-band palettes. There is no 320x200 mode on // palette this port needs for SCB per-band palettes. There is no 320x200 mode on
// this machine, so the stage is centred and letterboxed. // this machine, so the stage is centred and letterboxed.
#define X68K_CRTMOD_512_256 13 #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. // _iocs_bitsns key-group numbers that carry the keys jlKeyE cares about.
#define X68K_KEYGROUP_COUNT 15 #define X68K_KEYGROUP_COUNT 15
@ -53,6 +54,31 @@
// MC68901 MFP general-purpose I/O. Bit 4 is the CRTC's V-DISP line. // 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_MFP_GPIP ((volatile uint8_t *)0xE88001L)
#define X68K_GPIP_VDISP 0x10u #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_VBL_SPIN_LIMIT 2000000ul
#define X68K_TEE_SPIN_LIMIT 200000ul #define X68K_TEE_SPIN_LIMIT 200000ul
@ -159,12 +185,17 @@ static bool gTickHooked = false;
static uint32_t tickHzFromMfp(void); static uint32_t tickHzFromMfp(void);
uint32_t jlpMillisElapsed(void); uint32_t jlpMillisElapsed(void);
uint16_t jlpFrameHz(void); uint16_t jlpFrameHz(void);
static void buildSpreadTables(void);
static void spreadSetPalBase(uint8_t palBase);
static void calibrateTickHz(void); static void calibrateTickHz(void);
static void installTickIsr(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); 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 // 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 // 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. // if we were already supervisor -- in which case do not switch back.
gPrevSsp = _dos_super(0); gPrevSsp = _dos_super(0);
gPrevCrtMode = _iocs_crtmod(-1); // -1 queries without changing 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_crtmod(X68K_CRTMOD_512_256);
_iocs_g_clr_on(); // clear graphics + enable the plane _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 // 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 // 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 // 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. // 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 -- // NOTE: installing vdispHandler via _iocs_vdispst HANGS the machine --
// tested, no serial output at all, so it wedges before main() gets going. // 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 // 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)); __asm__ volatile ("move.w %0,%%sr" :: "d" (sr));
gTickHooked = false; gTickHooked = false;
} }
#if X68K_PRESENT_TEXT
disarmRaster();
#endif
if (gPrevCrtMode >= 0) { if (gPrevCrtMode >= 0) {
_iocs_crtmod(gPrevCrtMode); _iocs_crtmod(gPrevCrtMode);
} }
@ -360,6 +416,154 @@ static void calibrateTickHz(void) {
// ----- SCB per-band palette -------------------------------------------------- // ----- 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 // 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. // 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 // 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 -------------------------------------------------------------- // ----- Present --------------------------------------------------------------
// Copy the surface's four planes into the TEXT PLANE at $E00000. // 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. // measured wait states punish, so that 4x cut is the win.
// //
// Dirty rows only, and only the marked band within a row. // 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) { void jlpPresent(const jlSurfaceT *src) {
X68kPlanarT *pd; X68kPlanarT *pd;
volatile uint32_t *wordDst; volatile uint32_t *wordDst;
@ -576,6 +907,7 @@ void jlpPresent(const jlSurfaceT *src) {
} }
} }
#endif // X68K_PRESENT_TEXT
// ----- Input ---------------------------------------------------------------- // ----- Input ----------------------------------------------------------------

View file

@ -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 jlpSerialWrite(const uint8_t *buf, uint16_t len) {
uint16_t n; uint16_t n;
uint32_t guard; uint32_t guard;
uint16_t sr;
int ready;
for (n = 0u; n < len; n++) { for (n = 0u; n < len; n++) {
guard = 0ul; guard = 0ul;
while (_iocs_osns232c() == 0) { for (;;) {
sr = x68kIrqMask();
ready = _iocs_osns232c();
x68kIrqRestore(sr);
if (ready != 0) {
break;
}
guard++; guard++;
if (guard > X68K_TX_SPIN_LIMIT) { if (guard > X68K_TX_SPIN_LIMIT) {
return n; return n;
} }
} }
sr = x68kIrqMask();
_iocs_out232c((int)buf[n]); _iocs_out232c((int)buf[n]);
x68kIrqRestore(sr);
} }
return n; return n;
} }

View 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
View 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))