From b2ba0c33e9d7a531a51279e84dd70ea474c3a644 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Sat, 26 Sep 2026 19:54:15 -0500 Subject: [PATCH] More fixes and optimizations. --- scripts/bench-x68000.sh | 29 ++- scripts/verify-amiga-audio.sh | 181 +++++++++++++++++ scripts/verify-atarist-audio.sh | 279 ++++++++++++++++++++++++++ scripts/verify-dos-audio.sh | 176 +++++++++++++++++ scripts/verify-x68000-golden.sh | 29 ++- src/atarist/audio.c | 161 ++++++++++----- src/core/surface.c | 18 +- src/iigs/joeyDraw.s | 338 ++++++++++++++++++-------------- src/x68000/hal.c | 338 +++++++++++++++++++++++++++++++- src/x68000/serial.c | 35 +++- src/x68000/x68kRasterIsr.s | 36 ++++ tools/gradeAudioPhases.py | 128 ++++++++++++ 12 files changed, 1528 insertions(+), 220 deletions(-) create mode 100755 scripts/verify-amiga-audio.sh create mode 100755 scripts/verify-atarist-audio.sh create mode 100755 scripts/verify-dos-audio.sh create mode 100644 src/x68000/x68kRasterIsr.s create mode 100755 tools/gradeAudioPhases.py diff --git a/scripts/bench-x68000.sh b/scripts/bench-x68000.sh index 8377b40..0bcfc6d 100755 --- a/scripts/bench-x68000.sh +++ b/scripts/bench-x68000.sh @@ -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}, diff --git a/scripts/verify-amiga-audio.sh b/scripts/verify-amiga-audio.sh new file mode 100755 index 0000000..4e58f6d --- /dev/null +++ b/scripts/verify-amiga-audio.sh @@ -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/ +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 diff --git a/scripts/verify-atarist-audio.sh b/scripts/verify-atarist-audio.sh new file mode 100755 index 0000000..16a0ac7 --- /dev/null +++ b/scripts/verify-atarist-audio.sh @@ -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/.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 diff --git a/scripts/verify-dos-audio.sh b/scripts/verify-dos-audio.sh new file mode 100755 index 0000000..59f48ba --- /dev/null +++ b/scripts/verify-dos-audio.sh @@ -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/.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 diff --git a/scripts/verify-x68000-golden.sh b/scripts/verify-x68000-golden.sh index 6456baa..cb7dd27 100755 --- a/scripts/verify-x68000-golden.sh +++ b/scripts/verify-x68000-golden.sh @@ -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}, diff --git a/src/atarist/audio.c b/src/atarist/audio.c index ff6cf85..df46f42 100644 --- a/src/atarist/audio.c +++ b/src/atarist/audio.c @@ -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; diff --git a/src/core/surface.c b/src/core/surface.c index e2b3188..a3c3d8a 100644 --- a/src/core/surface.c +++ b/src/core/surface.c @@ -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. } diff --git a/src/iigs/joeyDraw.s b/src/iigs/joeyDraw.s index b017968..5ba22e9 100644 --- a/src/iigs/joeyDraw.s +++ b/src/iigs/joeyDraw.s @@ -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 diff --git a/src/x68000/hal.c b/src/x68000/hal.c index f967521..c9d81f5 100644 --- a/src/x68000/hal.c +++ b/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 ---------------------------------------------------------------- diff --git a/src/x68000/serial.c b/src/x68000/serial.c index be52a1d..0d1777d 100644 --- a/src/x68000/serial.c +++ b/src/x68000/serial.c @@ -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; } diff --git a/src/x68000/x68kRasterIsr.s b/src/x68000/x68kRasterIsr.s new file mode 100644 index 0000000..3d58dbc --- /dev/null +++ b/src/x68000/x68kRasterIsr.s @@ -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 diff --git a/tools/gradeAudioPhases.py b/tools/gradeAudioPhases.py new file mode 100755 index 0000000..c28fa62 --- /dev/null +++ b/tools/gradeAudioPhases.py @@ -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