Merge pull request #336 from libretro/release/v3.1.0

Release v3.1.0
This commit is contained in:
Joe Mattiello authored and GitHub committed 2026-08-06 12:31:40 -04:00
commit 59f7f9cc59
80 files changed
+14561 -1025

No files matched your search

+88 -11
View File
@@ -95,24 +95,28 @@ jobs:
os: ubuntu-latest
android: true
android_abi: 'arm64-v8a'
android_simd: 'neon'
- displayTargetName: 'Android armeabi-v7a'
artifact: 'libs/armeabi-v7a/libretro.so'
os: ubuntu-latest
android: true
android_abi: 'armeabi-v7a'
android_simd: 'neon'
- displayTargetName: 'Android x86_64'
artifact: 'libs/x86_64/libretro.so'
os: ubuntu-latest
android: true
android_abi: 'x86_64'
android_simd: 'sse2'
- displayTargetName: 'Android x86'
artifact: 'libs/x86/libretro.so'
os: ubuntu-latest
android: true
android_abi: 'x86'
android_simd: 'sse2'
# iOS / tvOS
- displayTargetName: 'iOS arm64'
@@ -181,8 +185,39 @@ jobs:
# ndk-build uses jni/Android.mk (not the project Makefile),
# so the parse-time version.h regen in Makefile doesn't run.
bash scripts/gen-version-h.sh
${{ steps.setup-ndk.outputs.ndk-path }}/ndk-build \
APP_ABI=${{ matrix.config.android_abi }} -j4
NDK="${{ steps.setup-ndk.outputs.ndk-path }}"
ABI="${{ matrix.config.android_abi }}"
SIMD="${{ matrix.config.android_simd }}"
UPPER=$(echo "$SIMD" | tr '[:lower:]' '[:upper:]')
# Dry run first, while nothing is built, so make actually prints
# the compile lines. blitter.c MUST carry -DBLITTER_SIMD_<ARCH>:
# without it blitter.c inlines the scalar ops and still links
# happily against the neon/sse2 vtable, so a missing define is a
# silent slow path that no build failure would reveal.
"$NDK/ndk-build" APP_ABI="$ABI" -n > ndk-dry.log 2>&1
BLIT_LINES=$(grep -c '[/ ]blitter\.c' ndk-dry.log || true)
if [ "$BLIT_LINES" = "0" ]; then
echo "::error::ndk-build -n printed no compile line for blitter.c; this check needs rework"
exit 1
fi
if ! grep '[/ ]blitter\.c' ndk-dry.log | grep -q -- "-DBLITTER_SIMD_${UPPER}"; then
echo "::error::blitter.c is not compiled with -DBLITTER_SIMD_${UPPER} on ${ABI}"
grep '[/ ]blitter\.c' ndk-dry.log | head -3
exit 1
fi
echo "==> ${ABI}: blitter.c carries -DBLITTER_SIMD_${UPPER}"
"$NDK/ndk-build" APP_ABI="$ABI" -j4
# And the arch .c that define has to agree with.
FOUND=$(find "obj/local/$ABI" -name 'blitter_simd_*.o' -exec basename {} \; | sort | tr '\n' ' ')
if [ "$FOUND" != "blitter_simd_${SIMD}.o " ]; then
echo "::error::expected blitter_simd_${SIMD}.o on ${ABI}, found: ${FOUND:-<none>}"
exit 1
fi
echo "==> ${ABI}: built ${FOUND}"
# Mach-O symbol gating: production builds must export retro_* only.
# Runs before the test step (which would relink with TEST_EXPORTS=1
@@ -209,10 +244,13 @@ jobs:
run: |
ARCH=$(uname -m)
CC="${{ matrix.config.cc }}"
# -DBLITTER_SIMD_<ARCH> is not optional: blitter_simd.h inlines
# the matching arch header, so an arch .c compiled without its
# define pulls in the scalar ops and then redefines every one.
case "$ARCH" in
x86_64|i686|i386) SIMD_SRC=src/tom/blitter_simd_sse2.c; EXTRA="-msse2" ;;
aarch64|arm64) SIMD_SRC=src/tom/blitter_simd_neon.c; EXTRA="" ;;
*) SIMD_SRC=src/tom/blitter_simd_scalar.c; EXTRA="" ;;
x86_64|i686|i386) SIMD_SRC=src/tom/blitter_simd_sse2.c; EXTRA="-msse2 -DBLITTER_SIMD_SSE2" ;;
aarch64|arm64) SIMD_SRC=src/tom/blitter_simd_neon.c; EXTRA="-DBLITTER_SIMD_NEON" ;;
*) SIMD_SRC=src/tom/blitter_simd_scalar.c; EXTRA="-DBLITTER_SIMD_SCALAR" ;;
esac
echo "==> Testing ${SIMD_SRC}..."
@@ -221,7 +259,7 @@ jobs:
./test_blitter_simd
echo "==> Cross-checking against scalar..."
$CC -O2 -Wall -I src -I src/core -I src/tom \
$CC -O2 -Wall -DBLITTER_SIMD_SCALAR -I src -I src/core -I src/tom \
-o test_blitter_scalar test/test_blitter_simd.c src/tom/blitter_simd_scalar.c
./test_blitter_scalar
@@ -323,12 +361,22 @@ jobs:
shell: bash
run: bash scripts/gen-version-h.sh
- name: Compile all sources with cl.exe
# Sources are .c, so cl.exe compiles them in C mode -- deliberately.
# That is what the buildbot does, and it is what makes this job a
# useful C89/MSVC check. Do not "fix" it to /TP. Do not add /Za
# either: it breaks <emmintrin.h> and the Windows SDK headers.
#
# Exactly one BLITTER_SIMD_<ARCH> may be defined per translation
# unit -- blitter_simd.h inlines the matching blitter_simd_<arch>.h,
# and any arch .c compiled under a different (or absent) define
# redefines every op. So the SIMD arches get one cl.exe run each,
# mirroring how Makefile.common builds exactly one of them.
- name: Compile all sources with cl.exe (scalar blitter SIMD)
shell: cmd
run: |
cl.exe /c /W3 /O2 /DNDEBUG /D_CRT_SECURE_NO_DEPRECATE ^
/I. /Isrc /Isrc\core /Isrc\tom /Isrc\jerry /Isrc\cd /Isrc\bios /Isrc\m68000 /Ilibretro-common\include ^
/D__LIBRETRO__ /DINLINE="_inline" ^
/D__LIBRETRO__ /DINLINE="_inline" /DBLITTER_SIMD_SCALAR ^
libretro.c ^
src\tom\blitter.c src\tom\blitter_compare.c src\tom\blitter_mmio.c src\jerry\dac.c src\jerry\dsp.c src\core\file.c ^
src\tom\gpu.c src\core\jaguar.c src\jerry\jerry.c src\tom\tom.c src\tom\op.c ^
@@ -339,9 +387,29 @@ jobs:
src\bios\jagbios.c ^
src\bios\jagcdbios.c src\bios\jagdevcdbios.c ^
src\m68000\m68kinterface.c ^
src\tom\blitter_simd_scalar.c ^
src\tom\blitter_simd_scalar.c
# The shape Makefile.common now selects for windows_msvc2015/2017
# x64 and x86: blitter.c inlines blitter_simd_sse2.h, so cl.exe
# compiles <emmintrin.h> intrinsics inside blitter.c itself -- not
# just inside the lint-exempt blitter_simd_sse2.c. That is the whole
# risk of enabling SSE2 for MSVC, and this step is what verifies it.
# Objects go to a subdir so they don't collide with the run above.
#
# No trailing `echo` in either step: `shell: cmd` has no `set -e`,
# so the step's exit code is the LAST command's. A closing echo
# would return 0 over any cl.exe failure and make this whole job
# green-but-inert. Let cl.exe be the last command.
- name: Compile blitter with cl.exe (SSE2 blitter SIMD)
shell: cmd
run: |
mkdir msvc-sse2
cl.exe /c /W3 /O2 /DNDEBUG /D_CRT_SECURE_NO_DEPRECATE ^
/I. /Isrc /Isrc\core /Isrc\tom /Isrc\jerry /Isrc\cd /Isrc\bios /Isrc\m68000 /Ilibretro-common\include ^
/D__LIBRETRO__ /DINLINE="_inline" /DBLITTER_SIMD_SSE2 ^
/Fomsvc-sse2\ ^
src\tom\blitter.c ^
src\tom\blitter_simd_sse2.c
echo MSVC compilation check passed
vita-build:
name: build-PS Vita
@@ -496,8 +564,17 @@ jobs:
# --diff-filter=d drops deleted paths: clang-tidy cannot lint a
# file that no longer exists and fails the whole job with
# "no input files" / "expected exactly one compiler job".
#
# blitter_simd_<arch>.{c,h} are all excluded. Only one arch is
# ever built, so the other two .c files aren't in
# compile_commands.json; and the .h files are include-only
# fragments that refuse to be compiled standalone (they
# #error unless blitter_simd.h pulled them in), so linting one
# directly is guaranteed to fail. They are covered instead by
# c89-lint's per-arch pass, which compiles them the way the
# build does.
FILES=$(git diff --name-only --diff-filter=d "$BASE"...HEAD -- '*.c' '*.h' \
| grep -Ev '^(src/m68000/|src/bios/jag.*\.c$|libretro-common/|src/core/version\.h$|src/tom/blitter_simd_(neon|sse2)\.c$)' \
| grep -Ev '^(src/m68000/|src/bios/jag.*\.c$|libretro-common/|src/core/version\.h$|src/tom/blitter_simd_(neon|sse2|scalar)\.(c|h)$)' \
| grep -E '^(src/|libretro\.c$)' || true)
if [ -z "$FILES" ]; then
echo "No relevant C files changed; skipping clang-tidy."
+3 -3
View File
@@ -31,18 +31,18 @@ jobs:
exit 1
fi
- name: Setup Java JDK
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 18
distribution: zulu
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Initial synchronisation
shell: bash
+3 -3
View File
@@ -34,18 +34,18 @@ jobs:
exit 1
fi
- name: Setup Java JDK
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 18
distribution: zulu
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Upload Source
shell: bash
@@ -30,13 +30,13 @@ jobs:
exit 1
fi
- name: Setup Java JDK
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 18
distribution: zulu
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v7
with:
python-version: '3.10'
@@ -45,7 +45,7 @@ jobs:
# (tagged commits and release/hotfix merges). Generated translations
# belong on develop and reach master at the next release.
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: develop
persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal access token.
@@ -70,7 +70,7 @@ jobs:
|| git commit -m "Fetch translations & recreate libretro_core_options_intl.h"
- name: GitHub Push
uses: ad-m/github-push-action@v0.8.0
uses: ad-m/github-push-action@v1.3.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: develop
+15 -1
View File
@@ -535,7 +535,10 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: nightly
ISSUE_TITLE: 'Nightly builds — download, install, and what they are'
# Must match issue #236's title exactly (including the emoji / [Release]
# prefix added by hand). Label lookup is preferred; this is the
# fallback if the nightly-tracker label is ever removed.
ISSUE_TITLE: '🐈‍⬛ [Release] Nightly builds — download, install, and what they are'
steps:
- uses: actions/checkout@v7
with:
@@ -663,8 +666,19 @@ jobs:
gh release edit "${TAG}" --repo "${REPO}" \
--title "${TITLE}" \
--notes-file "${{ steps.prep_body.outputs.body_file }}" \
--target "${GITHUB_SHA}" \
--prerelease --draft=false
gh release upload "${TAG}" --repo "${REPO}" release/* --clobber
# `gh release edit` leaves published_at at the original create
# date, so the Releases list kept showing "Published Aug 1" while
# assets/body moved every develop push — looking abandoned under
# newer stable tags. Draft→publish is the only API that rewrites
# that timestamp; assets stay attached and the id is unchanged.
# Window is one round-trip.
REL_ID=$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq .id)
gh api -X PATCH "repos/${REPO}/releases/${REL_ID}" -F draft=true >/dev/null
gh api -X PATCH "repos/${REPO}/releases/${REL_ID}" \
-F draft=false -F prerelease=true >/dev/null
else
gh release create "${TAG}" --repo "${REPO}" \
--title "${TITLE}" \
+10
View File
@@ -48,6 +48,7 @@ test/tools/test_memory_map
test/tools/test_op_gpu_object
test/tools/test_dsp_audio_diag
test/tools/cd_wedge_probe
test/tools/fmv_seek_probe
test/tools/sram_test
test/tools/netlink_pair
test/tools/netlink_game
@@ -105,3 +106,12 @@ test/tools/__pycache__/
coverage.xml
test/tools/netlink_latency
test/tools/netlink_delay_proxy
# Skip ledger written by `make test` (scripts/test-skip.sh)
test/.skipped-checks
# Created by `make platform=windows_msvc*` on a non-Windows host:
# those blocks run `reg query ... 2>nul`, and outside cmd.exe `nul`
# is a plain file. Committing it makes every Windows checkout fail
# with "error: invalid path 'nul'".
/nul
+3 -2
View File
@@ -54,7 +54,8 @@ Frame loop is event-driven (not cycle-accurate): `JaguarExecuteNew()` in `src/co
- `src/core/` — orchestration, memory map, events, settings, files, cheats
- `src/tom/` — video, GPU, OP, blitter (+ SIMD)
- `src/jerry/` — audio, DSP, DAC, EEPROM, input, wavetable, UART/netlink (`uart.c` + `jlink.c`, see `docs/netlink-design.md`)
- `src/cd/` — Jaguar CD: BUTCH/FIFO/DSA in `cdrom.c`, image loading (CUE/BIN, CHD, CDI) in `cdintf.c`; BIOS auth bypass + boot stub in `src/core/jaguar.c`
- `src/cd/` — Jaguar CD: BUTCH/FIFO/DSA/Q-subcode in `cdrom.c`, image loading (CUE/BIN, CDI — **no CHD**, removed during the CD overhaul; see issue #322) in `cdintf.c`; BIOS auth bypass + boot stub in `src/core/jaguar.c`
- `src/core/jaggd.c` — Jaguar GameDrive: SPI mailbox at `$F16000`, embedded GDBIOS blob, 6×1MB page → 16-bank switching for images up to 16 MB (spec: `docs/jgd-interface-notes.md`)
- `src/bios/` — embedded BIOS / boot stubs
- `src/m68000/` — UAE 68K (machine-generated; treat as opaque)
- `libretro-common/` — shared utility lib
@@ -156,7 +157,7 @@ When triaging "X crashes / hangs / goes to a black screen" reports, the user's R
Required runs before declaring an audio change done:
1. `make TEST_EXPORTS=1 test` — must exit 0. Both `test_audio_clipping` and `test_audio_presence` are part of the suite. The presence check on Iron Soldier 1 uses develop's measured envelope (`--rms-floor 200 --rms-ceiling 25000`). If your change moves IS1's RMS outside that band, you've changed audio behavior — verify it's intentional.
2. Sanity-check that previously-clipping titles (Skyhammer, IS2) didn't go from "loud broken" to "silent broken". Skyhammer should still fail clipping until it's actually fixed; if it suddenly passes clipping but presence drops to silence, that's the masked-failure pattern.
2. Sanity-check that previously-clipping titles (Skyhammer, IS2) didn't go from "loud broken" to "silent broken" — if one suddenly passes clipping but presence drops to silence, that's the masked-failure pattern. **Both are fixed as of 2026-08-05 and both assert clean**: the MMULT secondary-bank fix resolved them. Measured on `Skyhammer (World).j64`: 0.000% saturated, window RMS 3079.8, first audio at frame 171 — clean *and* not silent. (An older note here said Skyhammer "should still fail clipping"; that was stale, and it was inert anyway — the Makefile looked for `Skyhammer_(1999).jag`, which matches nothing, so the sentinel silently skipped while the suite reported exit 0.)
3. **Verify in RetroArch on a real game.** Headless tests cannot tell "music plays" from "structured noise at the right RMS" or catch BIOS-mode crashes. Memory: PR #170's BIOS crash + HLE silence in Skyhammer were both invisible to the test suite.
Do not relax thresholds in `test_audio_clipping.c` or `test_audio_presence.c` to make a PR pass. If a real fix makes a known-broken title legitimately quieter, that's a separate, deliberate baseline update — call it out in the commit, not as a side effect.
+299 -48
View File
@@ -47,7 +47,7 @@ TARGET_NAME := virtualjaguar
# Single source-of-truth for the human-readable version string.
# Bumped by .github/workflows/version-bump.yml (greps this line).
# Composed into CORE_VERSION in src/core/version.h, generated below.
CORE_BASE_VERSION := v3.0.0
CORE_BASE_VERSION := v3.1.0
ifeq ($(DEBUG),1)
CFLAGS += -DBUILD_TIMESTAMP="\"debug $(shell date -u +%Y-%m-%dT%H:%M:%SZ)\""
@@ -103,9 +103,11 @@ ifeq ($(platform), unix)
TARGET := $(TARGET_NAME)_libretro.so
fpic := -fPIC
ifneq ($(findstring SunOS,$(shell uname -a)),)
# Solaris ld: no --gc-sections, so GC_STYLE stays unset.
SHARED := -shared -z defs -z gnu-version-script-compat
else
SHARED := -shared -Wl,--no-undefined -Wl,--version-script=$(LINK_SCRIPT)
GC_STYLE := gnu
endif
# Classic Platforms ####################
@@ -118,9 +120,12 @@ else ifeq ($(platform), classic_armv7_a7)
TARGET := $(TARGET_NAME)_libretro.so
fpic := -fPIC
SHARED := -shared -Wl,--no-undefined -Wl,--version-script=$(LINK_SCRIPT)
# --gc-sections belongs on the link line, not in CFLAGS; see the
# GC_STYLE block below for why this target sets it by hand.
LDFLAGS += -Wl,--gc-sections
CFLAGS += -Ofast \
-flto=4 -fwhole-program -fuse-linker-plugin \
-fdata-sections -ffunction-sections -Wl,--gc-sections \
-fdata-sections -ffunction-sections \
-fno-stack-protector -fno-ident -fomit-frame-pointer \
-falign-functions=1 -falign-jumps=1 -falign-loops=1 \
-fno-unwind-tables -fno-asynchronous-unwind-tables -fno-unroll-loops \
@@ -145,6 +150,7 @@ else ifeq ($(platform), osx)
TARGET := $(TARGET_NAME)_libretro.dylib
fpic := -fPIC
SHARED := -dynamiclib $(MACHO_EXPORTS_FLAGS)
GC_STYLE := macho
ifeq ($(arch),ppc)
FLAGS += -DMSB_FIRST
OLD_GCC = 1
@@ -172,6 +178,7 @@ else ifneq (,$(findstring ios,$(platform)))
TARGET := $(TARGET_NAME)_libretro_ios.dylib
fpic := -fPIC
SHARED := -dynamiclib $(MACHO_EXPORTS_FLAGS)
GC_STYLE := macho
MINVERSION :=
ifeq ($(IOSSDK),)
IOSSDK := $(shell xcodebuild -version -sdk iphoneos Path)
@@ -196,6 +203,7 @@ else ifeq ($(platform), tvos-arm64)
TARGET := $(TARGET_NAME)_libretro_tvos.dylib
fpic := -fPIC
SHARED := -dynamiclib $(MACHO_EXPORTS_FLAGS)
GC_STYLE := macho
ifeq ($(IOSSDK),)
IOSSDK := $(shell xcodebuild -version -sdk appletvos Path)
endif
@@ -220,6 +228,7 @@ else ifeq ($(platform), qnx)
TARGET := $(TARGET_NAME)_libretro_$(platform).so
fpic := -fPIC
SHARED := -shared -Wl,--no-undefined -Wl,--version-script=$(LINK_SCRIPT)
GC_STYLE := gnu
CC = qcc -Vgcc_ntoarmv7le
CXX = QCC -Vgcc_ntoarmv7le_cpp
@@ -228,6 +237,7 @@ else ifneq (,$(findstring armv,$(platform)))
TARGET := $(TARGET_NAME)_libretro.so
fpic := -fPIC
SHARED := -shared -Wl,--no-undefined -Wl,--version-script=$(LINK_SCRIPT)
GC_STYLE := gnu
ARCH = arm
# Nintendo Switch (libnx)
@@ -564,6 +574,7 @@ else
CC ?= gcc
CXX ?= g++
SHARED := -shared -Wl,--no-undefined -Wl,--version-script=$(LINK_SCRIPT)
GC_STYLE := gnu
LDFLAGS += -static-libgcc -static-libstdc++ -lwinmm -lws2_32
endif
@@ -651,6 +662,71 @@ ifeq (,$(findstring msvc,$(platform)))
FLAGS += -ffast-math -fomit-frame-pointer -fno-common
endif
# ----------------------------------------------------------------
# Linker dead-code elimination (issue #321).
#
# src/m68000/cpustbl.c ships TWO complete 68000 handler tables:
# op_smalltbl_4_ff (3162 entries, "fast") and op_smalltbl_5_ff (1581,
# "slow but compatible"). m68kinterface.c binds op_smalltbl_5_ff
# unconditionally, so table 4 -- and every machine-generated cpuemu.c
# handler body reachable only through it -- is linked in and never
# called. Letting the linker garbage-collect it needs no source
# change at all; this block is the whole fix.
#
# GC_STYLE is opt-in PER PLATFORM, never global. The release matrix
# spans 16 toolchains while PR CI covers four, so a globally-applied
# flag that one exotic linker rejects fails at tag time, not in review.
# The rule used to set it in the platform blocks above:
#
# gnu -- the platform's SHARED line already passes GNU-ld-only
# options (--version-script / --no-undefined), which proves
# the link runs through GNU ld or lld, so --gc-sections is
# available too. Section-per-symbol flags are required
# there: without them ELF/PE GC works at section
# granularity and one live handler pins the whole object.
# macho -- Apple ld64. It is atom-based, so -dead_strip alone is
# sufficient and -ffunction-sections/-fdata-sections are
# no-ops; they are deliberately NOT added.
# unset -- byte-for-byte unchanged from before. Covers every
# STATIC_LINKING=1 target (vita, switch/libnx, ctr, ps3,
# psl1ght, psp1, emscripten -- those emit a .a/.bc and
# never run a link, so the flags could not take effect
# anyway), every MSVC/Xbox target, Solaris ld, and theos_ios
# (Theos drives its own link).
#
# classic_armv7_a7 is the one target that opts in by hand rather than
# via GC_STYLE, because it does NOT want the compile half: its CFLAGS
# already carry -ffunction-sections -fdata-sections, and GC_STYLE:=gnu
# would duplicate them. It used to pass -Wl,--gc-sections in CFLAGS,
# where it was provably inert -- the link line is $(OBJECTS) $(LDFLAGS)
# only. Measured with a Debian bookworm arm-linux-gnueabihf gcc 12
# cross-link (a proxy for the modmyclassic toolchain, not that
# toolchain itself), deleting that CFLAGS entry outright produced a
# byte-identical 1,512,628-byte .so.
#
# Do not expect the macOS win here. This block compiles with
# -flto=4 -fwhole-program, so cpustbl.o is a slim LTO object and GCC's
# IPA -- not the linker -- decides what dies: op_smalltbl_4_ff is
# already absent from the baseline .so. Moving the flag to LDFLAGS
# links clean with a byte-identical dynsym list and saves 80 bytes
# (1,512,628 -> 1,512,548, 0.005%), versus 22.7% on non-LTO ld64.
# It is kept because a flag that reads as enabled should be enabled,
# not because the size matters.
#
# Android is unaffected either way: ndk-build uses jni/Android.mk,
# not this Makefile.
# ----------------------------------------------------------------
ifneq ($(STATIC_LINKING),1)
ifneq ($(platform),theos_ios)
ifeq ($(GC_STYLE),gnu)
FLAGS += -ffunction-sections -fdata-sections
LDFLAGS += -Wl,--gc-sections
else ifeq ($(GC_STYLE),macho)
LDFLAGS += -Wl,-dead_strip
endif
endif
endif
LDFLAGS += $(fpic) $(SHARED)
FLAGS += $(fpic)
FLAGS += $(INCFLAGS) $(INCFLAGS_PLATFORM)
@@ -753,12 +829,13 @@ clean:
test/test_tom_visible_window test/test_framebuffer_integrity \
test/test_butch_cd test/test_bios_config test/test_boot_config \
test/test_cart_format \
test/test_cd_boot test/test_cd_hle_boot test/test_cd_bios_boot test/test_cd_toc_contract test/test_cd_fifo_stream test/test_cd_ssi_stream test/test_cd_second_transfer test/test_cd_hle_idempotent test/test_cd_lost_wakeup test/test_cd_pregap \
test/test_cd_boot test/test_cd_hle_boot test/test_cd_bios_boot test/test_cd_toc_contract test/test_cd_fifo_stream test/test_cd_ssi_stream test/test_cd_second_transfer test/test_cd_hle_idempotent test/test_cd_lost_wakeup test/test_cd_pregap test/test_cd_synth_read test/test_cd_synth_butch test/test_cd_synth_cdda test/test_cd_synth_subq \
test/test_audio_dac test/test_blitter \
test/test_state_compat test/test_frontend_pacing \
test/test_state_compat test/test_frontend_pacing test/test_jgd \
test/dump_pc test/heap_search \
test/tools/test_memory_map test/tools/test_option_visibility test/test_memtrack test/test_nvmbios test/tools/test_dsp_audio_diag \
test/tools/test_frame_timing
test/tools/test_frame_timing test/tools/test_runahead_determinism \
test/.skipped-checks
# Self-contained unit tests (parser + list management + simulated
# memory application). Does not require a ROM or a working build of
@@ -802,13 +879,19 @@ test: test/test_dram_timing test/test_cheat test/test_event_queue test/test_jlin
test/test_audio_pipeline test/test_audio_clipping test/test_audio_presence test/test_pit_clock_rate \
test/test_blitter_mmio test/test_blitter_cmd test/test_eeprom_lifecycle test/test_tom_visible_window \
test/test_framebuffer_integrity test/test_state_compat \
test/test_frontend_pacing \
test/test_frontend_pacing test/test_jgd \
test/tools/test_runahead_determinism \
test/test_butch_cd test/test_bios_config test/test_boot_config \
test/test_cart_format \
test/test_cd_boot test/test_cd_hle_boot test/test_cd_bios_boot test/test_cd_toc_contract test/test_cd_fifo_stream test/test_cd_ssi_stream test/test_cd_second_transfer test/test_cd_hle_idempotent test/test_cd_lost_wakeup test/test_cd_pregap \
test/test_cd_boot test/test_cd_hle_boot test/test_cd_bios_boot test/test_cd_toc_contract test/test_cd_fifo_stream test/test_cd_ssi_stream test/test_cd_second_transfer test/test_cd_hle_idempotent test/test_cd_lost_wakeup test/test_cd_pregap test/test_cd_synth_read test/test_cd_synth_butch test/test_cd_synth_cdda test/test_cd_synth_subq \
test/test_audio_dac test/test_blitter \
test/tools/test_memory_map test/tools/test_op_gpu_object test/tools/test_option_visibility test/test_memtrack test/test_nvmbios test/test_uart_core test/test_netlink_host \
test/tools/netlink_pair test/tools/netlink_latency test/tools/netlink_delay_proxy
@# Skip ledger: truncate FIRST so a previous run's rows cannot resurface
@# as fresh skips (the stale-row failure mode documented for
@# cd_boot_matrix.sh). Every optional check below records into it, and
@# the summary at the end of this recipe prints the roll-up.
@bash scripts/test-skip.sh reset
./test/test_dram_timing
./test/test_cheat
./test/test_event_queue
@@ -836,38 +919,65 @@ test: test/test_dram_timing test/test_cheat test/test_event_queue test/test_jlin
./test/test_subsystem_timeline ./$(TARGET)
./test/test_irq_cascade ./$(TARGET)
./test/test_boot_patterns
./test/test_audio_pipeline ./$(TARGET)
@# test_audio_pipeline takes an OPTIONAL positional ROM; without it, its
@# onset check and its BIOS-vs-HLE comparison skip unconditionally --
@# with ROMs, without ROMs, always. It was invoked bare, so those two
@# checks were permanently vacuous. Feed it the same Iron Soldier 1
@# dump the presence check uses (boots straight to a music-on title).
@rom=$$(bash scripts/find-rom.sh 'Iron Soldier (1994).jag' 'Iron Soldier (World)*.j64' 'Iron Soldier.jag'); \
if [ -n "$$rom" ]; then \
./test/test_audio_pipeline ./$(TARGET) "$$rom"; \
else \
bash scripts/test-skip.sh record "Audio pipeline (onset + BIOS/HLE cmp)" "no ROM matching 'Iron Soldier*' in the private corpus"; \
./test/test_audio_pipeline ./$(TARGET); \
fi
./test/test_audio_clipping ./$(TARGET) --self-test
@# Cartridges live either flat in test/roms/private/ or under its ROMS/
@# subdirectory, depending on how the local corpus was laid out. Each
@# lookup below tries both, because checking only one silently turns the
@# test into a SKIP -- which still prints and still exits 0, so the suite
@# stays green while the check is not running at all.
@# ROM lookup goes through scripts/find-rom.sh, which searches the whole
@# private corpus case-insensitively and prefers the canonical top-level
@# copy over duplicates buried in sub-collections. It replaced a pair of
@# hardcoded literal paths per title: the corpus names titles
@# inconsistently ("Iron Soldier 2 (World).j64" vs "Iron Soldier (World)
@# (v1.04).j64"), so a literal path that does not match turns the check
@# into a SKIP that still exits 0 -- the suite stays green while the
@# check is not running at all. That is exactly how the Skyhammer
@# sentinel below went inert. Every miss is now recorded in the skip
@# ledger and reported in the summary at the end of this recipe.
@# Negative control: healthy boot should not trip the clipping detector.
@rom="test/roms/private/Atari Karts (1995).jag"; \
[ -f "$$rom" ] || rom="test/roms/private/ROMS/Atari Karts (1995).jag"; \
if [ -f "$$rom" ]; then \
@rom=$$(bash scripts/find-rom.sh 'Atari Karts (1995).jag' 'Atari Karts*.jag' 'Atari Karts*.j64'); \
if [ -n "$$rom" ]; then \
./test/test_audio_clipping ./$(TARGET) "$$rom" --label "Atari Karts (negative control)" --quiet; \
else \
echo " SKIP: Atari Karts ROM (private) not available"; \
bash scripts/test-skip.sh record "Atari Karts (clipping neg. control)" "no ROM matching 'Atari Karts*' in the private corpus"; \
fi
@# Formerly known-broken titles (DSP-synth saturation class): fixed by
@# the MMULT secondary-bank fix (JTRM: the vector operand is always
@# register bank 1, not "the non-current bank"). These now assert
@# clean audio so a regression flips them red again.
@rom="test/roms/private/Skyhammer_(1999).jag"; \
[ -f "$$rom" ] || rom="test/roms/private/ROMS/Skyhammer_(1999).jag"; \
if [ -f "$$rom" ]; then \
@#
@# NOTE: the old literal path here ("Skyhammer_(1999).jag") never matched
@# anything -- the corpus holds it as "Skyhammer (World).j64" -- so this
@# sentinel silently skipped while the suite still reported exit 0. The
@# lookup below matches it case-insensitively under either spelling.
@#
@# SETTLED 2026-08-05, measured on "Skyhammer (World).j64": 0.000%
@# saturated, longest saturation run 0 samples, window RMS 3079.8, first
@# audio at frame 171. Skyhammer no longer clips -- the MMULT
@# secondary-bank fix resolved it -- and it is not silent either, so it is
@# not the masked-silence failure mode. Asserting clean, same as the Iron
@# Soldier 2 line below, is therefore correct on evidence rather than by
@# default. CLAUDE.md's "Skyhammer should still fail clipping" was stale
@# and has been corrected. Do NOT add an expected-fail wrapper here.
@rom=$$(bash scripts/find-rom.sh 'Skyhammer_(1999).jag' '*skyhammer*.jag' '*skyhammer*.j64' '*skyhammer*.rom' '*sky hammer*.jag' '*sky hammer*.j64'); \
if [ -n "$$rom" ]; then \
./test/test_audio_clipping ./$(TARGET) "$$rom" --label Skyhammer --quiet; \
else \
echo " SKIP: Skyhammer ROM (private) not available"; \
bash scripts/test-skip.sh record "Skyhammer (clipping sentinel)" "no ROM matching '*skyhammer*' in the private corpus"; \
fi
@rom="test/roms/private/Iron Soldier 2 (World).j64"; \
[ -f "$$rom" ] || rom="test/roms/private/ROMS/Iron Soldier 2 (World).j64"; \
if [ -f "$$rom" ]; then \
@rom=$$(bash scripts/find-rom.sh 'Iron Soldier 2 (World).j64' 'Iron Soldier 2*.j64' 'Iron Soldier 2*.jag'); \
if [ -n "$$rom" ]; then \
./test/test_audio_clipping ./$(TARGET) "$$rom" --label "Iron Soldier 2" --quiet; \
else \
echo " SKIP: Iron Soldier 2 ROM (private) not available"; \
bash scripts/test-skip.sh record "Iron Soldier 2 (clipping)" "no ROM matching 'Iron Soldier 2*' in the private corpus"; \
fi
@# Presence check: counterpart to the clipping check. A "fix" that
@# silences the game (e.g. PR #170 closed without merge) drops RMS
@@ -875,18 +985,61 @@ test: test/test_dram_timing test/test_cheat test/test_event_queue test/test_jlin
@# Soldier 1 boots straight to a music-on title; envelope was
@# measured on develop (RMS ~1175). Floor 200 catches silence
@# regressions; ceiling 25000 catches loud-broken regressions.
@rom="test/roms/private/Iron Soldier (1994).jag"; \
[ -f "$$rom" ] || rom="test/roms/private/ROMS/Iron Soldier (1994).jag"; \
if [ -f "$$rom" ]; then \
@# The exact 1994 dump is tried first: the envelope was measured on it,
@# and the [a1]/v1.04 alternates are not guaranteed to share it.
@rom=$$(bash scripts/find-rom.sh 'Iron Soldier (1994).jag' 'Iron Soldier (World)*.j64' 'Iron Soldier.jag'); \
if [ -n "$$rom" ]; then \
./test/test_audio_presence ./$(TARGET) "$$rom" --label "Iron Soldier 1" --rms-floor 200 --rms-ceiling 25000 --quiet; \
else \
echo " SKIP: Iron Soldier 1 ROM (private) not available (audio presence)"; \
bash scripts/test-skip.sh record "Iron Soldier 1 (audio presence)" "no ROM matching 'Iron Soldier*' in the private corpus"; \
fi
@# Same title at risc=2x (issue #314): real game audio through the DSP
@# with the RISC compute budget doubled. Presence must stay inside the
@# measured envelope -- if 2x silences it or blows it out, the scale is
@# leaking into the audio path. Same skip discipline as above.
@rom=$$(bash scripts/find-rom.sh 'Iron Soldier (1994).jag' 'Iron Soldier (World)*.j64' 'Iron Soldier.jag'); \
if [ -n "$$rom" ]; then \
./test/test_audio_presence ./$(TARGET) "$$rom" --label "Iron Soldier 1 (risc=2x)" --rms-floor 200 --rms-ceiling 25000 --quiet \
--option virtualjaguar_risc_clock_scale=2x; \
else \
bash scripts/test-skip.sh record "Iron Soldier 1 (audio presence, risc=2x)" "no ROM matching 'Iron Soldier*' in the private corpus"; \
fi
@# Save-state determinism: replay the same frames after
@# retro_unserialize and require identical video AND audio. This is
@# what backs `savestate_features = 3` in dist/info/ and the zero
@# serialization quirks reported from retro_load_game -- rewind,
@# netplay and run-ahead all assume a state is a complete snapshot.
@# It caught the DAC register file at F1A148-F1A157 being outside
@# every STATE_SAVE_BUF; yarc.j64 is in-tree so this never skips, and
@# a real music-on title is used when the private corpus is present.
./test/tools/test_runahead_determinism ./$(TARGET) test/roms/yarc.j64 --quiet
@rom=$$(bash scripts/find-rom.sh 'Iron Soldier (1994).jag' 'Iron Soldier (World)*.j64' 'Iron Soldier.jag'); \
if [ -n "$$rom" ]; then \
./test/tools/test_runahead_determinism ./$(TARGET) "$$rom" --quiet; \
else \
bash scripts/test-skip.sh record "Iron Soldier 1 (savestate determinism)" "no ROM matching 'Iron Soldier*' in the private corpus"; \
fi
./test/test_butch_cd
./test/test_cd_hle_idempotent
./test/test_cd_pregap
./test/test_bios_config
./test/test_boot_config
./test/test_cd_synth_read
./test/test_cd_synth_butch
./test/test_cd_synth_cdda
./test/test_cd_synth_subq
@# VJ_BIOS_DIR: both tests used to hardcode "test/roms/private" as the
@# libretro system dir, but the corpus keeps its BIOS dumps one level
@# down in ROMS/. The paths never resolved, so ten real-BIOS assertions
@# reported SKIP while the suite exited 0 -- the same silent-skip class
@# as the Skyhammer sentinel. Resolve the directory that actually holds
@# the Jaguar BIOS and hand it to both tests; they fall back to the old
@# literal when the variable is unset (CI, which has no BIOS dumps).
@bios=$$(bash scripts/find-rom.sh '*BIOS* Atari Jaguar (World).j64'); \
if [ -n "$$bios" ]; then \
VJ_BIOS_DIR=$$(dirname "$$bios"); export VJ_BIOS_DIR; \
else \
bash scripts/test-skip.sh record "Real-BIOS assertions (bios/boot config)" "no '[BIOS] Atari Jaguar (World).j64' in the private corpus"; \
fi; \
./test/test_bios_config && ./test/test_boot_config
./test/test_cart_format ./$(TARGET)
./test/test_audio_dac
./test/tools/test_memory_map ./$(TARGET)
@@ -899,36 +1052,45 @@ test: test/test_dram_timing test/test_cheat test/test_event_queue test/test_jlin
@# Try discs known to load first (see docs/cd-boot-matrix.md), then any
@# other. ls sorts all its arguments together, so probe one glob at a
@# time. A disc that still will not load is reported as SKIP, not FAIL.
@# The jagniccc.j64 guard that used to wrap this is gone: that ROM is
@# committed in-tree, so its absence means a broken checkout and must
@# fail the suite rather than read as a pass -- the same rationale the
@# test_state_compat and test_frontend_pacing lines below already state.
@# Only the disc half is genuinely optional, and it now records a ledger
@# skip so "ran without a disc" is distinguishable from "ran fully".
@VJ_VIS_ROOT="test/roms/private/Jaguar CD/BinCue"; VJ_VIS_DISC=""; \
for VJ_VIS_PAT in "Baldies*" "Myst*" "Hover*" "*"; do \
[ -n "$$VJ_VIS_DISC" ] && break; \
VJ_VIS_DISC=$$(ls "$$VJ_VIS_ROOT"/$$VJ_VIS_PAT/*.cue 2>/dev/null | head -1); \
done; \
if [ -f test/roms/jagniccc.j64 ]; then \
./test/tools/test_option_visibility ./$(TARGET) test/roms/jagniccc.j64 "$$VJ_VIS_DISC"; \
else \
echo "=== Core Option Visibility ==="; \
echo " SKIP: test/roms/jagniccc.j64 not present"; \
fi
if [ -z "$$VJ_VIS_DISC" ]; then \
bash scripts/test-skip.sh record "Core option visibility (disc half)" "no .cue image under test/roms/private/Jaguar CD/BinCue"; \
fi; \
./test/tools/test_option_visibility ./$(TARGET) test/roms/jagniccc.j64 "$$VJ_VIS_DISC"
./test/tools/test_op_gpu_object ./$(TARGET) test/roms/yarc.j64
@# Framebuffer integrity: alpha corruption + screen position shift detection.
@# Run both regions: max_height is region-independent, but the emitted
@# height is not, so a region-specific overflow must not hide.
@# Chained with && so an NTSC failure cannot be masked by a passing PAL
@# run -- with `;` the block would still exit 0 on the second command.
@if [ -f "test/roms/yarc.j64" ]; then \
./test/test_framebuffer_integrity ./$(TARGET) test/roms/yarc.j64 && \
./test/test_framebuffer_integrity ./$(TARGET) test/roms/yarc.j64 \
--option virtualjaguar_pal=enabled; \
else \
echo " SKIP: yarc.j64 ROM not available (framebuffer integrity)"; \
fi
@# Save-state version gate: a v2-layout state must still load, v1 and
@# future versions must be refused. Deliberately NOT wrapped in an
@# Unguarded on purpose: yarc.j64 is committed in-tree, so a missing file
@# is a broken checkout, not an absent optional ROM. The `[ -f ]` guard
@# that used to wrap this printed a SKIP and exited 0, which is the same
@# reads-as-a-pass hazard test_state_compat below explicitly refuses.
./test/test_framebuffer_integrity ./$(TARGET) test/roms/yarc.j64
./test/test_framebuffer_integrity ./$(TARGET) test/roms/yarc.j64 \
--option virtualjaguar_pal=enabled
@# Save-state version gate: every layout a released core wrote (v1, v2,
@# v3, v7) must still load with its chunks correctly aligned; version 0
@# and future versions must be refused. Deliberately NOT wrapped in an
@# `if [ -f ... ]` guard like the framebuffer test above: yarc.j64 is
@# committed in-tree, so a missing ROM is a broken checkout and the
@# test's exit 77 should stop the suite rather than read as a pass.
./test/test_state_compat ./$(TARGET) test/roms/yarc.j64
@# Jaguar GameDrive detection + banking + savestate v8 gate. Fully
@# synthetic (builds its own probe images), so it runs everywhere,
@# including checkouts without the private ROM tree.
./test/test_jgd ./$(TARGET)
@# Frontend pacing / fast-forward contract: the core must not throttle
@# itself, and samples-per-frame must match the advertised fps and
@# sample_rate, otherwise the frontend's audio driver becomes the pacing
@@ -937,6 +1099,29 @@ test: test/test_dram_timing test/test_cheat test/test_event_queue test/test_jlin
@# so a missing ROM means a broken checkout and should fail the suite
@# rather than silently read as a pass.
./test/test_frontend_pacing ./$(TARGET) test/roms/yarc.j64 --quiet
@# Clock-scale (issue #314) audio-pacing contract at non-1x. The one
@# invariant the scale options must never break is sample pacing:
@# I2S/DAC event scheduling stays on the unscaled sysclock, so the
@# audio_rate_contract (exactly 48000/fps samples per frame, 1:1
@# batches) must hold at every scale -- a violation here is a pitch
@# shift, the PR #170 regression class. yarc.j64 is committed
@# in-tree, so these run everywhere including CI. The m68k=0.5x row
@# also exercises the underclock error-diffusion path (zero-budget
@# slices must skip, not execute).
@# --max-fastest-frame-fraction 100 defuses the host-speed check on
@# these rows only: at 2x/2x the emulator does ~double the work per
@# frame, so "fastest frame < 0.5x period" is a property of the host,
@# not of the scale options, and would flake on loaded CI runners
@# (observed locally: 11.3 ms vs the 8.3 ms limit under parallel
@# builds). The assertions that matter here -- audio_rate_contract,
@# one_batch_per_frame, geometry_stability -- keep full strength.
./test/test_frontend_pacing ./$(TARGET) test/roms/yarc.j64 --quiet \
--max-fastest-frame-fraction 100 \
--option virtualjaguar_risc_clock_scale=2x \
--option virtualjaguar_m68k_clock_scale=2x
./test/test_frontend_pacing ./$(TARGET) test/roms/yarc.j64 --quiet \
--max-fastest-frame-fraction 100 \
--option virtualjaguar_m68k_clock_scale=0.5x
@# EEPROM lifecycle test: generates a test ROM, then exercises load/unload/reload.
@$(CC) -O2 -Wall -o /tmp/gen_eeprom_test_rom test/tools/gen_eeprom_test_rom.c && \
/tmp/gen_eeprom_test_rom /tmp/eeprom_lifecycle_test.j64 && \
@@ -952,6 +1137,11 @@ test: test/test_dram_timing test/test_cheat test/test_event_queue test/test_jlin
@echo "blitter readback tests probe register read paths that the emulator"
@echo "does not currently expose. Invoke them directly when validating"
@echo "regressions in those subsystems."
@# Skip roll-up. Every optional check that did not run is listed here by
@# name and reason, so an inert sentinel cannot hide behind exit 0 the way
@# the Skyhammer clipping check did. Non-fatal by default (CI has none of
@# the private ROMs); VJ_REQUIRE_ROMS=1 makes any skip a hard failure.
@bash scripts/test-skip.sh summary
test/test_cheat: test/test_cheat.c src/core/cheat.c src/core/cheat.h
$(CC) -O2 -Wall -std=c99 $(INCFLAGS) \
@@ -1024,7 +1214,8 @@ test/test_tom_visible_window: test/test_tom_visible_window.c src/tom/tom.c \
$(CC) -O2 -Wall -std=c99 $(INCFLAGS) \
-o $@ test/test_tom_visible_window.c
test/test_blitter_simd: test/test_blitter_simd.c $(BLITTER_SIMD_SRC) src/tom/blitter_simd.h
test/test_blitter_simd: test/test_blitter_simd.c $(BLITTER_SIMD_SRC) src/tom/blitter_simd.h \
$(BLITTER_SIMD_SRC:.c=.h)
$(CC) $(CFLAGS) -o $@ test/test_blitter_simd.c $(BLITTER_SIMD_SRC)
test/test_dsp_mac40: test/test_dsp_mac40.c src/jerry/dsp_acc40.h
@@ -1137,6 +1328,24 @@ test/test_state_compat: test/test_state_compat.c \
test/harness/harness.c \
$(if $(filter Linux,$(shell uname -s)),-ldl -lrt) -lm
# Jaguar GameDrive (JagGD) detection + banking. Synthetic-only: builds
# its own probe cartridge images at runtime, no private ROMs needed.
# Needs jgd*/JGD* from the wide test symbol set.
test/test_jgd: test/test_jgd.c \
test/harness/harness.c test/harness/harness.h \
src/core/state.h src/core/jaggd.h
$(CC) -O2 -Wall -std=c99 $(INCFLAGS) \
-o $@ test/test_jgd.c \
test/harness/harness.c \
$(if $(filter Linux,$(shell uname -s)),-ldl -lrt) -lm
test/tools/test_runahead_determinism: test/tools/test_runahead_determinism.c \
test/harness/harness.c test/harness/harness.h
$(CC) -O2 -Wall -std=c99 $(INCFLAGS) \
-o $@ test/tools/test_runahead_determinism.c \
test/harness/harness.c \
$(if $(filter Linux,$(shell uname -s)),-ldl -lrt) -lm
test/test_frontend_pacing: test/test_frontend_pacing.c \
test/harness/harness.c test/harness/harness.h
$(CC) -O2 -Wall -std=c99 $(INCFLAGS) \
@@ -1186,6 +1395,22 @@ test/test_cd_pregap: test/test_cd_pregap.c test/test_framework.h test/cd_asserti
$(CC) -O2 -Wall -Wno-unused-function -Wno-unused-variable -std=c99 $(INCFLAGS) \
-o $@ test/test_cd_pregap.c -ldl
test/test_cd_synth_read: test/test_cd_synth_read.c test/test_framework.h test/cd_assertions.h
$(CC) -O2 -Wall -Wno-unused-function -Wno-unused-variable -std=c99 $(INCFLAGS) \
-o $@ test/test_cd_synth_read.c -ldl
test/test_cd_synth_butch: test/test_cd_synth_butch.c test/test_framework.h
$(CC) -O2 -Wall -Wno-unused-function -Wno-unused-variable -std=c99 $(INCFLAGS) \
-o $@ test/test_cd_synth_butch.c -ldl
test/test_cd_synth_cdda: test/test_cd_synth_cdda.c test/test_framework.h
$(CC) -O2 -Wall -Wno-unused-function -Wno-unused-variable -std=c99 $(INCFLAGS) \
-o $@ test/test_cd_synth_cdda.c -ldl
test/test_cd_synth_subq: test/test_cd_synth_subq.c test/test_framework.h
$(CC) -O2 -Wall -Wno-unused-function -Wno-unused-variable -std=c99 $(INCFLAGS) \
-o $@ test/test_cd_synth_subq.c -ldl
test/test_cd_bios_boot: test/test_cd_bios_boot.c test/test_framework.h test/cd_assertions.h
$(CC) -O2 -Wall -Wno-unused-function -Wno-unused-variable -std=c99 $(INCFLAGS) \
-o $@ test/test_cd_bios_boot.c -ldl
@@ -1236,7 +1461,8 @@ test/heap_search: test/heap_search.c
tools: test/dump_pc test/heap_search test/test_cd_boot
endif
.PHONY: clean test lint coverage benchmark acid dsp-diag frame-timing cue2cdi
.PHONY: clean test lint coverage benchmark acid dsp-diag frame-timing cue2cdi \
runahead-determinism
endif
lint:
@@ -1337,6 +1563,31 @@ frame-timing:
$(if $(filter Linux,$(shell uname -s)),-ldl -lrt) -lm
./test/tools/test_frame_timing ./$(TARGET) "$(FRAME_TIMING_ROM)" $(FRAME_TIMING_FLAGS)
# `make runahead-determinism` -- Save-state determinism check. Saves a state,
# replays the same frames after retro_unserialize, and asserts the video and
# audio come back identical. This is the evidence behind
# `savestate_features = 3` in dist/info/, and behind reporting zero
# serialization quirks: run-ahead, rewind and netplay all assume a state is a
# complete snapshot.
#
# NOT part of `make test`: one assertion (audio_replay_identical) is a known
# failure — a single frame of ~0.05% RMS drift on the first rollback. See the
# tool header for the full measurement and what has already been ruled out.
#
# Usage:
# make runahead-determinism RUNAHEAD_ROM="path/to/game.j64"
# make runahead-determinism RUNAHEAD_ROM="path/to/game.j64" RUNAHEAD_FLAGS="--warmup 600 --frames 300"
RUNAHEAD_ROM ?= test/roms/yarc.j64
RUNAHEAD_FLAGS ?=
runahead-determinism:
$(MAKE) TEST_EXPORTS=1 -j$(shell getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)
$(CC) -O2 -Wall -std=c99 $(INCFLAGS) \
-o test/tools/test_runahead_determinism \
test/tools/test_runahead_determinism.c \
test/harness/harness.c \
$(if $(filter Linux,$(shell uname -s)),-ldl -lrt) -lm
./test/tools/test_runahead_determinism ./$(TARGET) "$(RUNAHEAD_ROM)" $(RUNAHEAD_FLAGS)
# Automated visual + audio verification for CD titles: frame-motion timeline,
# audio RMS, periodic screenshots (PPM). See the tool header for usage.
# make cd-visual CD_VISUAL_DISC="test/roms/private/Title/Title.cue" \
+43
View File
@@ -58,6 +58,7 @@ SOURCES_C := \
$(CORE_DIR)/src/jerry/joystick.c \
$(CORE_DIR)/src/core/settings.c \
$(CORE_DIR)/src/core/memtrack.c \
$(CORE_DIR)/src/core/jaggd.c \
$(CORE_DIR)/src/core/nvmbios.c \
$(CORE_DIR)/src/core/vjag_memory.c \
$(CORE_DIR)/src/core/universalhdr.c \
@@ -108,6 +109,31 @@ ifneq (,$(filter MINGW64% MINGW32%,$(MSYSTEM)))
BLITTER_SIMD_SRC := $(CORE_DIR)/src/tom/blitter_simd_sse2.c
endif
# MSVC. None of the matches above ever fire for these -- the platform
# strings are windows_msvc*, ARCH is unset -- so every MSVC core used
# to fall through to scalar. x64 has SSE2 in the ABI baseline, and
# MSVC 2015/2017 x86 already compiles to /arch:SSE2 by default, so
# neither adds a runtime CPU requirement.
#
# Spelled out one platform at a time rather than matched with
# findstring: several MSVC platform names must NOT land here.
# windows_msvc2017_*_arm, *_arm64 -- no x86 intrinsics at all
# xbox1_msvc2003 -- Pentium III: SSE, but no SSE2
# windows_msvc2010_*, -- C89-only C mode plus the
# windows_msvc2005_x86, _MSC_VER < 1700 SSE2_SET64
# windows_msvc2003_x86 path; the msvc-check CI job
# runs VS2022 and cannot verify
# either, so they stay scalar.
# No -msse2 for any of these -- cl.exe doesn't take it; see the flag
# block below.
MSVC_SSE2_PLATFORMS := \
windows_msvc2017_desktop_x64 windows_msvc2017_desktop_x86 \
windows_msvc2017_uwp_x64 windows_msvc2017_uwp_x86 \
windows_msvc2015_x64 windows_msvc2015_x86
ifneq (,$(filter $(MSVC_SSE2_PLATFORMS),$(platform)))
BLITTER_SIMD_SRC := $(CORE_DIR)/src/tom/blitter_simd_sse2.c
endif
# Native build fallback: auto-detect from host architecture, but only for
# native-build platforms (unix/osx/win). Cross-compile targets (vita, ps3,
# libnx, etc.) set platform explicitly and should not use host detection.
@@ -134,6 +160,23 @@ endif
SOURCES_C += $(BLITTER_SIMD_SRC)
# Tell blitter_simd.h which implementation header to inline into
# blitter.c. This must agree with the .c file selected above; if it
# doesn't, that .c fails to compile (duplicate definitions) instead of
# quietly running one impl while the vtable test validates another.
#
# Kept in its own variable because jni/Android.mk builds its own flag
# set from scratch (COREFLAGS) and never sees $(CFLAGS) -- it needs this
# define and nothing else from here.
ifeq ($(BLITTER_SIMD_SRC),$(CORE_DIR)/src/tom/blitter_simd_sse2.c)
BLITTER_SIMD_DEFINE := -DBLITTER_SIMD_SSE2
else ifeq ($(BLITTER_SIMD_SRC),$(CORE_DIR)/src/tom/blitter_simd_neon.c)
BLITTER_SIMD_DEFINE := -DBLITTER_SIMD_NEON
else
BLITTER_SIMD_DEFINE := -DBLITTER_SIMD_SCALAR
endif
CFLAGS += $(BLITTER_SIMD_DEFINE)
# Add -msse2 flag for all GCC/Clang SSE2 builds. No-op on x86_64 (baseline),
# required on i686 / gcc -m32. Skip for MSVC (uses /arch:SSE2, not -msse2).
ifeq ($(BLITTER_SIMD_SRC),$(CORE_DIR)/src/tom/blitter_simd_sse2.c)
+42 -4
View File
@@ -1,6 +1,6 @@
# Virtual Jaguar libretro
# Virtual Jaguar libretro — Atari Jaguar & Jaguar CD emulator
Port of the [Virtual Jaguar](http://shamusworld.gotdns.org/git/virtualjaguar) Atari Jaguar emulator to the [libretro](https://www.libretro.com/) API.
This is the actively maintained **Atari Jaguar and Jaguar CD emulator** for [libretro](https://www.libretro.com/) / RetroArch. It continues the Virtual Jaguar project, extensively rewritten and developed here: Jaguar CD support, an HLE BIOS, netplay link cable, RetroAchievements, savestates/run-ahead, and a hardware-accuracy program measured against the Jaguar Technical Reference Manual.
[![C/C++ CI](https://github.com/libretro/virtualjaguar-libretro/actions/workflows/c-cpp.yml/badge.svg)](https://github.com/libretro/virtualjaguar-libretro/actions/workflows/c-cpp.yml)
@@ -10,10 +10,13 @@ Port of the [Virtual Jaguar](http://shamusworld.gotdns.org/git/virtualjaguar) At
- Supports NTSC and PAL video modes
- 2-player input with configurable numpad mapping
- Fast and legacy blitter modes (the legacy/accurate path is SIMD-accelerated on SSE2 and NEON)
- Optional BIOS boot sequence, plus an HLE BIOS so games can boot without a BIOS image
- No BIOS files required: the Jaguar boot ROM and both Jaguar CD BIOSes are built into the core, with an HLE BIOS as the default boot path (see [BIOS](#bios))
- Save state, run-ahead (deterministic serialization), SRAM/EEPROM via the libretro SRAM interface, cheat codes, and a memory map for RetroAchievements
- Supported ROM formats: `.j64`, `.jag`, `.rom`, `.abs`, `.cof`, `.bin`, `.prg` (including inside ZIP archives), plus `.cue` and `.cdi` for Jaguar CD images, and conservative headerless raw homebrew loading
- Network link play (JagLink / CatBox emulation): Doom deathmatch, AirCars, BattleSphere Gold — via RetroArch netplay, or a direct TCP link between frontends on socket-capable platforms ([setup guide](docs/netlink-user-guide.md))
- Jaguar GameDrive (JagGD) cartridges: bank switching for images up to 16 MB, so GD-locked homebrew boots
- Audio CDs play through the Jaguar CD player and its Virtual Light Machine visualizer (set **CD Boot Mode** to `Auto` or `Real BIOS` — audio-only discs have no session-2 boot stub for the HLE path)
- Optional M68K / RISC clock-scale (overclock) core options for framerate-limited titles
## Recent improvements (libretro fork)
@@ -38,6 +41,41 @@ make platform=ios-arm64 # Cross-compile (ios-arm64, osx,
Output varies by platform: `.so` (Linux), `.dylib` (macOS), `.dll` (Windows).
## BIOS
**No BIOS files are required.** The Jaguar console boot ROM and both Jaguar CD
BIOSes (retail and developer) are embedded in the core, so every boot mode
works out of the box:
- **Cartridges** — the `BIOS (Cartridges)` core option chooses between the
HLE BIOS (default: the core performs the boot setup itself, skipping the
boot animation) and the real boot ROM. The console boot ROM is always the
embedded copy; it is never loaded from disk.
- **CD discs** — the `CD Boot Mode` core option chooses between the HLE CD
BIOS (default, recommended) and a real CD BIOS (`Real BIOS`, or `Auto`,
which currently also boots the real BIOS). In the real-BIOS modes the
`CD BIOS Type` option selects the retail or developer image.
### Optional external CD BIOS override
In the real-BIOS CD modes only (`CD Boot Mode` set to `Real BIOS` or
`Auto`), a CD BIOS ROM file in the RetroArch `system`
directory takes precedence over the embedded images — useful if you want to
run a specific BIOS revision. The file must be exactly 256 KiB and can live
directly in `system/` or in an `Atari - Jaguar/`, `Atari - Jaguar CD/`,
`jaguar/`, or `jaguarcd/` sub-folder, under one of these names:
| Type | Accepted filenames |
| --- | --- |
| Retail | `[BIOS] Atari Jaguar CD (World).j64` / `.rom` / `.bin` |
| Developer | `[BIOS] Atari Jaguar Developer CD (World).j64` / `.rom` / `.bin` |
| Generic | `jaguarcd_bios.bin`, `jagcd_bios.bin`, `jaguarcd.bin`, `jagcd.bin`, `Jaguar CD BIOS.rom`, `Jaguar CD BIOS.bin` |
Selection is by filename only — the core does not verify which BIOS a file
actually contains, so a mislabelled or corrupt file will boot to a black
screen. If real-BIOS CD boots misbehave, remove or rename any CD BIOS files
in `system/` to fall back to the known-good embedded images.
## Documentation
- [Network play setup guide](docs/netlink-user-guide.md)
@@ -61,7 +99,7 @@ This project is built on the work of many contributors. See the [full list on Gi
- SDL/Linux/Win32 port by Niels Wagenaar & Carwin Jones (SDLEMU).
- Cleanups, GUI/Qt port, and ongoing upstream maintenance by James Hammons (Shamus).
- libretro core port by libretro/RetroArch contributors.
- libretro fork maintenance — Joseph Mattiello ([@JoeMatt](https://github.com/JoeMatt)).
- Current maintainer — Joseph Mattiello ([@JoeMatt](https://github.com/JoeMatt)). This repository is where Virtual Jaguar development continues today.
## License
+13 -3
View File
@@ -5,7 +5,7 @@ supported_extensions = "j64|jag|rom|abs|cof|bin|prg|cue|cdi"
corename = "Virtual Jaguar"
license = "GPLv3"
permissions = ""
display_version = "v3.0.0"
display_version = "v3.1.0"
categories = "Emulator"
# Hardware Information
@@ -17,7 +17,7 @@ systemid = "atari_jaguar"
supports_no_game = "false"
database = "Atari - Jaguar"
savestate = "true"
savestate_features = "2"
savestate_features = "3"
cheats = "true"
input_descriptors = "true"
memory_descriptors = "true"
@@ -29,4 +29,14 @@ needs_fullpath = "false"
disk_control = "false"
is_experimental = "false"
description = "A port of the Virtual Jaguar emulator, originally based on Potato Emulation, to libretro. Supports Jaguar CD (CUE/BIN, CDI) with Memory Track saves, JagLink/CatBox network play over TCP or RetroArch netplay, a high-level BIOS that lets most commercial titles boot without a real BIOS image, save states, SRAM, cheat codes, and RetroAchievements. The accurate blitter is SIMD-accelerated (SSE2 on x86, NEON on ARM); the Blitter core option can be set to 'Fast' to trade some accuracy for additional speed on lower-end hardware."
# BIOS / Firmware (all optional -- embedded copies are used when absent)
notes = "(!) No BIOS files are required: the Jaguar boot ROM and CD BIOS images are embedded in the core.|(!) An optional CD BIOS file in the system directory overrides the embedded copy in real-BIOS CD boot mode."
firmware_count = 2
firmware0_desc = "[BIOS] Atari Jaguar CD (World).j64 (Jaguar CD BIOS, retail - optional override)"
firmware0_path = "[BIOS] Atari Jaguar CD (World).j64"
firmware0_opt = "true"
firmware1_desc = "[BIOS] Atari Jaguar Developer CD (World).j64 (Jaguar CD BIOS, developer - optional override)"
firmware1_path = "[BIOS] Atari Jaguar Developer CD (World).j64"
firmware1_opt = "true"
description = "The actively maintained Atari Jaguar and Jaguar CD emulator for libretro, continuing the Virtual Jaguar project (originally by David Raingeard of Potato Emulation). No BIOS files are required: the Jaguar boot ROM and CD BIOS images are embedded in the core. Supports Jaguar CD (CUE/BIN, CDI) with Memory Track saves, JagLink/CatBox network play over TCP or RetroArch netplay, a high-level BIOS that lets most commercial titles boot without a real BIOS image, save states, SRAM, cheat codes, RetroAchievements, and M68K/RISC clock-scale (overclock) core options for framerate-limited games. The accurate blitter is SIMD-accelerated (SSE2 on x86, NEON on ARM); the Blitter core option can be set to 'Fast' to trade some accuracy for additional speed on lower-end hardware."
+106
View File
@@ -0,0 +1,106 @@
# Virtual Jaguar libretro v3.1.0
New-feature release: Jaguar GameDrive support, clock-scale (overclock) options,
audio-CD / Virtual Light Machine playback, and savestate compatibility restored
for every format any released core ever wrote — plus a substantially hardened
test suite that now covers the CD path in CI with no commercial discs.
## Highlights
- **Jaguar GameDrive (JagGD) cartridge support** (#312, #320). Detection plus
full 16 MB bank switching (6×1 MB pages over 16 banks), implemented from
RetroHQ's published GDBIOS bindings. GD-locked homebrew that previously hung
forever now boots. New core option `virtualjaguar_jgd = auto` (default) /
`enabled` (force, for GD-locked <6 MB images) / `disabled`. Also fixes a
pre-existing loader buffer overflow for >6 MB images.
- **M68K / RISC clock-scale options** (#314, #316). Overclock or underclock
each domain independently (`virtualjaguar_m68k_clock_scale` 0.5x–3x,
`virtualjaguar_risc_clock_scale` 0.5x–2x) for framerate-limited titles.
Defaults are bit-identical to previous behaviour; bus/DRAM costs stay in
real time so the timing model is not distorted, and audio pacing is proven
unchanged at every scale (no pitch shift).
- **Audio CDs play, and the Virtual Light Machine now works** (#291, #300,
#325). Single-session audio discs no longer stall the BIOS, and the VLM
unmutes on its own: its mute gate turned out to be the Q-channel
CONTROL/ADR data-track flag, which we never served because BUTCH's subcode
registers were unimplemented. Set `CD Boot Mode` to `Auto`/`Real BIOS` —
audio-only discs have no session-2 boot stub for the HLE path.
- **Savestates: every released format loads again** (#268, #301). States from
v2.2.0 / v2.3.0 / v2.3.1 / v2.3.2 were either rejected or — worse —
silently mis-parsed from the CD chunk onward. All four released layouts now
load exactly; the loader is exact, not best-effort. Savestate format bumps
to v8 (JagGD state).
## Performance
- **Binary is ~22% smaller** (#321, #326). The UAE 68K core shipped two
complete 68000 handler tables and dispatched only one; the linker now
garbage-collects the dead table and its exclusively-reachable handlers.
Makefile-only, per-platform gated, emulation bit-identical.
- **Accurate blitter ~16% faster on blit-heavy titles** (#332, #334). The
SIMD helpers were reached through a function-pointer table across
translation units with no LTO, so nothing inlined — 20.6% of runtime sat
in five tiny leaf functions. Tempest 2000 +16.2%, other titles +6%.
Bit-identical output (132,912 blits compared, zero differences).
- **MSVC x64/x86 and every Android ABI were silently falling back to the
scalar blitter** (#333, #335) — they now get the SIMD path they should
always have had.
## Bug fixes
- **AvP: red background behind the shotgun** with the accurate blitter
(#267, #292) — DCOMPEN transparency now keys on the unshaded source pixel,
so SRCSHADE can no longer make transparent pixels opaque.
- **CD: a redundant seek re-framed the in-flight stream** (#306, #307) —
rewinding both the FIFO and SSI audio heads (up to ~13 ms of replayed
CD-DA). Found by the new synthetic tests; pinned in both byte and audio
domains.
- **External CD BIOS files are validated** (#296, #299) — a truncated or
wrong-console file at the override path now falls back to the embedded
BIOS with a log line instead of booting to a black screen. The developer
CD BIOS dump is now accepted.
- **AvP overscan artefacts investigated and closed as hardware-faithful**
(#266) — the green dot/bar lives at x≥320, where 67 corpus titles render
real content; masking would damage them and miss the artefact.
## Accuracy / investigation record
- The FMV scene-jump investigation (#297) ran every hypothesis to ground with
measurements now in `docs/fmv-drift-notes.md`: the presentation clock is
video-field-locked (drift structurally impossible), transfer rates are
within +0.15 % of hardware, FIFO scheduling charges no ISR latency, and a
BigPEmu reference capture bounds seek latency to tens of ms. A reproducible
Dragon's Lair scene-branch fixture ships in `test/fixtures/`.
## Testing
- **Three synthetic CD test suites** run in CI with zero commercial discs:
HLE read delivery (#303), real-BIOS BUTCH/DSA/FIFO (#304), and CD-DA over
the SSI on a multi-track chirp disc whose decoded audio identifies track,
position, and channel (#309).
- **Silently-skipping checks eliminated** (#308): ten real-BIOS assertions
and the Skyhammer clipping sentinel had been inert; the suite now prints a
named skip ledger ("Skipped checks: none" on a full corpus) and
`VJ_REQUIRE_ROMS=1` escalates skips to failures.
- **JagGD probe-ROM harness** (32 checks, synthetic) and non-1x clock-scale
audio-contract rows run in CI.
- The netlink latency test no longer flakes under load (#310, #317) —
absolute frame-ceiling assertions replaced a load-sensitive ratio.
## Stats
- 79 files changed, ~14,450 insertions, ~1,020 deletions across 30 merged
PRs since v3.0.0 (`git diff --shortstat v3.0.0..develop`).
## Known issues
- Battle Morph (CD) runs too fast (#331) — most likely the unmodelled
GPU/DSP pipeline hazards (#313); a reporter with real hardware has offered
comparisons.
- Pipeline-hazard timing (#313) and dram_timing domain audit (#318) are
tracked under the timing-accuracy epic (#319).
- See `docs/cd-known-issues.md` for the CD backlog.
## Downloads
Built for 16 platforms by CI on the `v3.1.0` tag.
+58 -6
View File
@@ -59,13 +59,43 @@ code (1 = single, 2 = double), then `bset #3,d2` for data mode, then
expected reply is `$1700 | payload` (`bset #9`), which
`src/cd/cdrom.c:1641` already synthesizes correctly.
### 2. FMV scene-jump schedule drift (Dragon's Lair / Space Ace / BD13)
### 2. ~~FMV scene-jump schedule drift (Dragon's Lair / Space Ace / BD13)~~ -- RESOLVED (#297: every lead run to ground; seek model calibrated to reference)
FMV titles occasionally jump scenes early/late: the games' own
delivery-clock counters drift relative to our transfer pacing (root-cause
notes: DL clock at `$129AD6/$129ADE` advances +7680/s; subcode registers
exonerated). Reproducible via RAM-snapshot counter comparison. Medium
effort; HLE-mode only polish.
**Closed 2026-08-05.** The investigation is in
[`fmv-drift-notes.md`](fmv-drift-notes.md) (§2–§11); the summary of where
each hypothesis ended:
- **"Schedule drift" is structurally impossible.** Dragon's Lair's
presentation counter is at `$562E/$5630` (not the `$129AD6/$129ADE`
this note originally cited) and it is **video-field-locked**: +32760
per field, zero jitter, identical histogram at 1x/2x/4x read speed
(§2).
- **Transfer rate is exonerated.** Both paths deliver within +0.15% of
the 352,800 B/s hardware 2x rate; the earlier "-1.11% slow /
`fifoFillDelay` re-arm" claim was refuted by direct measurement — the
fill→drain latency it blamed is zero for 99.997% of drains (§9, #305).
- **A real stream-corruption bug was found and fixed on this path**: a
redundant `$12xx` seek re-framed the in-flight FIFO/SSI stream,
replaying up to ~13 ms of CD-DA (#306, fixed #307). It may well have
been the visible "glitchy transition" in the original report.
- **Seek latency — the last open lead — is now modeled and calibrated.**
A BigPEmu reference capture of Dragon's Lair's death branch (558 ms ≈
33.4 fields of black vs our 29–30) showed the missing term is ~60 ms
on a near-full-stroke seek — tens of ms, **not** the unsourced
"30–315 ms multi-tier" range this file used to cite (its ~300 ms top
end would have shown as ~+18 fields; the capture shows +4).
`CDROMSeekDistanceTicks()` (src/cd/cdrom.c) now charges 1 halfline
tick per 72 sectors of head travel in **both** boot modes; the
measured branch gap lands at 33 fields in both, matching the
reference within a field (§11).
The reproducible branch fixture (`test/tools/run_dl_branch_fixture.sh`,
#311) plus long-run cycle checks pin the behaviour: the die/retry loop
repeats with a constant period and identical LBA sequence over 6+
consecutive cycles in each mode. Standing caveat: the seek constant is
**reference parity with BigPEmu, not silicon ground truth** — if a
hardware capture ever materializes, recalibrate `SEEK_SECTORS_PER_TICK`
against it (one number, linear model, no tiers).
### 3. Myst BIOS-mode audio parity listen
@@ -101,3 +131,25 @@ from structured noise at the right level.
- Boot-matrix rows are build-stamped; a row whose stamp does not match the
current build is re-run, never trusted (stale-row resurrection produced
the phantom "Battle Morph bios pc_escape flake").
## Known bad CDI dumps
Some DiscJuggler CDI V2 rips in the wild lack a valid
`ATARI APPROVED DATA HEADER ATRI ` magic at session-2 track `+0x42`
(after the I2S word-swap). The CDI walk/offset math is correct; the header
data is simply wrong or absent. Load is **refused** with an actionable
`[CD-BOOTSTUB]` log line (warn-and-refuse — we do not tolerate truncated
ATARI magic, which would risk false-positive boots).
Measured against the local 14-CDI corpus (10 load, 4 fail):
| Local image | Defect at `+0x42` (word-swapped) | Log signature |
|---|---|---|
| vidgrid | **zero-filled** (32/32 zeros) | `Boot header region is zero-filled` |
| ironsoldier2 | garbage / title residue (`matched 1/32`) | `matched N/32 bytes` |
| mystdemo | non-magic garbage (`matched 0/32`) | `matched 0/32 bytes` |
| worldtourracing | non-magic garbage (`matched 0/32`) | `matched 0/32 bytes` |
Re-dump from a known-good source (or use CUE/BIN) rather than filing this as
an unsupported-format bug. See issue #269.
File diff suppressed because it is too large. Load diff
+415
View File
@@ -0,0 +1,415 @@
# Jaguar GameDrive (JagGD) interface notes — research for #312
Status: phases 1+2 of §9 are implemented (`src/core/jaggd.c`, core option
`virtualjaguar_jgd`, savestate v8, `test/test_jgd.c`); the GD menu, SD/FAT
filesystem, GPU async reads, encrypted `.jgd` and `.MRQ` remain out of scope.
This document is the hardware/protocol spec of record for that
implementation, at the quality bar of `docs/memory-track.md`.
**Primary ground truth: RetroHQ's own published homebrew API**
(<https://github.com/RetroHQ/JagGD>, by SainT, the JagGD author). Unlike the
Memory Track, we did NOT have to reconstruct this interface from third-party
behavior — the cart-side 68K source (`gdbios_bindings.s`) is public and contains
the SPI register addresses, bit definitions, the full handshake protocol, and
the bank-switching semantics in SainT's own comments. Local copies of
everything cited live in the research scratchpad
(`.../scratchpad/jaggd-api/`, `.../scratchpad/jagstudio/`,
`.../scratchpad/bigpemu_disasm.txt`).
Confidence labels used throughout:
- **established** — stated in RetroHQ source (`gdbios_bindings.s` /
`gdbios.h` / `README.md`) or directly observable in shipped JagStudio code
(`RAPTORGD.O`, disassembled and verified identical to the bindings).
- **established-behavioral** — read out of the BigPEmu 1.2x arm64 binary
(`/Applications/BigPEmu.app/Wrapper/BigPEmu.app/BigPEmu`, disassembled) or
its changelog. Tells us what the ecosystem's reference emulator does, not
necessarily what silicon does.
- **inferred** — follows from the above but not directly stated.
- **guessed** — plausible, must be verified before relying on it.
---
## 1. Hardware model
The JagGD is a flash cart with (established, from the API source + product
materials):
- **16 MB of onboard SDRAM** presented to the Jaguar as cartridge ROM.
- An **ASIC/FPGA** that decodes the cart bus, maps SDRAM into the 6 MB cart
window, and exposes a small SPI-style mailbox register set.
- A **microcontroller** ("the GD", "the micro" in SainT's comments) behind
that SPI link. It owns the SD card, the FAT filesystem, serial numbers, the
menu, firmware, and it *serves the GDBIOS blob on request*.
Communication model: the 68K talks to the micro through three registers in
JERRY's **GPIO2** decode range. Cart EEPROM already works exactly this way
through GPIO0/GPIO1 ($F14800/$F15000, see `src/jerry/eeprom.c`), so the GPIO
chip selects demonstrably route to the cartridge connector; GPIO2 is the next
one up and is marked "reserved" in our own jerry.c address map (established by
analogy + the fact that the GD API uses these addresses from cart code).
## 2. Register map
All from `gdbios_bindings.s` (established) unless noted.
| Address | Name (RetroHQ) | Access | Meaning |
|---|---|---|---|
| `$F16002` | `ASIC_SPI_STATUS` | R/W 16-bit | Status + control, bits below |
| `$F16004` | `ASIC_SPI_DATA` | R/W 16-bit | Write = trigger one SPI byte exchange; word read = drain rx latch |
| `$F16005` | `ASIC_SPI_DATA_BYTE` | R 8-bit | Received byte (low byte of `$F16004`) |
`ASIC_SPI_STATUS` bits (established):
| Bit | Mask | Name | Direction | Meaning |
|---|---|---|---|---|
| 0 | `$0001` | `SLAVE_SELECT` | W | Assert slave select (ack packet start) |
| 3 | `$0008` | `HAVE_DATA` | R | Handshake bit; see GDWaitData below. Idle/ready state is **clear**; the slave raises it to ack/announce data |
| 4 | `$0010` | `PACKET_START` | W | Request packet start / block boundary ("process it, GD!") |
| 5 | `$0020` | `LATCH_FULL` | R | A received byte is sitting in the rx latch (must be drained before starting a transaction) |
| 15 | `$8000` | (busy) | R | Exchange in progress. All waits are `tst.w $F16002` / `bmi` loops, i.e. spin while bit 15 set |
Write of `$0000` to `$F16002` = end of packet / deselect (established — every
transaction ends with `clr.w ASIC_SPI_STATUS`).
**Data register semantics** (established from code shape; exact tx byte order
inferred): writing **any word** to `$F16004` clocks **one byte exchange**. The
transmitted byte is the **low byte of the written word** (inferred — see
`GDExchangeWord` trace below; the bulk-read loop writes its loop counter as a
don't-care dummy, so the written value is irrelevant when only receiving).
After bit 15 clears, the received byte is read at `$F16005`.
`GDExchangeWord` (send a 16-bit word, receive a 16-bit word — established):
```
move.w d0,$F16004 ; exchange byte #1
(wait not busy)
move.b $F16005,d0 ; rx1 -> low byte
ror.w #8,d0 ; rx1 -> high byte, old high byte -> low
move.w d0,$F16004 ; exchange byte #2 (tx = old high byte)
(wait not busy)
move.b $F16005,d0 ; result = rx1<<8 | rx2
```
`GDWaitData` (the packet handshake — established):
```
1. spin until HAVE_DATA (bit 3) == 0
2. write PACKET_START|SLAVE_SELECT ($0011) to $F16002
3. spin until HAVE_DATA == 1 ; slave ack
```
**Consequence for a machine with no GD attached:** step 3 never completes.
There is no timeout anywhere in the RetroHQ or JagStudio code. See §6.
## 3. Micro packet protocol
Transaction shape (established, from `_GD_HWVersion` and `_GD_Install`):
```
write $0010 to STATUS ; request packet start
GDWaitData ; handshake
GDExchangeWord(command) ; 16-bit command word
GDExchangeWord(param_size) ; 16-bit param byte count (0 if none)
[param bytes...]
write $0010 to STATUS ; "we're done, process it GD!"
--- response ---
GDWaitData
GDExchangeWord / byte loop ; response payload, format per command
write $0010 to STATUS ; after each ≤512-byte block
...
write $0000 to STATUS ; end of packet
```
Known command words (established):
| Cmd | Function | Response |
|---|---|---|
| `12` (`$0C`) | HW version | u32: high word = **firmware version, BCD** (e.g. `$0111` = v1.11), low word = ASIC version, BCD. Read as two `GDExchangeWord`s, high word first. After reading, the caller busy-waits ~500 dbra iterations "to let the micro finish up" |
| `$80` | Fetch GDBIOS | u16 blob size, then the blob streamed in ≤512-byte chunks, one `PACKET_START` write between chunks; each byte clocked out by a dummy word write to `$F16004` |
Before `$80`, the installer drains the rx latch: while `LATCH_FULL` set, read
`$F16004` (word) and re-check ("in case of FIFO DMA termination",
established).
All other micro commands (file I/O, serials, LED, reset, page set, …) are
issued **by the GDBIOS blob**, not by game code, and their SPI encodings are
**unknown** — and irrelevant for us, because the blob itself comes from the
emulated micro, i.e. *we get to supply our own blob* (see §7). BigPEmu
necessarily does the same thing (it cannot ship RetroHQ's copyrighted blob).
## 4. GDBIOS blob ABI (what games actually link against)
Games do not poke registers for anything except the two commands above. They
call `GD_Install(buffer)` (a 4 KB, long-aligned RAM buffer), which:
1. Calls HW version (cmd 12); **fails if firmware < `$111` (v1.11)** —
established, both in RetroHQ bindings (`MINVERSION`... actually the FW
gate) and JagStudio's `RAPTOR_GD_Init` (identical code, verified by
disassembling `RAPTORGD.O`).
2. Streams the blob (cmd `$80`) into the buffer, stores the pointer
(`GDB_Base`).
3. Checks blob version word ≥ `$100`, then calls blob function 1 (`GD_Init`).
Blob memory layout (established):
| Offset | Size | Meaning |
|---|---|---|
| `+0` | u16 | GDBIOS version, BCD, min `$100` |
| `+2` | u16 | Highest function number implemented |
| `+N*4` | 4 bytes | Entry point of function N (a 4-byte slot — enough for one `bra.w`/`jmp` stub) |
Call convention (established): `a6` = blob base; caller does
`cmp.w #N,2(a6)` / `blt fail` then `jsr N*4(a6)`. `d0-d1/a0-a1` scratch,
everything else callee-saved. Function numbers and register ABI:
| N | Name | Args in | Returns (d0) |
|---|---|---|---|
| 1 | `GD_Init` | — | — |
| 2 | `GD_InitGPURead` | a0=GPU RAM buffer (224 bytes), d0=flags (0 preserve / 1 fast) | — |
| 3 | `GD_BIOSVersion` | — | u16 BCD |
| 4 | `GD_ROMWriteEnable` | d0=1 enable / 0 disable | — |
| 5 | `GD_ROMSetPage` | d0 = page<<16 \| bank | — |
| 6 | `GD_ROMSetPages` | d0 = u32, one bank nibble per page, LSN = page 0 | — |
| 7 | `GD_GetCartSerial` | a0=16-byte buffer | 0 ok / !0 fail |
| 8 | `GD_GetCardSerial` | a0=16-byte buffer | 0 ok / !0 fail |
| 9 | `GD_CardIn` | — | 0 no / 1 yes |
| 10 | `GD_FileOpen` | a0=path, d0=mode | handle ≥0 / <0 fail |
| 11 | `GD_FileClose` | d0=handle | 0 / <0 |
| 12 | `GD_FileSeek` | d0 = flags<<16 \| handle, d1=offset | 0 / <0 |
| 13 | `GD_FileRead` | d0 = flags<<16 \| handle, a0=buffer, d1=size | 0 / <0 |
| 14 | `GD_FileWrite` | d0=handle, a0=buffer, d1=size | 0 / <0 |
| 15 | `GD_FileTell` | d0=handle | offset / $FFFFFFFF |
| 16 | `GD_FileSize` | d0=handle | size / $FFFFFFFF |
| 17 | `GD_FileAsyncPos` | — | pos |
| 18 | `GD_FileAsyncWait` | — | — |
| 19 | `GD_FileAsyncActive` | — | 0/1 |
| 20 | `GD_FileInfo` | a0=path, a1=buffer, d0=flags (bit0 = long name) | ≥0 / <0 |
| 21 | `GD_DirOpen` | a0=path | handle / <0 |
| 22 | `GD_DirRead` | d0 = flags<<16 \| handle, a0=buffer | ≥0 / <0 |
| 23 | `GD_DirClose` | d0=handle | 0 / <0 |
| 24 | `GD_Reset` | d0 = 0 normal / 1 to GD menu / 2 debug | — |
| 25 | `GD_SetLED` | d0=flags | — |
| 26 | `GD_DebugString` | a0=C string (→ virtual USB COM port) | — |
`GD_HWVersion` is **not** a blob function — it is raw SPI in the binding and
works pre-install.
## 5. Bank switching semantics
Verbatim from SainT's comments in `gdbios_bindings.s` (established):
```
ROMSetPage(u16 page, u16 bank)
Page 0 : $8xxxxx 1 : $9xxxxx 2 : $axxxxx
3 : $bxxxxx 4 : $cxxxxx 5 : $dxxxxx
Bank 0-15 is 1MB pages of the onboard 16MB SDRAM
ROMSetPages(u32 banks)
Set all SDRAM banks at once, one per nibble.
Lowest significant nibble is page 0, upto nibble 5. Data above is ignored.
```
So:
- **Granularity:** 1 MB. The 6 MB cart window ($800000–$DFFFFF) is six
independent 1 MB pages, each mappable to any of sixteen 1 MB banks of the
16 MB SDRAM. (established)
- **Window:** whole cart space. Note page 5 covers $Dxxxxx including the
$DFFF00 BUTCH-overlay tail — for emulation, keep our existing
$DFFF00–$DFFFFF handling above the banked read, exactly as today's dispatch
already orders it. (inferred, trivially safe)
- **Latch behavior:** immediate on the next bus access; a page register is
plain combinational address-translation state in the ASIC. No evidence of
any delay or double-buffering. (inferred)
- **Initial mapping:** identity (page N → bank N) so that a flat ≤6 MB image
looks like a normal cart, and a >6 MB image exposes its first 6 MB.
(inferred — required for anything to boot at all; BigPEmu boots >6 MB
images this way per its changelog)
- **Writes:** `GD_ROMWriteEnable(1)` makes the SDRAM-backed "ROM" writable
(the GD menu uses this to load; homebrew can use cart space as extra RAM).
Granularity global vs per-page unknown; assume global. (established that it
exists; granularity guessed)
- **How the real blob performs the switch is unknown** (SPI command to the
micro vs. a direct ASIC register). Deliberately irrelevant to us: games can
only reach banking through the blob ABI, and the emulator supplies the
blob. (established consequence)
## 6. GD detection — what a GD-locked title actually does
From JagStudio v1.11 (`RAPTORGD.O` disassembled; `buildfiles/template/rapapp.s`;
`projects/basic/jaguargd/`) — JagStudio is what real GD homebrew (e.g. SKYLAR)
is built with (established):
1. Projects opt in with `useGD=1`; startup then calls `RAPTOR_GD_Init`
**unconditionally, before DSP setup** ("MUST be before DSP setup").
2. `RAPTOR_GD_Init` = RetroHQ's `GD_Install` with a detect flag:
`raptor_GD_detect = 1`, run HW-version (cmd 12); if FW < `$111` →
`GDB_Base = 0`, `detect = -1`, return. Otherwise install blob + `GD_Init`.
3. Game code branches on `raptor_GD_detect == 1` ("GD Cart Detected") — and
every wrapped call re-checks `GDB_Base != 0` and the function-count word,
returning -1 gracefully when absent.
**The failure mode without a GD is a hang, not a refusal**: `GDWaitData`
step 3 spins forever waiting for `HAVE_DATA` to go high. On our core today,
`$F16002` reads fall into the `F14000–F1A0FF` catch-all in
`src/jerry/jerry.c` → `EepromReadWord` → returns `$0000` for non-EEPROM
offsets — bit 3 never rises, the 68K wedges in `.waitAck`, and `crash_detect`
will report `video_stall`. BigPEmu with JGD off behaves the same way (its GD
register special-case is gated on a JGD-enabled flag bit; disabled, the read
falls through to a flat memory array) — which is exactly why Batocera tells
users GD-locked games "won't boot" until they turn on Force JGD.
So the **detection contract we must implement** is precisely:
status handshake (bit 3 sequencing + bit 15 busy + bit 5 drain), command 12
returning FW ≥ `$111` (recommend `$0300`/`$0102`-style current-ish BCD values;
anything ≥ `$0111` works), and command `$80` serving a valid blob.
What we do *not* have to implement for detection: serial numbers can return
zeros-with-success (titles hard-locked to one physical cart serial are
distributed as encrypted `.jgd` files we can't load anyway — out of scope,
same as BigPEmu).
## 7. What BigPEmu does (established-behavioral)
From its changelog (mirrored at emunations.com) and the arm64 binary:
- v1.01 "Added JaguarGD bank switching support." v1.02 "Added an option to
force Jaguar GD emulation, which enables bank switching even in ROM images
smaller than 6MB" (config key `ForceJGD`; sits alongside `AttachButch`,
`AttachMT`, `ShareMT`, `CDSeekSpeed` in `BigPEmuConfig.System`). v1.19
"Added more Jaguar GD functionality. Filesystem functions are stubbed out…"
- Its JERRY byte/word read+write handlers special-case addresses ≥ `$F16002`
behind a JGD-enable flag. The status read is synthesized: `LATCH_FULL` is
set from a response-FIFO head≠tail comparison, and the read has
side effects on the stored status bits (read-sensitive handshake
emulation). `PACKET_START` writes raise a "process packet" flag consumed by
a command engine that fills the response FIFO. I.e. **BigPEmu emulates the
GD at the SPI-register level for detection/install**, matching §2–§3.
- GD *function* services are HLE'd host-side: its file-open paths read the
filename out of emulated RAM and call host filesystem/`wcslen` code — i.e.
the blob it serves is a thin trampoline into native handlers. (This is the
v1.19 "filesystem stubs" layer.)
- Auto-enable: banking turns on automatically for images ≥ 6 MB; `ForceJGD`
covers smaller GD-locked images. Our proposed core option
`virtualjaguar_jgd = disabled|auto|enabled` maps 1:1.
- `.MRQ` (GD box-art/metadata sidecar) support is pure UI, ignore.
## 8. Remaining unknowns, and the cheapest experiment for each
| Unknown | Impact | Cheapest resolution |
|---|---|---|
| Exact tx byte order of `$F16004` word writes (low-byte-out inferred) | None if we implement to satisfy the binding code shape (we are the only slave); matters only for exotic homebrew doing raw SPI | Write a 20-line probe ROM from the bindings, run under BigPEmu with its scripting/debugger tracing `$F16004`; or ask SainT on AtariAge |
| Full micro SPI command set beyond 12/`$80` | None for scope of #312 (blob is ours) | Dump the real blob: run `GD_Install` on real hardware and save the 4 KB buffer to SD with `GD_FileWrite` — a ~30-line JagStudio program; also answers the next row |
| What the real GDBIOS `ROMSetPage` pokes | None (same reason); would be nice for a hardware-faithful mode | Same blob dump, disassemble |
| Initial page map on boot (identity assumed) | Boot of >6 MB images | Run SKYLAR 1.0 in BigPEmu, break before first `ROMSetPage`, checksum the six pages against file offsets |
| `ROMWriteEnable` granularity (global assumed) | Writable-cart feature only | Blob dump / SainT |
| Stock-console (no GD) read value at `$F16002` | Only affects how faithfully "GD absent" hangs; our current `$0000` reproduces the observed hang class | Real-hardware probe; not needed for #312 |
| Whether any homebrew calls raw SPI beyond `GD_HWVersion` | Compatibility ceiling | Corpus scan for `$F160` word constants in ROM images once we have GD titles locally |
None of these block implementation.
## 9. Implementation sketch (mapped onto our code)
New `src/core/jaggd.c` + `jaggd.h` (mirror `memtrack.c` structure):
- **State:** `jgdEnabled` (from core option `virtualjaguar_jgd =
disabled|auto|enabled`; `auto` = on when image > `0x5FFF00` bytes),
`uint8_t jgdPage[6]` (init 0..5), full-image buffer `jgdROM` (≤16 MB;
`jaguarMainROM`/`jagMemSpace` cart region stays as-is for the non-GD path),
SPI engine: `status`, small state machine (IDLE → HANDSHAKE → CMD → LEN →
PARAMS → RESPONSE), response byte-FIFO, `rxLatch`.
- **Register hook:** in `src/jerry/jerry.c`, intercept `$F16000–$F16007`
in `JERRYReadByte/Word` and `JERRYWriteByte/Word` **before** the
`F14000–F1A0FF` EEPROM catch-all (which currently swallows these
addresses), delegating to `JGDControlRead/Write` when `jgdEnabled`.
Behavior per §2–§3: implement exactly the sequence the bindings perform;
respond to cmd 12 with FW `$0300` / ASIC `$0102` (any BCD ≥ `$0111` FW),
cmd `$80` with our blob; unknown commands → zero-filled responses.
- **The served blob:** ~200 bytes of hand-written 68K, embedded via bin2c
like `src/bios/*` (source checked in under `src/bios/` with its build
script, same pattern as the CD boot stub). Header `$0100`, func count 26.
Implemented: 1 (rts), 3 (version), 4 (write enable), 5/6 (page set),
7/8 (zero serial, return 0), 9 (return 0), 24 (write to reset backdoor —
or just rts initially), 25/26 (rts); file/dir/async functions return -1
(`moveq #-1,d0; rts` — BigPEmu also only "stubs" these; genuinely
implementing them against a host directory is possible later via the same
backdoor). Banking mechanism for the blob: functions 5/6 write the packed
bank value to a **backdoor register we define at `$F16006`** (word write,
inside the GPIO2 range we already own; nothing else decodes it). This
keeps the blob 68K-only and trivially small, and keeps all interpretation
in `jaggd.c`. `retro_deinit` must reset all of this (iOS static-state
rule).
- **Cart read path:** in `src/core/jaguar.c` `JaguarReadByte/Word/Long`
cart branches (and the DMA/`JaguarRead*`-alike helpers at lines ~659-690),
when `jgdBankingActive`:
`off = address - 0x800000; return jgdROM[((uint32_t)jgdPage[off >> 20] << 20) | (off & 0xFFFFF)]`
— one table lookup + shift, same cost class as the existing MEMTRACK
gate. Word/long reads that could straddle a 1 MB boundary: only possible
for the odd-address/boundary cases; handle by composing bytes (straddling
reads are already rare/degenerate on real carts). `GD_ROMWriteEnable`
makes the same translation apply in `JaguarWriteByte/Word` cart branches
(write into `jgdROM`).
- **Loader:** `src/core/file.c` `JaguarLoadROM`/callers currently cap at
the cart window; accept up to `0x1000000` (16 MB), copy first 6 MB into
`jaguarMainROM` for the flat path, keep full image in `jgdROM`, set
auto-enable. (Alignment/padding to 1 MB multiple.)
- **Savestate:** serialize `jgdEnabled(active)`, `jgdPage[6]`, SPI engine
state + FIFO; single version bump per release policy
(`docs/savestate-compat.md`). The 16 MB image itself is NOT serialized
(same as cart ROM today) — but pages written via `ROMWriteEnable` are
dirty state; simplest correct v1: serialize a dirty flag and, if any
write occurred, the full written-page set. (Defer: most titles won't
write.)
- **Memory map / RA:** `test/tools/test_memory_map.c` expects the current
descriptor layout; cart descriptor stays the 6 MB window (it describes the
68K address space, which is unchanged).
- **Out of scope** (match BigPEmu): GD menu, SD/FAT filesystem semantics,
`GD_InitGPURead` GPU async reads (function 2 = rts; `GD_FREAD_GPU` modes
are documented to not function without it, so returning -1 from FileRead
keeps the contract honest), `.jgd` encrypted images, `.MRQ` sidecars.
Suggested implementation order: (1) register hook + detection handshake +
blob install — gets "GD Cart Detected" on the JagStudio example; (2) banking
+ >6 MB loader — gets SKYLAR; (3) savestate + core option plumbing + tests.
## 10. Test cases
| Title | What it exercises | Local availability |
|---|---|---|
| JagStudio `jaguargd` example | `GD_Install`, detect flag, serials, LED, file I/O — prints "GD Cart Detected"/"NOT Detected" on screen | **Yes** — prebuilt `build/jaguargd.bin` (BJL/RAM-load format) in the scratchpad JagStudio tree; rebuild to `.rom` with JagStudio (Windows/Wine or the shipped `jaggd-x64` tooling) for a cart image |
| SKYLAR 1.0 preview (JagFest 2024) | The real thing: 16 MB image, live `ROMSetPage` banking + GD file access | No — download from the AtariAge thread ("SKYLAR 1.0 - 16MB BankSwitching GameDrive ROM", topic 371072; needs the SKYLAR directory on "SD card" — file calls will return -1 under our stub, so expect partial function: boot+banking yes, streamed assets no |
| Own probe ROM (recommended) | Deterministic harness ROM built with rmac + the RetroHQ bindings: install, `ROMSetPages`, checksum each page window, report via screen color / RAM flag readable by `harness_dlsym` | Build ourselves — best regression asset, no distribution problem, drop into `test/roms/` |
| GD-locked commercial homebrew | The ForceJGD path for <6 MB images | None in the local corpus (scanned `test/roms/private` — no GD titles, nothing >4 MB unpacked). Names of specific GD-locked retail homebrew were not pinned down by public sources during this research; Batocera/BigPEmu docs confirm the class exists without naming titles. Acquire via AtariAge when needed; the probe ROM covers the mechanism in the meantime |
The local corpus check: `test/roms/private/ROMS` is a classic-era set (139
retail entries + PD folder); no GD-aware or >6 MB images. The two >4 MB files
are 7z archives (Ultra Vortek, PD collection).
## 11. Source index (provenance)
- `github.com/RetroHQ/JagGD` — `gdbios.h`, `gdbios_bindings.s`, `README.md`
(SainT/RetroHQ, official). Registers, bits, protocol, blob ABI, page/bank
semantics, GPU-read notes. Cloned to scratchpad `jaggd-api/`.
- JagStudio v1.11 (`reboot-games.com/jagstudio/releases/jagstudio-v1.11.zip`)
— `buildfiles/raptor/RAPTORGD.O` (disassembled with capstone; byte-for-byte
the same logic as the bindings + `raptor_GD_detect`), `RAPTORGD.INC`,
`template/rapapp.s` (`useGD=1` init ordering), `projects/basic/jaguargd/`
example. Scratchpad `jagstudio/`.
- BigPEmu macOS arm64 binary — JERRY handler special-cases at
`0x1001d0a0c`/`0x1001d1f88`/`0x1001d22f8` (GD SPI status/data emulation,
gated on a mode flag), GD filesystem HLE cluster near `0x10014b150`
(host-side file ops on emulated-RAM filenames), config key block
(`ForceJGD` next to `AttachButch`/`AttachMT`). Disasm at scratchpad
`bigpemu_disasm.txt` (2M lines).
- BigPEmu changelog v1.01/v1.02/v1.19 via emunations.com mirror; BigPEmu user
manual (richwhitehouse.com) for MRQ note.
- AtariAge topic 371072 (SKYLAR 1.0) — existence/shape of a real 16 MB
banking title; JagStudio credit "Rik, CJ and SainT for implementing …
Bankswitching / GameDrive files … in JagStudio".
- Batocera wiki `systems:jaguar` — user-facing confirmation that GD-locked
games need Force JGD Emulation.
- This repo: `src/jerry/jerry.c` GPIO map comment (F16000-F16FFF = GPIO2
reserved; F14000-F1A0FF catch-all currently swallowing GD addresses via
`EepromReadByte` → `$0000`), `src/core/jaguar.c` cart dispatch + MEMTRACK
overlay pattern, `src/jerry/eeprom.c` GPIO0/1 precedent that GPIO decodes
reach the cart connector.
+499
View File
@@ -0,0 +1,499 @@
# Open-issue handoff — verification, tooling and ticket plan
Written 2026-08-03 against `develop`. Audience: an agent or engineer who will
break this into work tickets and execute them. Every claim here is either
verified (marked so) or explicitly flagged as unconfirmed — do not promote an
unconfirmed item to fact without re-measuring.
**Read `CLAUDE.md` first.** It is not optional context; it carries the C89
rules, the branch policy, the private-ROM-tree warning, and the test-ABI
mechanics that every ticket below depends on.
---
## STATUS UPDATE — 2026-08-04 (post-merge)
Everything in the ticket table below has landed on `develop`, and two of the
four bug tickets moved substantially. Read this block before acting on the
sections underneath it; where they disagree, this block is newer.
| Ticket | State now |
|---|---|
| #266 | **Root-caused.** Not a rendering bug: a real 1-bpp OP object AvP parks at XPOS=320, exposed because our presented window is 326 columns wide (`VIRTUAL_SCREEN_WIDTH`, `src/tom/tom.h`) while the picture is 320. Faithful output a CRT hid. Open on a presentation decision (overscan-crop option). |
| #267 | **Fix in PR #292** — DCOMPEN transparency now keys on the unshaded source. 448 red pixels -> 0, all gates green. Open until confirmed in RetroArch. |
| #268 | Decision + inspector merged. Open, blocked on the reporter pasting `vjss_info` output for the two files. |
| #269 | **Closed.** Warn-and-refuse shipped; truncated-magic tolerance explicitly rejected. |
Merged tooling now available (do not rebuild): `test/tools/vjss_info.c`,
`VJFBDIG3` band digest in `test/tools/fb_row_digest.c` + `fb_row_diff.py`,
`test/fixtures/avp_reach_gameplay.press` (Alien) and, in PR #289,
`avp_reach_marine_shotgun.press` (Marine + shotgun).
**The headless caveat survived contact with reality, in both directions.** #266
and #267 both turned out to be reproducible headlessly once the right tooling
existed — so "not reproduced headlessly" really was a tooling gap, exactly as
the caveat warned. That does not weaken the caveat for the remaining unknowns:
neither ticket has a RetroArch confirmation of on-screen severity.
---
## 0. Cross-cutting rules that will bite you
These have each cost real debugging time in this repo. Internalise them before
opening any ticket.
1. **Headless ≠ what RetroArch shows.** The harness does not read the same
composited framebuffer RetroArch presents. A visual artefact can be real and
still be invisible headlessly (`CLAUDE.md`, "Headless framebuffer caveat").
*"Not reproduced headlessly" is never proof a visual bug is fixed.* This
directly governs #266.
2. **Verify the binary you are testing.** `make` can skip a rebuild when file
mtimes are second-identical. Always:
`VJ_EXPECT_BUILD=$(./scripts/build-id.sh) ./test/tools/<harness> ...`
3. **Test ABI switching.** `make` links the production-slim ABI; `make test`
needs `TEST_EXPORTS=1` for harnesses to `dlsym` internals.
4. **Branch from `libretro/develop`**, never local `develop`, never `master`.
5. **Never relax a test threshold to make a PR green.** If a real fix
legitimately changes a measured value, that is a deliberate, called-out
baseline update — not a side effect.
6. **Never `git clean -xfd`.** `test/roms/private` is a symlink to an
irreplaceable ROM tree outside the repo. Use `find -L` to traverse it.
7. **Set `DEVELOPER_DIR=/Library/Developer/CommandLineTools`** on macOS host
builds, or every invocation raises an App Management prompt.
8. **A stale-object trap exists**: if another session cross-built for iOS in
this tree, `make` fails with *"building for 'macOS', but linking in object
file built for 'iOS'"*. Fix is `make clean`, not investigation.
### Tooling inventory (all already exist — do not rebuild these)
| Tool | Use |
|---|---|
| `test/harness/harness.h` | Shared dlopen/init/run harness. Common CLI: `--json --frames N --option K=V --press FRAME:BUTTON[:HOLD]`. **Scripted input is how you reach in-game states headlessly.** |
| `test/tools/cd_visual_verify` | Per-second motion timeline, non-black coverage, audio RMS, periodic PPM screenshots. `sips -s format png` to read them. |
| `test/tools/test_blitter_compare` | Fast-vs-accurate per-blit diff. `--frame-window F L`, `--cmd-filter MASK VAL`, `--load-state`. Isolated the Iron Soldier wireframe bug. |
| `test/tools/cd_wedge_probe` | Frozen-framebuffer detector; dumps 68K regs, pcQueue, CD trace, RAM. Exits 42 when caught. |
| `test/regression_test.sh` | Screenshot regression vs `test/baselines/` (baselines are local, not committed). |
| `test/tools/cd_boot_matrix.sh` | Per-title CD boot-stage matrix, both modes. **22 rows, all `GAME_CODE` — this is the CD regression gate.** |
| `test/test_cd_hle_boot` + `VJ_TEST_CD_EXTS=cdi` | Corpus sweep across disc images. |
| `src/core/crash_detect.c` | Runtime watchdog: `gpu_pc_escape`, `dsp_pc_escape`, `gpu_wedge`, `dsp_wedge`, `video_stall`, `cd_seek_wedge`. Add new signatures here rather than sprinkling one-off logs. |
| `VJ_CD_TRACE=1` | CD trace ring (DSA TX/RX, seek, FIFO). `VJ_HARNESS_LOG_INFO=1` / `VJ_HARNESS_LOG_DEBUG=1` to surface it. |
---
## 1. #266 — AvP green dot / green right-edge (x ≥ 320 overscan)
> **2026-08-04:** Confirmed real. 2000-frame sweeps with the merged band digest
> find `#00FC38` at exactly `(324,32)` in HLE (1 px/frame, 1679/2000 frames) and
> at `x=323..325`, `y=1..239` under BIOS (366 px/frame, 1123/2000). That colour
> never appears at x<320. Sharpest lead: 9 HLE frames have a fully black active
> area while the green dot persists — the strip is not cleared in step with the
> active picture. Caveat found the hard way: `band_first_x/y` under-reports this,
> since it only surfaces green when nothing precedes it in row-major order — use
> a colour census instead.
**Status: ROOT-CAUSED 2026-08-04; diagnostic probe merged as PR #293.** Still
open on a presentation decision, not on investigation. Not a rendering bug — a real OP object
AvP parks off-screen, exposed by our 326-column presented window. Open on a
presentation decision, not on investigation. Details in the block above.
### What is established
- BIOS off, both blitters, frame 1500: a single `#00FC38` pixel at exactly
`(324, 32)`.
- BIOS on, both blitters: textured `#00FC38` at `x = 323..325`, `y ≈ 0..240`.
- Both hit ranges are at **x ≥ 320**. AvP's framebuffer is 326 columns; emulated
active video is 320. Columns 320–325 are an overscan/border strip that a
frontend may crop or show.
- Present in v2.1.0 (`48096c1`) — **not a regression**.
- The old "line buffer not cleared because AvP doesn't set BGEN" theory is
**superseded**: BGEN *is* set for AvP with `BG=0x0000`.
- ~~Two later full sweeps on develop found no green pixels anywhere in the
border rows.~~ **Superseded — those sweeps were blind, not clean.** Every
check at the time folded columns ≥320 into a row hash, so the band was never
actually inspected. A colour census over the band finds the green in both
BIOS modes. Related trap: the `VJFBDIG3` digest's `band_first_x/y` also
under-reports it, surfacing green only when nothing precedes it in row-major
order — use a census, not first-hit.
### The decisive question
Is anything actually written into columns 320–325, or is the artefact produced
downstream in presentation/composition? The two sweeps point at the latter, but
the headless read path is exactly the thing that cannot answer it.
### Verification plan
1. **Get a current-nightly RetroArch capture with overscan cropping explicitly
disabled.** This is the gating input; without it the ticket cannot progress.
If the artefact is gone on a nightly, close it.
2. If still present: instrument the write side, not the read side. Determine
whether TOM/OP/blitter ever writes `x ≥ 320` for AvP, by adding a temporary
counter over the overscan columns rather than by screenshotting.
3. Compare what the core hands to `video_cb` against what RetroArch composites —
the delta *is* the bug if step 2 shows clean writes.
### Automation worth building
Extend the A/B framebuffer digest tooling to emit a **separate digest for
columns 320–325**, corpus-wide. Overscan content is currently invisible to every
existing check; a per-column-band digest turns "did anything change in the
border strip" into a one-line regression assertion, and it generalises past AvP.
### Acceptance criteria
Either (a) a nightly capture shows the artefact gone → close with evidence, or
(b) the write-side instrumentation names the component that fills x ≥ 320, and a
fix is gated so that no other title's overscan digest changes.
### Traps
Do not "fix" this by blanking columns 320–325 unconditionally. That would hide
the symptom, and if some title legitimately renders there it becomes a new bug
with no signature.
---
## 2. #267 — AvP red background behind shotgun (accurate blitter)
> **2026-08-04:** Reproduced headlessly and root-caused. The artefact is behind
> the shotgun's **HUD slot-1 icon** (`x[249..298] y[62..79]`), not the in-view
> weapon sprite as the report reads. Blit `cmd=$49800601` =
> SRCEN|UPDA1|UPDA2|LFU_AN|LFU_A|DCOMPEN|**SRCSHADE** — `BKGWREN` and `DSTEN` are
> both clear, so #166's `!bkgwren` guard never applied. With an all-zero source
> the accurate path writes CLUT index N where fast writes N-1, leaving the
> backdrop at index 1 (dark red) instead of 0. Fix not written.
>
> Two gotchas: a save state taken after the pickup does **not** carry the
> artefact (so `test_blitter_compare --load-state` can't iterate on it — drive
> from boot), and the fixture's post-briefing route is a seeded wander that
> assumes the core stays deterministic.
**Status: FIXED — PR #292 merged to `develop` 2026-08-04.** The fix keeps an
unshaded copy of the source pixel (`srcd_cmp`) so DCOMPEN transparency compares
the pixel as read from memory: SRCSHADE's intensity offset belongs to the write
data only, and shading a transparent (PATD-matching) pixel must not make it
opaque.
### What is established
- Symptom requires **Fast Blitter OFF** and a specific in-game weapon state.
- The reporter's guess that #166 is responsible was checked: #166 gated a
phrase-mode `dstd` read on `!bkgwren` at `src/tom/blitter.c:2993`. An audit
found the only other unconditional `dstd` read (`blitter.c:2872`, inside
`if (dread)`) cannot fire under AvP's `!dsten && bkgwren` combination — so
the guard *should* cover the case as understood. That makes **"it is a
different blit type"** the stronger lead — **now confirmed**: the blit is
`cmd=$49800601` with SRCSHADE set and `BKGWREN`/`DSTEN` both clear, so #166's
guard never applied. DCOMPEN was comparing against the *shaded* source, so a
transparent pixel shaded to index 1 became opaque.
- ~~On current develop, fast vs accurate are byte-identical across a full
900-frame AvP timeline.~~ **Misleading — that timeline never reached the
state.** The Alien fixture cannot pick up a shotgun (a Marine weapon). Driving
to the Marine pickup shows exactly 448 differing pixels per frame from 10560
onward. Agreement outside that window is real: 31,474 blits over frames
11800–11900 diverge on none.
### Verification plan (strictly ordered — step 1 is the whole unlock)
1. **Build an input script that reaches the shotgun.** Use
`--press FRAME:BUTTON[:HOLD]` to navigate menus into gameplay and select the
weapon. Until this exists, nothing else in this ticket can run. Treat this as
its own ticket — it is reusable infrastructure, not overhead.
2. Re-confirm on a current nightly, accurate blitter, at that moment. If the
byte-identical result holds through the shotgun draw, close as fixed by
unrelated blitter work — with the frame window quoted as evidence.
3. If it reproduces: `test/tools/test_blitter_compare` with `--frame-window`
around the draw and `--cmd-filter` to narrow to the offending B_CMD. This is
the exact technique that isolated the Iron Soldier wireframe-tank bug.
4. From the isolated command, determine the blit type and which inhibit mask is
wrong.
### Automation worth building
The step-1 input fixture. Bank it as a named scripted-input fixture other AvP
tickets (#266) can reuse, and add the resulting frame to the screenshot
regression corpus so a future regression is caught without manual play.
### Acceptance criteria
Either a quoted byte-identical comparison through the shotgun draw (close), or
an identified B_CMD plus a fix that leaves `test_blitter_compare` at zero
divergent blits corpus-wide.
---
## 3. #268 — AvP savestates rejected (below STATE_MIN_VERSION)
**Status: fixed. This section is kept for the history; the earlier "not a
bug, needs a decision" reading was wrong.** See `docs/savestate-compat.md`.
### What the investigation actually found
Two defects, and the second is the bigger one:
- `STATE_MIN_VERSION` was `2`, so v1 states (release v2.2.0) were refused.
That is the reported symptom.
- **v1, v2 and v3 states all mis-parsed from the CDROM chunk onward.** v2
(v2.3.0/v2.3.1) and v3 (v2.3.2) were *inside* the accepted window:
`retro_unserialize()` returned true, the core kept running, and every chunk
after the CD block was read at the wrong offset. The CD-support work
restructured the CDROM chunk (−2627 bytes) with only its trailing 28 bytes
version-gated. Nothing user-visible flagged it.
So the release-notes claim that states "from the v2.x line load normally" was
false as shipped in v3.0.0.
### What was done
`STATE_MIN_VERSION` is `1`, `DACStateLoad` gates the I2S resampler fields, and
`CDROMStateLoad` forks on `STATE_VERSION_CDROM_RESTRUCTURE` to consume the
legacy 8004-byte block. Both fixes are exact, not best-effort — the defaulted
fields are all re-derived before use (DAC) or belong to a CD path those cores
never had (CDROM). This deliberately reverses the "not adding a legacy loader
in this pass" call recorded on the issue, on the grounds that the loader turned
out to be exact.
Verified with genuine states written by v2.2.0, v2.3.0 and v2.3.2 cores built
from their own tags against the real AvP ROM; `test/test_state_compat` guards
all four released layouts in CI.
### Still open on the ticket
The reporter's two files were never available, so *their* header versions
remain inferred rather than measured. `test/tools/vjss_info` answers it in one
line if they turn up.
---
## 4. #269 — CDI V2 rips with truncated/absent boot headers
> **2026-08-04 — CLOSED.** Warn-and-refuse shipped; truncated-magic tolerance
> rejected. Note `worldtourracing` reports `matched 0/32` at `+0x42`, not the
> 22/32 this section claims below — the "partial magic" premise doesn't hold at
> the offset the boot stub actually checks.
**Status: CLOSED 2026-08-04 — warn-and-refuse shipped.**
### What is established
Of 14 local CDI images, 4 fail `retro_load_game` — all CDI V2 rips:
- **ironsoldier2, mystdemo, vidgrid** — the boot-header region is **zero-filled
in the file itself**. No offset math can recover data that is not present.
These are bad dumps.
- **worldtourracing** — ~~carries a partial 22-of-32-byte boot magic.~~
**Wrong.** It reports `matched 0/32` at `+0x42`, the offset the boot stub
actually checks. The 22/32 figure came from a different offset. The
"partial magic" premise that motivated the tolerance option does not hold,
which made the decision easier, not harder.
The CDI walk itself is correct; this is not an offset bug. (Verified previously:
the V2 rips are `zeros(N) + content`, N = 112/76.)
### Decision — made and shipped 2026-08-04
**Warn and refuse.** `CDIntfExtractBootStub()` now names the dump problem: a
zero-filled region gets an explicit bad-rip message, anything else reports
`matched N/32 bytes` with the denominator from `sizeof(MAGIC)`. Known-bad dumps
are tabulated in `docs/cd-known-issues.md`. Corpus unchanged at 10 pass / 4 fail.
Truncated-magic tolerance was **rejected**: a prefix match against decoy-prone
data is a weak predicate, and a wrong accept boots garbage instead of failing
cleanly.
### Verification plan
`VJ_TEST_CD_EXTS=cdi test/test_cd_hle_boot` over the corpus (this is how the
four were found). Whatever is implemented, assert the *message* in that sweep,
not just the pass/fail.
### Acceptance criteria
All 14 CDI images produce either a successful boot or a specific, actionable
diagnostic naming the dump defect. Zero regressions in the 22-row boot matrix.
---
## 5. VLM / audio-CD support — tracked as #291
**Status: audio-CD support shipped — PR #300 merged to `develop` 2026-08-04;**
**#291 remains open for the rest of the VLM surface.** Findings below were re-verified
against the tree on that date; three claims in the original writeup were wrong
and are corrected inline. All behavioural observations come from a *synthetic*
two-tone CUE, never a real audio CD.
### What is established (all verified)
- The **Virtual Light Machine ships inside the CD BIOS we already compile in**.
`src/bios/jagcdbios.c` and `jagdevcdbios.c` are both 262144 bytes but declare
*different* symbols (`jaguarCDBootROM` and `jaguarDevCDBootROM`). Both contain
`Virtual Light Machine v0.9 / (c) 1994 Virtual Light Company Ltd. / Jaguar
CD-ROM version / FFT code by ib2 / Grafix code by Yak`. No BIOS file needed.
- Booting a synthetic single-session audio CD with
`virtualjaguar_cd_boot_mode=bios` runs the real BIOS — logo + starfield
animate for 90 s — but **never hands off to the VLM**.
- Trace shows **three** DSA commands sent, then silence: `$7001` (Set DAC Mode),
`$150A` (Set Mode = double speed + **data**), and `$0300` (Read session TOC),
answered `$0301` — first TOC word, bit 0 set, "more follows". The BIOS never
reads the next word and never sends another command. No seek
(`$10/$11/$12`), no Play (`$01`). Source: the unfiltered CD trace ring
(`CDTraceDump()`, `VJ_CD_TRACE=1`) — **not** the `[CDDA] DSA cmd` log, which
filters to high bytes `$01/$04/$05/$15/$51/$70` (`cdrom.c:1608-1610`) and so
cannot show `$03xx` or seeks at all.
- So the BIOS stops **after** probing the disc, one TOC word in. Why is unknown.
(Untested: whether button presses change anything.)
- HLE rejects the disc outright: `[CD-BOOTSTUB] Early exit: loaded=1
numSessions=1`, then "unsupported or invalid content format".
- Root cause is **disc shape**: the CD stack is built for 2-session game discs;
`cdintf.c` gates on `numSessions >= 2` (≈ lines 427, 445, 1164, and **1335**, which is the gate that actually produces the observed failure).
### Two corrections to natural assumptions
- **"Add an option to always boot the BIOS" is not the fix** — that option
already exists (`CD Boot Mode = Real BIOS`) and is exactly what was tested.
- **Nearly every Jaguar CD track is `TRACK AUDIO`, including data tracks** —
433 of 434 across the 35 corpus CUEs; the one exception is
`Alice's Mom's Rescue!` (unlicensed homebrew, `MODE2/2352`). `FLAGS DCP` is
**not** universal — only 14 of 35 CUEs carry it, so do not gate on `FLAGS`.
Game discs and music CDs are indistinguishable by track type; the BIOS
separates them by the `ATARI APPROVED DATA HEADER` magic in session 2.
### Verification plan
1. Find what the BIOS polls after `$150A` and why it never advances. Trace the
68K PC in the stall loop and identify the register/response it waits on.
2. Serve a single-session all-audio disc convincingly: TOC plus CDDA.
3. Success = the VLM appears and reacts to the audio.
### Repro
```bash
# synthesize a music CD: two audio tone tracks, one session, no ATRI header
./test/tools/cd_visual_verify <core> musiccd.cue \
--option virtualjaguar_cd_boot_mode=bios \
--frames 5400 --outdir DIR --shot-every 1800
# add VJ_CD_TRACE=1 VJ_HARNESS_LOG_DEBUG=1 to see the DSA traffic
```
### Traps
- The VLM is BIOS code — it can **only** work in real-BIOS mode. HLE will never
produce it. Do not scope an HLE path.
- Gate any change strictly on **1 session AND no ATRI header** so game discs
take byte-identical paths. `cdintf.c`/`cdrom.c` are the code that took the
longest to stabilise for game boot.
- **Regression gate: the 22-row CD boot matrix must stay all `GAME_CODE`.**
### Follow-on this unlocks
`supports_no_game` (currently `"false"`; `libretro.c` has `if (!info) return
false;`) would let the CD BIOS boot with no disc. That is also the only context
in which libretro **disk control** becomes meaningful — swapping audio CDs while
the VLM runs. Note `disk_control = "false"` in the `.info` file is *correct
today*: the CD BIOS jump table (18 entries, `jagcd_hle.c:60-77`) has no
disc-status call and the reverse-engineered DSA set has no lid/status opcode, so
a game cannot be told a disc changed. Enabling disk control without that would
let a frontend swap the image under a running game — a corruption path, not a
feature.
---
## 6. #252 — Crowdin: in-repo work merged, org-side work remains
The pipeline landed (PR #264). **Nothing further is a code ticket**; four steps
need libretro org access:
1. Obtain a Crowdin access token for this repo (Crowdin project managers, or the
`retroarch-translations` Discord channel).
2. Add it as the Actions secret **`CROWDIN_API_KEY`**.
3. Run **Crowdin Translations Initial Setup** manually, once, then delete or
disable it — re-running can overwrite newer Crowdin-side translations.
4. Run **Crowdin Translation Sync** manually once to confirm it can push to
`develop`.
Until step 2, both workflows fail fast on the empty-key guard by design.
**Known residual gap** (documented in `docs/core-option-translations.md`): the
vendored `intl/` scripts call `subprocess.run()` without `check=True`, so a
token that is present but *rejected*, or a Crowdin outage, still yields a green
run that changed nothing. Belongs upstream.
**Per-language enablement is manual and expected:** the generator writes only
`libretro_core_options_intl.h`; each language stays inert until its `NULL` in
`options_intl[]` is replaced with `&options_xx`. That is upstream behaviour, and
referencing the symbol before the header defines it would not compile.
**Risk note (dependabot bumps already on `develop`):** PRs #278–#281 all merged
before any live Crowdin run. #278 is the non-routine one —
`ad-m/github-push-action` **0.8.0 → 1.3.0** (major), the action that pushes
regenerated translations in
`.github/workflows/crowdin_translation_sync.yml`. The other three
(`setup-python` 4→7, `checkout` 4→7, `setup-java` 4→5) land in the same
workflows. Until step 4 above succeeds once, a sync failure is ambiguous
between "bump broke it" and "was never set up". Check the push-action
changelog for breaking input changes (`github_token`, `branch`) when
triaging the first live failure.
---
## 7. #254 — Standalone SDL frontend
Parked feature, no investigation needed. The useful framing for whoever scopes
it: **`test/harness/` is already most of a frontend** — it implements the
libretro callbacks headlessly. Phase 1 (CLI: `vjag game.j64 [--fullscreen]
[--bios real] [--netlink client:HOST]`, SDL window, audio, gamepad) is mostly
wiring that harness to SDL rather than new emulator work. Evaluate SDL2 vs SDL3
at implementation time.
---
## 8. #236 — Nightly builds
Informational, auto-updated by CI on every nightly. **No work.** Edits to the
body are overwritten. Do not open tickets against it.
---
## 9. Dependabot bumps — merged (history)
PRs #278, #279, #280, and #281 all merged to `develop` (#278 as `6249339`;
#279–#281 as `c4edff9` / `791d292` / `f1e84d8`). There is no open dependabot
work left. The untested major bump of `ad-m/github-push-action` is now a risk
note under §6 (#252), not a ticket.
---
## Suggested ticket breakdown
Ordered by value-per-effort, not by issue number. Cheap-model task files for
items 1–4 and 6 live under `docs/tasks/`.
| # | Ticket | Depends on | Size |
|---|---|---|---|
| 1 | Savestate header inspector CLI (#268) | — | XS |
| 2 | #268 maintainer decision + document | 1 | XS |
| 3 | #269 warn-and-refuse + known-bad-dump table + corpus message assertion | — | S |
| 4 | Scripted-input fixture reaching AvP in-game (#267 unlock) | — | S–M |
| 5 | #267 re-confirm on nightly; isolate B_CMD if it reproduces | 4 | M |
| 6 | Overscan column-band digest in A/B sweep tooling (#266) | — | S |
| 7 | #266: request nightly capture; write-side instrumentation if confirmed | 6 | M |
| 8 | VLM: trace the post-`$150A` stall (investigation only, no code) | — | M |
| 9 | VLM: single-session audio-disc support, gated | 8 | L |
| 10 | `supports_no_game` + CD BIOS standalone boot | 9 | M |
| 11 | Disk control (audio-CD swap in VLM only) | 10 | M |
| 12 | #254 SDL frontend phase 1 | — | L |
Items 1–3 are closeable quickly and clear the tracker. Items 4 and 6 are
infrastructure that make two stuck visual bugs tractable and stay useful
afterwards — do them before the bugs they unlock. Items 8–11 are the VLM chain
and are strictly sequential. Item 12 is independent of everything else.
### What NOT to do
- Do not close #266 or #267 on headless evidence alone. Both are visual bugs in
a code path where the harness is known not to see what RetroArch shows.
- Do not implement disk control before the VLM chain — there is nothing for it
to control, and it would be actively harmful on game discs.
- Do not touch `cdintf.c` session logic without running the 22-row boot matrix.
- Do not edit vendored `intl/` scripts for style; they are a clean upstream copy
and the two existing divergences are documented deliberately.
+3
View File
@@ -81,6 +81,9 @@ publishing step is the `nightly` job instead of `release`:
2. Updates the single rolling `nightly` prerelease **in place**
(`gh release edit` + `gh release upload --clobber`, not delete-and-recreate,
so `/releases/tag/nightly` never 404s mid-run and the release keeps its id).
After the upload it briefly toggles draft→published so GitHub rewrites
`published_at` — plain `edit` leaves that stamp on the original create
date and the Releases page looks abandoned under newer stable tags.
3. Rewrites `display_version` in the bundled `.info` to
`v<X.Y.Z>-nightly-<sha>`, so a nightly `.info` can never be mistaken for the
file that gets PR'd to libretro-super.
+136
View File
@@ -0,0 +1,136 @@
# Savestate compatibility
Status as of 2026-08-04. Tracker: issue [#268](https://github.com/libretro/virtualjaguar-libretro/issues/268).
## Version window
Defined in `src/core/state.h`:
| Constant | Value | Meaning |
|---|---|---|
| `STATE_MAGIC` | `0x564A5353` (`"VJSS"`) | Header magic |
| `STATE_VERSION` | `8` | Version this build **writes** |
| `STATE_MIN_VERSION` | `1` | Oldest version this build will **load** |
`retro_unserialize()` refuses anything outside `STATE_MIN_VERSION … STATE_VERSION`
outright, returning false without touching emulator state. Inside the window it
always loads in full; chunks whose fields an older layout did not carry are
reconstructed rather than read (see below).
Header fields are stored with host-endian `memcpy` (`STATE_SAVE_VAR`). On a
little-endian host the on-disk magic bytes are `53 53 4A 56`, not the ASCII
string `"VJSS"`.
## What released cores wrote
Only four format versions have ever left a release tag:
| Version | Written by |
|---|---|
| 1 | v2.2.0 (savestate support first shipped here) |
| 2 | v2.3.0, v2.3.1 |
| 3 | v2.3.2 |
| 7 | v3.0.0, v3.1.0 |
| 8 | develop (unreleased): trailing Jaguar GameDrive chunk, `STATE_VERSION_JAGGD` |
Versions 4, 5 and 6 existed only on `develop` / nightlies. All four released
layouts load on the current core.
## v8: Jaguar GameDrive chunk
v8 (one shared bump per release policy — all in-flight changes since v3.1.0
use it) appends a fixed-size 540-byte GameDrive chunk (`JGDStateSave` in
`src/core/jaggd.c`) after the bus-arbiter accumulators: active/write-enable
flags, the six 1 MB bank page registers, and the SPI mailbox engine
(state machine + response FIFO). The chunk is written all-zero for non-GD
content, which keeps the zero-tail property `test_state_compat`'s
`dac_block_is_last` structural check relies on. Loading a pre-v8 state
resets the GameDrive to its power-on mapping (identity pages, write
protect, idle SPI) — the game re-runs `GD_Install` after a console reset
anyway, so nothing is lost. The 16 MB SDRAM image itself is NOT serialized
(same policy as cart ROM); pages modified via `GD_ROMWriteEnable` do not
survive a load — a deliberate v1 simplification, called out in
`docs/jgd-interface-notes.md` §9.
## Two bugs, both fixed (issue #268)
### 1. v1 was gated out
`STATE_MIN_VERSION` was `2`, so v2.2.0's states were refused outright. The
complete layout difference between v1 and v2 is 24 bytes — the four DAC I2S
resampler fields (`i2sWritePos`, `i2sWriteCount`, `i2sPhase`, `i2sRateRatio`)
— verified by extracting and diffing every `*StateSave` / `*StateLoad` body
between the `v2.2.0` and `v2.3.0` tags.
`DACStateLoad` now skips those four fields for a v1 header and leaves them at
their `DACInit()` defaults. That is exact, not best-effort: `DACPrepareFrame`
(libretro.c, top of `retro_run`, before `JaguarExecuteNew`) re-seeds
writePos/writeCount, truncates the phase and re-derives the rate ratio from
the restored SMODE/SCLK registers, so the defaults never reach the audio
output.
### 2. v1, v2 AND v3 mis-parsed from the CDROM chunk onward
This one was live on `develop` for v2 and v3 — versions already inside the
accepted window. `retro_unserialize()` returned `true` and the game kept
running, so nothing looked wrong, but every chunk from the CD block onward was
read at the wrong offset.
The CD-support work restructured `CDROMStateSave`/`Load`: it dropped the two
`cdBuf2` / `cdBuf3` staging buffers (`uint8_t [2532 + 96]` each, 5256 bytes
together) and put the BUTCH/FIFO/DSA/SSI working set there instead. Only the
last 28 bytes of that change were version-gated
(`STATE_VERSION_CDROM_DSA_QUEUE`, `..._DRIVE_SPEED`). Net effect on an old
blob: the loader read 5256 bytes of stale sector data as flags and finished
2627 bytes short, desyncing the Joystick, Memory Track and DAC chunks behind
it.
`CDROMStateLoad` now forks on `STATE_VERSION_CDROM_RESTRUCTURE` (4). Below it
the loader consumes the legacy 8004-byte block — 2748 bytes of prefix
(`cdRam` … `firstTime`, byte-identical in every version) plus the 5256 dead
staging bytes — and starts the drive from the idle state `CDROMReset()`
establishes. `cdrom_eeprom_ram` is deliberately left alone: it is
file-backed NVM, and an old blob has nothing to say about it.
Zero-defaulting the CD fields is exact for these states in practice — no core
that wrote v1/v2/v3 had a working CD path (`cdBuf2`/`cdBuf3` belonged to the
unfinished BUTCH stub), so there is no CD session to lose.
### Verification
A genuine state was written by each released core built from its own tag
(v2.2.0, v2.3.0, v2.3.2) against the real Alien vs Predator ROM, then loaded
in the current core. In all four cases (plus a current-version control) the
DAC chunk — the last module, so the accumulated victim of any upstream
mis-size — lands at its correct offset with a unique match, and main RAM, TOM
and JERRY register space come back byte-identical.
`test/test_state_compat` builds v1/v2/v3 fixtures at runtime (see
`synth_legacy_state`) and asserts the same thing in CI. Disabling the legacy
CDROM branch turns three of its assertions red.
### Still refused
Anything below `STATE_MIN_VERSION` (now only the never-released version 0) and
anything above `STATE_VERSION`. Nightly-only versions 4-6 load, but were never
covered by the release-compat policy.
## Inspecting a file
Build and run the header inspector (`test/tools/vjss_info.c`, added in PR #284). If the file is not present in your checkout, update to a revision that includes PR #284 first:
```bash
cc -O2 -Wall -std=c89 -o test/tools/vjss_info test/tools/vjss_info.c
./test/tools/vjss_info path/to/file.state
```
Example output from a freshly serialized current-core state:
```
magic=0x564A5353 endian=le version=8 flags=0x00000000 reserved=0x00000000 verdict=loadable
```
Verdicts: `loadable`, `too_old`, `too_new`, `bad_magic`.
RetroArch `.state` files for this core are raw `retro_serialize()` payloads
(no extra wrapper), so `vjss_info` can read the first 16 bytes directly.
+135
View File
@@ -0,0 +1,135 @@
# T0 — Correct handoff §9 (dependabot bumps are merged)
## Environment (do this first, exactly)
```bash
cd /Users/jmattiello/Workspace/Provenance/virtualjaguar-libretro
export DEVELOPER_DIR=/Library/Developer/CommandLineTools
git fetch libretro
# PR #282 is already open on this branch — do NOT create a new branch.
git checkout docs/open-issue-handoff
git merge --ff-only libretro/develop || true
[ -e test/roms/private ] || ln -sfn "${JAGUAR_ROMS_PRIVATE:?set me}" test/roms/private
```
## Hard rules (violating any of these fails the task)
- NEVER run `git clean -xfd` or any recursive delete at repo root.
- C89 only: all declarations at top of block, no `for (int i…)`, no
designated initializers. Run `bash scripts/c89-lint.sh <file>` before commit.
- Branch from `libretro/develop`. Never `master`. Never stack on another task.
- Never relax a test threshold to make something pass.
- Touch ONLY the files in "Files you may touch". If the fix seems to need
another file, STOP and report.
## Goal (one sentence)
Update `docs/open-issue-handoff.md` so §9 records that dependabot PRs
#278–#281 already merged, move the untested `ad-m/github-push-action`
major-bump risk into §6 (#252), and drop table row 12 ("Review dependabot
#278").
## Files you may touch (explicit allowlist)
- `docs/open-issue-handoff.md`
## Steps (numbered, copy-pasteable commands)
1. Confirm you are on the PR #282 branch:
```bash
git branch --show-current # must print: docs/open-issue-handoff
gh pr view 282 --json number,headRefName,state
```
2. Confirm the bumps are merged on `libretro/develop` (do not re-merge them):
```bash
git log --oneline libretro/develop | head -20
# Expect commits mentioning ad-m/github-push-action, setup-python,
# checkout, setup-java (hashes around 6249339 / c4edff9 / 791d292 / f1e84d8).
```
3. Edit `docs/open-issue-handoff.md`:
**§6 (#252)** — after the per-language enablement paragraph, add a
**Risk note** that:
- states PRs #278–#281 all merged before any live Crowdin run;
- names #278 as the non-routine bump (`ad-m/github-push-action` 0.8.0 → 1.3.0);
- notes the other three (`setup-python` 4→7, `checkout` 4→7, `setup-java` 4→5);
- says a sync failure is ambiguous until §6 step 4 has one green run.
**§9** — replace the entire "Open dependabot PRs — #278, #279, #280, #281"
section with a short **merged (history)** section that records the four
merges and points the risk note at §6. No open-work language.
**Suggested ticket breakdown table** — delete the row
`| 12 | Review dependabot #278 major bump | Crowdin green run | XS |`
and renumber `#254 SDL frontend` to row 12. Update the prose under the
table ("Item 13" → "Item 12"). Optionally note that cheap-model task
files for items 1–4 and 6 live under `docs/tasks/`.
4. Do not invent new tickets. Do not close any GitHub issues.
## Acceptance gate (literal command + expected exit code / expected output)
```bash
# Gate A: #279/#280/#281 appear only in the merged-history / risk-note lines,
# never as "open dependabot PRs" work.
rg -n '#279|#280|#281' docs/open-issue-handoff.md
```
Expected: matches only inside the §6 risk note and/or the §9 merged-history
paragraph. Zero matches that say the PRs are still open.
```bash
# Gate B: table row 12 must NOT be the dependabot review ticket.
rg -n 'Review dependabot #278' docs/open-issue-handoff.md; echo exit:$?
```
Expected: no matches, exit 1 from `rg` (not found).
```bash
# Gate C: section 9 title reflects merged state.
rg -n '^## 9\.' docs/open-issue-handoff.md
```
Expected: a title containing `merged` or `history`, not `Open dependabot PRs`.
## STOP conditions (abort triggers — report, do not improvise)
- You are not on `docs/open-issue-handoff` / PR #282 cannot be found.
- `docs/open-issue-handoff.md` already has the §9 merged-history rewrite and
Gates A–C pass — then make no further edits; report "already done".
- Any change would require editing a file outside the allowlist.
## Deliverable (exact commit message, PR title, PR body, issue comment text)
**Commit message:**
```
docs: mark dependabot #278-#281 merged; move push-action risk to #252
```
**PR:** this lands on existing PR #282 (`docs: verification and ticket plan
for every open issue`). Push to the same branch:
```bash
git add docs/open-issue-handoff.md
git commit -m "$(cat <<'EOF'
docs: mark dependabot #278-#281 merged; move push-action risk to #252
EOF
)"
git push libretro HEAD:docs/open-issue-handoff
```
**PR comment** (post on #282):
```
Updated §9: #278–#281 are merged history, not open work. Untested
ad-m/github-push-action major bump is now a risk note under §6 (#252).
Dropped table row "Review dependabot #278".
```
**Do not close any issue.**
+268
View File
@@ -0,0 +1,268 @@
# T1 — Savestate header inspector (`vjss_info`)
## Environment (do this first, exactly)
```bash
cd /Users/jmattiello/Workspace/Provenance/virtualjaguar-libretro
export DEVELOPER_DIR=/Library/Developer/CommandLineTools
git fetch libretro
git checkout -b tools/vjss-info libretro/develop
[ -e test/roms/private ] || ln -sfn "${JAGUAR_ROMS_PRIVATE:?set me}" test/roms/private
```
## Hard rules (violating any of these fails the task)
- NEVER run `git clean -xfd` or any recursive delete at repo root.
- C89 only: all declarations at top of block, no `for (int i…)`, no
designated initializers. Run `bash scripts/c89-lint.sh <file>` before commit.
- Branch from `libretro/develop`. Never `master`. Never stack on another task.
- Never relax a test threshold to make something pass.
- Touch ONLY the files in "Files you may touch". If the fix seems to need
another file, STOP and report.
## Goal (one sentence)
Add `test/tools/vjss_info.c` that reads the first 16 bytes of a Virtual Jaguar
savestate, prints magic/version/flags/reserved, detects host byte order, and
prints a loadability verdict against `STATE_VERSION` / `STATE_MIN_VERSION`.
## Files you may touch (explicit allowlist)
- `test/tools/vjss_info.c` (**create**)
- `test/tools/README-vjss-info.md` (**create**, optional one-screen usage note)
Do **not** edit `src/core/state.h`, `libretro.c`, or any shipped core code.
## Critical fact (read before writing a single line)
`STATE_SAVE_VAR` in `src/core/state.h` is a plain `memcpy` of a host
`uint32_t`. Headers are **host-endian**, not big-endian and not a literal
ASCII `"VJSS"` string on disk.
On a little-endian host the on-disk magic `0x564A5353` ("VJSS" as a
big-endian fourcc value stored as a native `uint32_t`) appears as bytes:
```
53 53 4A 56
```
A naive `memcmp(buf, "VJSS", 4)` reports every valid little-endian state as
corrupt. Your tool MUST:
1. Read 16 bytes (magic, version, flags, reserved — four `uint32_t`s).
2. Interpret them as little-endian AND as big-endian.
3. Report which interpretation yields `magic == 0x564A5353`.
4. Use that endianness for version/flags/reserved.
5. Print a verdict:
- `loadable` if `STATE_MIN_VERSION (2) <= version <= STATE_VERSION (7)`
- `too_old` if `version < 2`
- `too_new` if `version > 7`
- `bad_magic` if neither endianness matches
Constants (do not hardcode differently):
```c
/* from src/core/state.h — copy the numeric values, do not #include state.h
* into a standalone tool unless you also pull its dependencies cleanly.
* Prefer duplicating the three numbers with a comment citing state.h. */
#define VJSS_MAGIC 0x564A5353u /* "VJSS" */
#define VJSS_VERSION 7u
#define VJSS_MIN_VERSION 2u
```
## Steps (numbered, copy-pasteable commands)
1. Create `test/tools/vjss_info.c` as a standalone C89 program (no libretro
link required). CLI:
```
vjss_info <statefile>
```
Required stdout (one line or a short block — keep it machine-grepable):
```
magic=0x564A5353 endian=le version=7 flags=0x00000000 reserved=0x00000000 verdict=loadable
```
Use `endian=le` or `endian=be`. Exit codes:
- `0` — file opened and magic matched (any verdict including too_old/too_new)
- `1` — I/O error or bad_magic
- `2` — usage error
2. Build the tool:
```bash
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
cc -O2 -Wall -std=c89 -o test/tools/vjss_info test/tools/vjss_info.c
```
3. Build a known-good current-version state for the gate. Prefer a tiny
throwaway dump (do **not** commit the dump helper or the `.state` file):
```bash
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
make -j"$(getconf _NPROCESSORS_ONLN)" TEST_EXPORTS=1
# Dump first 16+ bytes via a one-shot Python helper (not committed):
python3 <<'PY'
import ctypes, sys
core = ctypes.CDLL("./virtualjaguar_libretro.dylib")
# Minimal libretro dance is painful in Python; instead use test_state_compat
# which already serializes — OR write /tmp/vjss_dump.c as below.
print("use /tmp/vjss_dump.c recipe in step 3b", file=sys.stderr)
sys.exit(0)
PY
```
**Step 3b (recommended dump recipe)** — write `/tmp/vjss_dump.c` (NOT in
the repo), build it against the harness, dump a state from `test/roms/yarc.j64`:
```bash
cat > /tmp/vjss_dump.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include "test/harness/harness.h"
int main(int argc, char **argv) {
harness_config cfg = HARNESS_CONFIG_DEFAULT;
size_t (*ser_size)(void);
bool (*ser)(void *, size_t);
void *buf;
size_t n;
FILE *f;
cfg.frames = 30;
if (!harness_init_from_args(&cfg, argc, argv)) return 1;
if (!harness_load_rom(&cfg)) return 1;
harness_run(&cfg);
ser_size = (size_t (*)(void))harness_dlsym(&cfg, "retro_serialize_size");
ser = (bool (*)(void *, size_t))harness_dlsym(&cfg, "retro_serialize");
if (!ser_size || !ser) { fprintf(stderr, "missing serialize\n"); return 1; }
n = ser_size();
buf = malloc(n);
if (!buf || !ser(buf, n)) return 1;
f = fopen("/tmp/vjss_yarc.state", "wb");
if (!f) return 1;
fwrite(buf, 1, n, f);
fclose(f);
printf("wrote /tmp/vjss_yarc.state bytes=%zu\n", n);
harness_shutdown(&cfg);
free(buf);
return 0;
}
EOF
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
cc -O2 -Wall -std=c99 \
-I. -I./libretro-common/include \
-o /tmp/vjss_dump /tmp/vjss_dump.c test/harness/harness.c -ldl -lm
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
/tmp/vjss_dump ./virtualjaguar_libretro.dylib test/roms/yarc.j64 --frames 30 --quiet
```
4. Run the inspector on that state:
```bash
./test/tools/vjss_info /tmp/vjss_yarc.state
```
5. Lint:
```bash
bash scripts/c89-lint.sh test/tools/vjss_info.c
```
## Acceptance gate (literal command + expected exit code / expected output)
```bash
bash scripts/c89-lint.sh test/tools/vjss_info.c
# exit 0
./test/tools/vjss_info /tmp/vjss_yarc.state
# stdout MUST contain: version=7
# stdout MUST contain: verdict=loadable
# stdout MUST contain: endian=le (on Apple Silicon / Intel Mac)
# exit 0
# Negative check: truncating magic must fail
dd if=/tmp/vjss_yarc.state of=/tmp/vjss_bad.state bs=16 count=1 2>/dev/null
printf '\x00\x00\x00\x00' | dd of=/tmp/vjss_bad.state bs=1 seek=0 conv=notrunc 2>/dev/null
./test/tools/vjss_info /tmp/vjss_bad.state; echo exit:$?
# stdout MUST contain: verdict=bad_magic
# exit MUST be 1
```
## STOP conditions (abort triggers — report, do not improvise)
- You believe the header is big-endian on disk "because Jaguar is big-endian"
— wrong; STOP and re-read the Critical fact above.
- Building `/tmp/vjss_dump` fails because of missing symbols — rebuild with
`make TEST_EXPORTS=1`. If still failing after `make clean && make TEST_EXPORTS=1`,
STOP and report.
- You are tempted to change `STATE_MIN_VERSION` or add a legacy loader — that
is T2 / a maintainer decision, not this task.
- `test/roms/yarc.j64` is missing — STOP and report; do not download ROMs
from the internet into the tree.
## Deliverable (exact commit message, PR title, PR body, issue comment text)
**Commit message:**
```
test(tools): add vjss_info savestate header inspector
```
**PR title:**
```
test(tools): add vjss_info savestate header inspector
```
**PR body template:**
```markdown
## Summary
- Standalone CLI that prints VJSS magic/version/flags and a loadability verdict.
- Handles host-endian `STATE_SAVE_VAR` layout (tries LE and BE).
Closes nothing. Supports triage for #268.
## Acceptance gate transcript
$ bash scripts/c89-lint.sh test/tools/vjss_info.c
<paste>
$ ./test/tools/vjss_info /tmp/vjss_yarc.state
<paste>
```
**Push + PR:**
```bash
git add test/tools/vjss_info.c
git status # only allowlisted files
git commit -m "$(cat <<'EOF'
test(tools): add vjss_info savestate header inspector
EOF
)"
git push -u libretro HEAD
gh pr create --base develop --title "test(tools): add vjss_info savestate header inspector" --body-file - <<'EOF'
## Summary
- Standalone CLI that prints VJSS magic/version/flags and a loadability verdict.
- Handles host-endian `STATE_SAVE_VAR` layout (tries LE and BE).
Supports triage for #268.
## Acceptance gate transcript
(paste here)
EOF
```
**Issue comment on #268** (after PR exists):
```
Added a header inspector in PR <N>: `./test/tools/vjss_info your.state`
Please paste its one-line output for the two rejected files so we can see
the on-disk version field. Do not close this issue from that PR.
```
+187
View File
@@ -0,0 +1,187 @@
# T2 — #268 maintainer decision + docs (does NOT close the issue)
## Environment (do this first, exactly)
```bash
cd /Users/jmattiello/Workspace/Provenance/virtualjaguar-libretro
export DEVELOPER_DIR=/Library/Developer/CommandLineTools
git fetch libretro
git checkout -b docs/savestate-min-version libretro/develop
[ -e test/roms/private ] || ln -sfn "${JAGUAR_ROMS_PRIVATE:?set me}" test/roms/private
```
## Hard rules (violating any of these fails the task)
- NEVER run `git clean -xfd` or any recursive delete at repo root.
- C89 only: all declarations at top of block, no `for (int i…)`, no
designated initializers. Run `bash scripts/c89-lint.sh <file>` before commit.
- Branch from `libretro/develop`. Never `master`. Never stack on another task.
- Never relax a test threshold to make something pass.
- Touch ONLY the files in "Files you may touch". If the fix seems to need
another file, STOP and report.
## Goal (one sentence)
Record a deliberate decision that states below `STATE_MIN_VERSION` (2) are
rejected by design, document how to inspect a file with `vjss_info`, and post
that decision on GitHub issue #268 — **without closing the issue**.
## Depends on
T1 (`vjss_info`) must already be merged to `libretro/develop`, OR your branch
must contain `test/tools/vjss_info.c` (cherry-pick / merge T1 first).
```bash
test -f test/tools/vjss_info.c || {
echo "STOP: T1 not present — merge/cherry-pick tools/vjss-info first"
exit 1
}
```
## Files you may touch (explicit allowlist)
- `docs/savestate-compat.md` (**create**)
- Optionally one cross-link line in `docs/open-issue-handoff.md` §3 pointing
at `docs/savestate-compat.md` (only if that file exists on your branch)
Do **not** change `STATE_MIN_VERSION`, `STATE_VERSION`, or any loader code.
## Decision to document (do not invent a different policy)
**Recommended / required for this task:** leave gating as-is.
- Current core writes `STATE_VERSION = 7`.
- Current core loads only `2 <= version <= 7`.
- Rejection of older states is **expected behaviour**, not an AvP-specific bug.
- A Battle Sphere Gold state loading on the same build already proved the
loader works; the reporter's files were never measured (do not claim they
are "v1").
- A best-effort legacy loader below `STATE_MIN_VERSION` is explicitly **out of
scope** for this task.
## Steps (numbered, copy-pasteable commands)
1. Create `docs/savestate-compat.md` with at least:
- Title + date
- Table of `STATE_VERSION` / `STATE_MIN_VERSION` values (cite
`src/core/state.h`)
- The deliberate decision paragraph above
- How to inspect a file:
```bash
./test/tools/vjss_info path/to/file.state
```
- Note that RetroArch `.state` files are raw `retro_serialize()` payloads
for this core (no extra wrapper), so `vjss_info` can read them directly
on the first 16 bytes
- Link to issue #268 as the tracker for user-facing reports
2. Build `vjss_info` if the binary is missing:
```bash
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
cc -O2 -Wall -std=c89 -o test/tools/vjss_info test/tools/vjss_info.c
```
3. Post the issue comment (see Deliverable). **Do not** run
`gh issue close 268`.
## Acceptance gate (literal command + expected exit code / expected output)
```bash
test -f docs/savestate-compat.md && echo OK_FILE
rg -n 'STATE_MIN_VERSION|vjss_info|deliberate|expected behaviour|expected behavior' docs/savestate-compat.md
# Must hit deliberate/expected + vjss_info + STATE_MIN_VERSION
# Must NOT have closed the issue
gh issue view 268 --json state -q .state
# Expected: OPEN
```
## STOP conditions (abort triggers — report, do not improvise)
- T1 / `test/tools/vjss_info.c` is absent — STOP.
- You are about to lower `STATE_MIN_VERSION` or write a legacy loader — STOP.
- You are about to close #268 — STOP. Maintainer closes after reading the
comment and any reporter follow-up.
- Reporter's two files appear in the tree — do not commit them; run
`vjss_info` locally and paste output into the issue comment only.
## Deliverable (exact commit message, PR title, PR body, issue comment text)
**Commit message:**
```
docs: record savestate STATE_MIN_VERSION policy for #268
```
**PR title:**
```
docs: record savestate STATE_MIN_VERSION policy for #268
```
**PR body template:**
```markdown
## Summary
- Documents that rejecting states below STATE_MIN_VERSION (2) is deliberate.
- Points reporters at `vjss_info` for header triage.
- Does **not** close #268.
## Acceptance gate transcript
$ gh issue view 268 --json state -q .state
OPEN
$ rg -n 'STATE_MIN_VERSION|vjss_info' docs/savestate-compat.md
<paste>
```
**Push + PR:**
```bash
git add docs/savestate-compat.md
git commit -m "$(cat <<'EOF'
docs: record savestate STATE_MIN_VERSION policy for #268
EOF
)"
git push -u libretro HEAD
gh pr create --base develop --title "docs: record savestate STATE_MIN_VERSION policy for #268" --body-file - <<'EOF'
## Summary
- Documents that rejecting states below STATE_MIN_VERSION (2) is deliberate.
- Points reporters at `vjss_info` for header triage.
- Does **not** close #268.
## Acceptance gate transcript
(paste here)
EOF
```
**Issue comment on #268** (required; copy-paste):
```markdown
## Decision (documented in docs/savestate-compat.md, PR <N>)
This is savestate **version gating working as designed**, not an AvP-specific
loader bug.
- Current cores write `STATE_VERSION = 7` and load only
`STATE_MIN_VERSION (2) … STATE_VERSION (7)`.
- Older Virtual Jaguar states below that window are refused by
`retro_unserialize()` (returns false).
- We are **not** adding a best-effort legacy loader in this pass.
### Please run this on the two rejected files
```bash
./test/tools/vjss_info /path/to/file.state
```
Paste the one-line output (magic/version/verdict) for each file. That turns
"what version is this" into a measurable fact.
Leaving the issue **open** until that output lands (or a maintainer
explicitly closes as expected behaviour).
```
+216
View File
@@ -0,0 +1,216 @@
# T3 — #269 CDI bad-dump diagnostics (warn-and-refuse, no behaviour change)
## Environment (do this first, exactly)
```bash
cd /Users/jmattiello/Workspace/Provenance/virtualjaguar-libretro
export DEVELOPER_DIR=/Library/Developer/CommandLineTools
git fetch libretro
git checkout -b fix/cdi-bad-dump-diag libretro/develop
[ -e test/roms/private ] || ln -sfn "${JAGUAR_ROMS_PRIVATE:?set me}" test/roms/private
```
## Hard rules (violating any of these fails the task)
- NEVER run `git clean -xfd` or any recursive delete at repo root.
- C89 only: all declarations at top of block, no `for (int i…)`, no
designated initializers. Run `bash scripts/c89-lint.sh <file>` before commit.
- Branch from `libretro/develop`. Never `master`. Never stack on another task.
- Never relax a test threshold to make something pass.
- Touch ONLY the files in "Files you may touch". If the fix seems to need
another file, STOP and report.
## Goal (one sentence)
When `CDIntfExtractBootStub` rejects a disc for magic mismatch, log **why**
(zero-filled header vs partial N/32 match vs other), document the four known
bad CDI V2 dumps, and keep refuse-as-refuse — **do not** tolerate truncated
magic to force a boot.
## Files you may touch (explicit allowlist)
- `src/cd/cdintf.c` (only the magic-mismatch diagnostics path around the
`memcmp(swapped + 0x42, MAGIC, sizeof(MAGIC))` check — today ~line 1441)
- `docs/cd-known-issues.md` (add a "Known bad CDI dumps" section)
Do **not** edit `src/cd/cdrom.c`, HLE boot stub injection in `jaguar.c`,
session-count gates, or `intl/`.
## What is established (do not re-investigate)
Of 14 local CDI images, 4 fail load — all CDI V2:
| Image (local names) | Defect |
|---|---|
| ironsoldier2, mystdemo, vidgrid | boot-header region **zero-filled in the file** |
| worldtourracing | **partial 22/32** boot magic |
The CDI walk/offset math is correct. Missing data cannot be invented.
**Product decision for this task (already chosen):** warn and refuse, with an
actionable message. Do **not** implement truncated-magic acceptance.
## Steps (numbered, copy-pasteable commands)
1. Locate the magic check:
```bash
rg -n 'Magic mismatch at \+0x42' src/cd/cdintf.c
```
2. Replace the single generic `LOG_ERR` on mismatch with C89-legal code that:
a. Counts how many of the 32 magic bytes at `swapped + 0x42` match
`MAGIC[i]`.
b. Detects all-zero: every byte in that 32-byte window is `0x00`.
c. Logs exactly one of:
- **All zero:**
`[CD-BOOTSTUB] Boot header region is zero-filled at +0x42 — this image is an incomplete / bad rip, not an unsupported format`
- **Partial (1..31 matches):**
`[CD-BOOTSTUB] Magic mismatch at +0x42 of session-2 track BIN (matched N/32 bytes)`
where `N` is the match count (worldtourracing should show `22/32`).
- **Zero matches but not all-zero:**
keep a clear generic mismatch line (may reuse the old text plus
`(matched 0/32 bytes)`).
d. Still `return false;` in every branch. **No boot on mismatch.**
Declarations stay at the top of the enclosing block (C89).
3. Add a section to `docs/cd-known-issues.md` titled **Known bad CDI dumps**
listing the four titles above, stating warn-and-refuse is intentional, and
pointing at the new log lines. Do not claim a core "fix" for missing data.
4. Lint + build:
```bash
bash scripts/c89-lint.sh src/cd/cdintf.c
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
make -j"$(getconf _NPROCESSORS_ONLN)" TEST_EXPORTS=1
```
5. Run the CDI corpus sweep (private ROM tree required):
```bash
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
make test/test_cd_hle_boot
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
VJ_TEST_CD_EXTS=cdi ./test/test_cd_hle_boot 2>&1 | tee /tmp/cdi_hle_boot.log
```
6. Confirm messages (adjust filenames if your corpus uses different basename
spellings — match on the log text, not a hardcoded path):
```bash
rg -n 'zero-filled|matched 22/32|incomplete / bad rip' /tmp/cdi_hle_boot.log
```
7. Regression gate — boot matrix must stay all `GAME_CODE` (do not skip):
```bash
# Use the project's usual invocation; keep the run bounded if the script
# supports CD_MATRIX_* knobs. Resume is OK if OUT already has same-build rows.
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
bash test/tools/cd_boot_matrix.sh
```
If a full matrix is too long for the session, run at least one known-good
CUE title through `test/test_cd_hle_boot` without `VJ_TEST_CD_EXTS=cdi` and
state clearly in the PR that the matrix must be finished before merge —
but prefer finishing the matrix.
## Acceptance gate (literal command + expected exit code / expected output)
```bash
bash scripts/c89-lint.sh src/cd/cdintf.c
# exit 0
# After VJ_TEST_CD_EXTS=cdi ./test/test_cd_hle_boot:
rg -c 'zero-filled' /tmp/cdi_hle_boot.log
# Expected: >= 3 (ironsoldier2, mystdemo, vidgrid)
rg -n 'matched 22/32' /tmp/cdi_hle_boot.log
# Expected: at least one hit (worldtourracing)
# Behaviour unchanged: those four still fail to become GAME_CODE / still
# fail load. Count successful CDI loads must remain 10 of 14 (or whatever
# the pre-change baseline was — do not increase by "fixing" dumps).
```
## STOP conditions (abort triggers — report, do not improvise)
- You are about to accept a 22/32 magic match and boot worldtourracing — STOP.
That is the rejected alternative in the handoff doc (false-positive risk).
- `test/roms/private` is missing or has fewer than 14 `.cdi` files — STOP and
report; do not download dumps.
- Magic check site has moved and you cannot find `memcmp(swapped + 0x42` —
STOP and report the new locus; do not rewrite the whole extractor.
- Boot matrix loses any `GAME_CODE` row — revert and STOP.
- Stale iOS objects break the macOS link — `make clean` once, then rebuild.
If still broken, STOP.
## Deliverable (exact commit message, PR title, PR body, issue comment text)
**Commit message:**
```
fix(cd): diagnose zero-filled / partial CDI boot headers (#269)
```
**PR title:**
```
fix(cd): diagnose zero-filled / partial CDI boot headers (#269)
```
**PR body template:**
```markdown
## Summary
- Distinguish zero-filled vs partial ATARI magic mismatches in CDIntfExtractBootStub.
- Document known-bad CDI V2 dumps.
- Refuse stays refuse — no truncated-magic boot path.
Addresses #269 (does not claim the bad dumps become playable).
## Acceptance gate transcript
$ bash scripts/c89-lint.sh src/cd/cdintf.c
<paste>
$ rg -n 'zero-filled|matched 22/32' /tmp/cdi_hle_boot.log
<paste>
```
**Push + PR:**
```bash
git add src/cd/cdintf.c docs/cd-known-issues.md
git commit -m "$(cat <<'EOF'
fix(cd): diagnose zero-filled / partial CDI boot headers (#269)
EOF
)"
git push -u libretro HEAD
gh pr create --base develop --title "fix(cd): diagnose zero-filled / partial CDI boot headers (#269)" --body-file - <<'EOF'
## Summary
- Distinguish zero-filled vs partial ATARI magic mismatches in CDIntfExtractBootStub.
- Document known-bad CDI V2 dumps.
- Refuse stays refuse.
Addresses #269.
## Acceptance gate transcript
(paste here)
EOF
```
**Issue comment on #269:**
```markdown
PR <N> implements warn-and-refuse diagnostics:
- zero-filled +0x42 → explicit bad-rip message
- partial match → `matched N/32 bytes` (worldtourracing = 22/32)
- known-bad table in docs/cd-known-issues.md
No truncated-magic acceptance (false-positive risk). Bad dumps remain unloadable.
```
+253
View File
@@ -0,0 +1,253 @@
# T4 — Overscan column-band digest (`VJFBDIG3`) for #266 tooling
## Environment (do this first, exactly)
```bash
cd /Users/jmattiello/Workspace/Provenance/virtualjaguar-libretro
export DEVELOPER_DIR=/Library/Developer/CommandLineTools
git fetch libretro
git checkout -b tools/overscan-band-digest libretro/develop
[ -e test/roms/private ] || ln -sfn "${JAGUAR_ROMS_PRIVATE:?set me}" test/roms/private
```
## Hard rules (violating any of these fails the task)
- NEVER run `git clean -xfd` or any recursive delete at repo root.
- C89 only: all declarations at top of block, no `for (int i…)`, no
designated initializers. Run `bash scripts/c89-lint.sh <file>` before commit.
- Branch from `libretro/develop`. Never `master`. Never stack on another task.
- Never relax a test threshold to make something pass.
- Touch ONLY the files in "Files you may touch". If the fix seems to need
another file, STOP and report.
## Goal (one sentence)
Extend `fb_row_digest` / `fb_row_diff.py` so each frame also records a digest
of columns `320 .. width-1` (the overscan/border strip), bumping the file
magic from `VJFBDIG2` to `VJFBDIG3`.
## Files you may touch (explicit allowlist)
- `test/tools/fb_row_digest.c`
- `test/tools/fb_row_diff.py`
- `test/tools/fb_ab_sweep.sh` (only if needed for a one-line comment or
magic-related flag; prefer not to change behaviour)
Do **not** edit `src/tom/tom.c`, OP, blitter, or blank columns 320–325 in the
core. This task is tooling only.
## Critical facts
1. AvP presents wider than 320. `game_width` comes from
`TOMGetVideoModeWidth()` (up to 652). Columns 320–325 are **inside** the
buffer the harness already hashes; they are just folded into the whole-row
hash today.
2. Active area for the band digest is columns `x >= 320`. If `width <= 320`,
band width is 0 and band fields are zeros / sentinel.
3. `fb_ab_sweep.sh` uses `find "$ROMS_ROOT"` **without** `-L`. Passing the
`test/roms/private` **symlink** finds nothing. Always pass the resolved
path, e.g. `"$(cd test/roms/private && pwd -P)"` or
`$JAGUAR_ROMS_PRIVATE`.
## Format change (implement exactly)
Bump magic: `VJFBDIG2` → `VJFBDIG3`.
Per-frame record today (`VJFBDIG2`):
```
uint32 width, height, frame_hash, written_extent
uint32 row_hash[height]
```
Per-frame record for `VJFBDIG3` (append after `row_hash[height]`):
```
uint32 band_x0 /* always 320, or 0 if width <= 320 */
uint32 band_width /* max(0, width - 320) */
uint32 band_hash /* FNV-1a over all pixels in columns [band_x0, width) for every row; 0 if band_width==0 */
uint32 band_nonblack /* count of pixels in the band with RGB != 0; 0 if band_width==0 */
uint32 band_first_x /* first non-black x in band, or 0xFFFFFFFF if none */
uint32 band_first_y /* first non-black y in band, or 0xFFFFFFFF if none */
```
Use the same little-endian `put_u32` path as existing fields. Reuse the
existing FNV-1a helper.
`fb_row_diff.py` must:
- Accept `VJFBDIG3` (and may reject `VJFBDIG2` with a clear error, **or**
accept both — pick one and document it in a comment at the magic constant).
- Prefer: require matching magic on both inputs; if either is v2, exit 2 with
`need VJFBDIG3`.
- When comparing v3 files, if geometry/audio/row rules pass, also report
`band_changed_frames=N` and fail (exit 1) if the patched band_hash differs
on any frame **unless** you add an explicit `--ignore-band` flag defaulting
to off. For this task, **failing on band_hash mismatch is correct** when
the rest of the A/B rules would have passed — but if that breaks the
existing brown-bar allow rule unexpectedly, STOP and report rather than
inventing a new allow rule.
Minimal acceptable `fb_row_diff.py` behaviour for merge:
- Parse v3.
- Print per-run summary including max `band_nonblack` and whether any frame
had `band_nonblack > 0`.
- Keep the existing brown-bar classification for row hashes.
## Steps (numbered, copy-pasteable commands)
1. Read `test/tools/fb_row_digest.c` and `test/tools/fb_row_diff.py` end to end.
2. Implement the v3 fields in `on_video()` in `fb_row_digest.c`. Keep C89
style consistent with the file (the file already uses C99-ish build flags
in its header comment — match the file's existing declaration style; still
run `c89-lint` and fix what it flags if the script covers this path).
Note: `scripts/c89-lint.sh` may skip or flag this file — if the lint script
skips it, still avoid mid-block declarations in new code you add.
3. Update `fb_row_diff.py` magic + parser.
4. Rebuild tools + core:
```bash
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
make -j"$(getconf _NPROCESSORS_ONLN)" TEST_EXPORTS=1
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
cc -O2 -Wall -std=c99 \
-I. -I./libretro-common/include \
-o test/tools/fb_row_digest test/tools/fb_row_digest.c \
test/harness/harness.c -ldl -lm
```
5. Gate run — AvP (wide) vs yarc (typically 320):
```bash
AVP='test/roms/private/ROMS/Alien vs Predator (1994).jag'
test -f "$AVP" || AVP="$(find -L test/roms/private -iname 'Alien vs Predator (1994).jag' | head -1)"
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
./test/tools/fb_row_digest ./virtualjaguar_libretro.dylib "$AVP" \
--frames 120 --out /tmp/avp_dig3.bin --quiet
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
./test/tools/fb_row_digest ./virtualjaguar_libretro.dylib test/roms/yarc.j64 \
--frames 120 --out /tmp/yarc_dig3.bin --quiet
# Inspect with Python one-liner (not committed):
python3 <<'PY'
import struct, sys
def peek(path):
b = open(path,'rb').read(16)
assert b[:8] == b'VJFBDIG3', b[:8]
nv, na = struct.unpack_from('<2I', b, 8)
# first frame header after 16-byte file hdr
f = open(path,'rb'); f.seek(16)
w,h,fh,ext = struct.unpack('<4I', f.read(16))
f.read(4*h) # row hashes
band = struct.unpack('<6I', f.read(24))
print(path, 'frames', nv, 'w', w, 'band', band)
peek('/tmp/avp_dig3.bin')
peek('/tmp/yarc_dig3.bin')
PY
```
## Acceptance gate (literal command + expected exit code / expected output)
```bash
# Magic
dd if=/tmp/avp_dig3.bin bs=8 count=1 2>/dev/null | od -An -tc
# Must show V J F B D I G 3
python3 <<'PY'
import struct
def band(path):
f=open(path,'rb'); assert f.read(8)==b'VJFBDIG3'
nv,na=struct.unpack('<2I', f.read(8))
w,h,fh,ext=struct.unpack('<4I', f.read(16))
f.read(4*h)
return struct.unpack('<6I', f.read(24)) + (w,)
bx0,bw,bh,bnb,fx,fy,w = band('/tmp/avp_dig3.bin')
assert w > 320, w
assert bx0 == 320 and bw == w-320, (bx0,bw,w)
print('AVP_OK', w, bw)
bx0,bw,bh,bnb,fx,fy,w = band('/tmp/yarc_dig3.bin')
assert bw == 0 or w <= 320, (w,bw)
print('YARC_OK', w, bw)
PY
# exit 0
python3 test/tools/fb_row_diff.py /tmp/yarc_dig3.bin /tmp/yarc_dig3.bin --label yarc
# exit 0 (identical)
```
## STOP conditions (abort triggers — report, do not improvise)
- AvP dump shows `width <= 320` for all frames — the overscan strip is not
reachable via this path on current develop; STOP and report (do not blank
columns in the core to "create" a band).
- You are about to "fix" #266 by clearing columns 320–325 in TOM/OP — STOP.
Tooling only.
- Tempted to keep writing `VJFBDIG2` with a side-car file — STOP; bump magic.
- `fb_row_diff.py` changes would require redesigning the brown-bar allow rule
— implement parse + report first; if classification conflicts, STOP with
notes rather than silently weakening the allow rule.
## Deliverable (exact commit message, PR title, PR body, issue comment text)
**Commit message:**
```
test(tools): add VJFBDIG3 overscan column-band digest (#266)
```
**PR title:**
```
test(tools): add VJFBDIG3 overscan column-band digest (#266)
```
**PR body template:**
```markdown
## Summary
- fb_row_digest emits per-frame digest for columns x>=320 (VJFBDIG3).
- fb_row_diff.py reads the new record.
- Tooling only — does not claim #266 fixed.
## Acceptance gate transcript
$ python3 … (AVP_OK / YARC_OK)
<paste>
```
**Push + PR:**
```bash
git add test/tools/fb_row_digest.c test/tools/fb_row_diff.py
git commit -m "$(cat <<'EOF'
test(tools): add VJFBDIG3 overscan column-band digest (#266)
EOF
)"
git push -u libretro HEAD
gh pr create --base develop --title "test(tools): add VJFBDIG3 overscan column-band digest (#266)" --body-file - <<'EOF'
## Summary
- fb_row_digest emits per-frame digest for columns x>=320 (VJFBDIG3).
- fb_row_diff.py reads the new record.
- Tooling only — does not claim #266 fixed. Do not close #266.
## Acceptance gate transcript
(paste here)
EOF
```
**Issue comment on #266:**
```markdown
PR <N> adds an overscan column-band digest (VJFBDIG3) so columns x≥320 are
visible to A/B sweeps. This does **not** close the bug — headless still is
not RetroArch's compositor (see CLAUDE.md caveat). Next: nightly capture
with overscan crop disabled, then write-side instrumentation if still present.
```
+229
View File
@@ -0,0 +1,229 @@
# T5 — AvP scripted-input fixture (reach sustained in-game motion)
## Environment (do this first, exactly)
```bash
cd /Users/jmattiello/Workspace/Provenance/virtualjaguar-libretro
export DEVELOPER_DIR=/Library/Developer/CommandLineTools
git fetch libretro
git checkout -b test/avp-input-fixture libretro/develop
[ -e test/roms/private ] || ln -sfn "${JAGUAR_ROMS_PRIVATE:?set me}" test/roms/private
```
## Hard rules (violating any of these fails the task)
- NEVER run `git clean -xfd` or any recursive delete at repo root.
- C89 only: all declarations at top of block, no `for (int i…)`, no
designated initializers. Run `bash scripts/c89-lint.sh <file>` before commit.
- Branch from `libretro/develop`. Never `master`. Never stack on another task.
- Never relax a test threshold to make something pass.
- Touch ONLY the files in "Files you may touch". If the fix seems to need
another file, STOP and report.
## Goal (one sentence)
Commit a replayable harness `--press` script that drives Alien vs Predator
from boot into **sustained in-game motion**, banked for reuse by #266 / #267.
## Scope boundary (read twice)
**In scope:** reach a state with ongoing gameplay motion (player can move;
framebuffer keeps changing for ≥300 frames).
**Out of scope for this task:** selecting the shotgun, proving the red
background bug, proving the green overscan bug, closing #266 or #267.
Weapon-select is a follow-on for a strong model after this fixture exists.
## Files you may touch (explicit allowlist)
- `test/fixtures/avp_reach_gameplay.press` (**create** — plain text)
- `test/fixtures/README.md` (**create** or append — how to invoke)
- `test/tools/run_avp_fixture.sh` (**create** — thin wrapper that expands the
press file into `cd_visual_verify` / harness args)
Do **not** edit blitter, TOM, OP, or core options defaults.
## Press file format
One event per line, comments with `#`:
```
# frame:button[:hold]
700:b:8
720:option:6
```
Buttons (from `test/harness/harness.c`):
`up down left right a b c pause option 0 1 2 3 4 5 6`
Jaguar mapping reminder: A/B/C = `a`/`b`/`c`, Pause = `pause`, Option = `option`.
Wrapper must turn the file into repeated `--press` flags.
## ROM path
```bash
AVP='test/roms/private/ROMS/Alien vs Predator (1994).jag'
test -f "$AVP" || AVP="$(find -L test/roms/private -iname 'Alien vs Predator (1994).jag' | head -1)"
echo "AVP=$AVP"
test -f "$AVP" || { echo "STOP: AvP ROM missing"; exit 1; }
```
Use BIOS off unless you discover menus require BIOS on — if you switch, record
it in the fixture README as a required `--option`.
## Steps (numbered, copy-pasteable commands)
1. Build core + `cd_visual_verify`:
```bash
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
make -j"$(getconf _NPROCESSORS_ONLN)" TEST_EXPORTS=1
DEVELOPER_DIR=/Library/Developer/CommandLineTools \
cc -O2 -Wall -std=c99 \
-I. -I./libretro-common/include \
-o test/tools/cd_visual_verify test/tools/cd_visual_verify.c \
test/harness/harness.c -ldl -lm
```
2. Create `test/fixtures/avp_reach_gameplay.press` and
`test/tools/run_avp_fixture.sh`.
Suggested wrapper behaviour:
```bash
# run_avp_fixture.sh <core> [extra harness args...]
# expands fixtures/avp_reach_gameplay.press into --press args
# invokes cd_visual_verify with --frames high enough to cover the script
# writes shots under /tmp/avp_fixture_$$
```
3. Iterate the press script. Strategy that usually works for Jaguar title
menus: wait through boot/attract, press `b` or `a` to start, use
`option` / d-pad as needed, then hold `up`/`right` periodically in-game.
Capture smoke evidence:
```bash
OUT=/tmp/avp_fixture_try1
mkdir -p "$OUT"
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
bash test/tools/run_avp_fixture.sh ./virtualjaguar_libretro.dylib \
--outdir "$OUT" --shot-every 300
# Read the timeline printed by cd_visual_verify.
# Convert a late PPM if useful:
# sips -s format png "$OUT"/frame_*.ppm --out "$OUT"
```
4. You get **three candidate attempts**. After the third failure against the
acceptance gate, STOP (see STOP conditions).
5. When the gate passes twice in a row, commit the fixture + wrapper + README.
## Acceptance gate (literal command + expected exit code / expected output)
Mechanical — not "looks like AvP to a human":
```bash
# Run 1
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
bash test/tools/run_avp_fixture.sh ./virtualjaguar_libretro.dylib \
--outdir /tmp/avp_fix1 --shot-every 0 2>&1 | tee /tmp/avp_fix1.log
# Run 2 (must also pass — reproducibility)
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
bash test/tools/run_avp_fixture.sh ./virtualjaguar_libretro.dylib \
--outdir /tmp/avp_fix2 --shot-every 0 2>&1 | tee /tmp/avp_fix2.log
```
Define pass in `run_avp_fixture.sh` (exit 0) iff the `cd_visual_verify`
timeline shows **all** of:
1. Across windows covering the **last 300 frames**,
`sum(moving_frames) ≥ 40` (AvP first-person tops out ~12/60 per window
under `cd_visual_verify`'s 0.5%-change detector — a per-window 30% bar
is unreachable in-game).
2. Peak `nonblack` in those late windows ≥ **5000** pixels.
3. Process exit 0 from the verifier wrapper (`run_avp_fixture.sh` encodes
this gate).
If `cd_visual_verify` stdout format makes scripting awkward, parse it in the
wrapper with `awk`/`rg` and exit 1 on failure — do not weaken thresholds.
**Explicit non-goals for the gate:** shotgun on screen, red background absent,
green pixels absent. Do not close #267.
## STOP conditions (abort triggers — report, do not improvise)
- **Three failed candidate scripts** against the gate — STOP and report the
three press files + timelines. Do not invent a fourth without maintainer
input.
- Headless shows motion but you cannot tell if it is the menu attract loop —
if nonblack+motion pass, that is enough for this task; do not chase
weapon-select.
- Tempted to commit a save state instead of a press script — STOP unless the
press path is impossible; savestates are a different ticket (#268 world).
- Tempted to declare #267 fixed because fast==accurate on the fixture window —
that is a strong-model follow-on; mention it in the PR, do not close #267.
- AvP ROM missing — STOP.
## Deliverable (exact commit message, PR title, PR body, issue comment text)
**Commit message:**
```
test(fixtures): AvP scripted input to sustained gameplay (#267 unlock)
```
**PR title:**
```
test(fixtures): AvP scripted input to sustained gameplay (#267 unlock)
```
**PR body template:**
```markdown
## Summary
- Adds `test/fixtures/avp_reach_gameplay.press` + runner.
- Gate: late-window motion + non-black coverage, two consecutive runs.
- Does **not** reach shotgun / does not close #267.
## Acceptance gate transcript
$ bash test/tools/run_avp_fixture.sh …
<paste run1 and run2>
```
**Push + PR:**
```bash
git add test/fixtures/avp_reach_gameplay.press test/fixtures/README.md \
test/tools/run_avp_fixture.sh
git commit -m "$(cat <<'EOF'
test(fixtures): AvP scripted input to sustained gameplay (#267 unlock)
EOF
)"
git push -u libretro HEAD
gh pr create --base develop --title "test(fixtures): AvP scripted input to sustained gameplay (#267 unlock)" --body-file - <<'EOF'
## Summary
- Adds AvP press fixture + runner for sustained in-game motion.
- Unlock for #267 / reusable by #266. Does not close either issue.
## Acceptance gate transcript
(paste here)
EOF
```
**Issue comment on #267:**
```markdown
PR <N> banks a scripted-input fixture that reaches sustained AvP gameplay
headlessly (`test/fixtures/avp_reach_gameplay.press`).
Still **not** closing: shotgun / red-background confirmation needs a strong
model + RetroArch check. Headless ≠ composited output (CLAUDE.md caveat).
Next: extend the press script to weapon-select, then `test_blitter_compare`
with `--frame-window` if the bug still reproduces on a nightly.
```
+86
View File
@@ -0,0 +1,86 @@
# Cheap-model task files
> **All six tasks in this directory are complete and merged as of 2026-08-04.**
> They are kept as worked examples of the self-contained task format, not as
> open work. Current status lives in
> [`docs/open-issue-handoff.md`](../open-issue-handoff.md).
Self-contained prompts for weak models in fresh sessions. Each file inlines
its own environment block, hard rules, allowlist, steps, acceptance gate,
STOP conditions, and PR/issue deliverable text. Do not rely on shared
context across sessions.
Source of truth for the open-issue picture: [`docs/open-issue-handoff.md`](../open-issue-handoff.md).
Read [`CLAUDE.md`](../../CLAUDE.md) before any code change.
## Execution protocol
1. **One task per session.** One branch per task. One PR per task.
2. Branch fresh from `libretro/develop` every time — never stack on another
task branch. (Exception: T0 lands on the already-open `docs/open-issue-handoff` / PR #282.)
3. Paste the entire task file into the session. Do not summarise it first.
4. A PR without a pasted acceptance-gate transcript in the body is incomplete.
5. If any STOP condition fires, stop and report. Do not improvise.
## Order
| File | Ticket | Depends | Parallel? |
|---|---|---|---|
| [00-handoff-dependabot-stale.md](00-handoff-dependabot-stale.md) | Handoff doc §9 | — | yes (on PR #282 branch) |
| [01-vjss-info.md](01-vjss-info.md) | #268 tooling | — | yes |
| [02-savestate-decision.md](02-savestate-decision.md) | #268 decision | T1 merged | after 01 |
| [03-cdi-bad-dump-diag.md](03-cdi-bad-dump-diag.md) | #269 | — | yes |
| [04-overscan-band-digest.md](04-overscan-band-digest.md) | #266 tooling | — | yes |
| [05-avp-input-fixture.md](05-avp-input-fixture.md) | #267 unlock | — | yes |
T0, T1, T3, T4, T5 may run in parallel. T2 waits on T1.
## What is NOT here
Judgment-heavy work stays with a strong model / maintainer:
- #266 write-side instrumentation and RetroArch nightly capture
- #267 root-cause after the fixture exists
- VLM / audio-CD chain
- #254 SDL frontend
- Closing #266 or #267 on headless evidence alone (forbidden)
## Shared preamble (every task file inlines this)
The blocks below are copied verbatim into each task file so a session that
only receives one file still has them.
### Environment
```bash
cd /Users/jmattiello/Workspace/Provenance/virtualjaguar-libretro
export DEVELOPER_DIR=/Library/Developer/CommandLineTools
git fetch libretro
# then: git checkout -b <BRANCH> libretro/develop (see each task)
[ -e test/roms/private ] || ln -sfn "${JAGUAR_ROMS_PRIVATE:?set me}" test/roms/private
```
### Hard rules
- NEVER run `git clean -xfd` or any recursive delete at repo root.
- C89 only for C: all declarations at top of block, no `for (int i…)`, no
designated initializers, no VLAs. Prefer `/* */` comments.
Run `bash scripts/c89-lint.sh <file>` before commit on every touched `.c`.
- Branch from `libretro/develop`. Never `master`. Never stack on another task.
- Never relax a test threshold to make something pass.
- Touch ONLY the files in that task's "Files you may touch". If the fix
seems to need another file, STOP and report.
- Prefix host builds with `DEVELOPER_DIR=/Library/Developer/CommandLineTools`.
- After edits: `VJ_EXPECT_BUILD=$(./scripts/build-id.sh)` when running harnesses.
- If `make` fails with *"building for 'macOS', but linking in object file
built for 'iOS'"*, run `make clean` and rebuild — do not investigate.
### PR evidence requirement
Every PR body MUST end with:
```
## Acceptance gate transcript
$ <exact command from the task>
<paste stdout/stderr and exit code>
```
+772
View File
@@ -0,0 +1,772 @@
# Audio CDs and the Virtual Light Machine — investigation and plan
Issue: [#291](https://github.com/libretro/virtualjaguar-libretro/issues/291) —
"Jaguar CD: audio CDs never reach the Virtual Light Machine (single-session discs rejected)".
Investigated on `develop` @ `0ecf924` (v3.0.0), macOS/clang, embedded retail CD BIOS,
2026-08-04. Every trace line quoted below was produced by the runs described here.
**Headline: the root cause was not the loader.** Single-session audio discs load
fine; the loader synthesises a correct one-session TOC already. The blocker was a
**wire-protocol bug in the `$03nn` Read-session-TOC response** in `src/cd/cdrom.c`,
which made the CD BIOS spin forever in its DSA read loop. Fixing it takes the
synthetic music CD all the way to the BIOS's **CD player front-end and into the
VLM screen** — no loader change at all. That fix is implemented on this branch;
what remains is CDDA audio routing.
> **Update, 2026-08-04, `develop` @ `b399eb1`.** §3.2's diagnosis ("Phase 2:
> CDDA routing on `$01nn` Play Title") is **withdrawn — it was a measurement
> error, not a bug.** The SSI head is already streaming correct CD-DA at
> 44.1 kHz before and after Play Title; nothing needs positioning. The real
> blocker is one bit inside the VLM's own DSP code, and everything downstream
> of it works: forcing that bit clear makes the Virtual Light Machine render
> and react to the music. Full evidence, and the new committed regression
> test, in **§8**. Read §8 before acting on §3.2 or on the Phase 2 row of §6.
---
## 1. Confirmation of the prior finding
> "No game title touches CD command `$03`/`$14` — the DSA TOC path is exercised
> only by the audio-CD flow, so the fix is low-risk."
**Confirmed, and with wider scope than originally claimed.** The concern was that
the statement might only hold for *game-resident* drivers while the real BIOS —
which runs for all matrix titles in `bios` mode — uses `$03`/`$14` on game discs
too. It does not.
Method: `VJ_CD_TRACE=1 VJ_CD_TRACE_LIVE=1 VJ_HARNESS_LOG_INFO=1` +
`cd_visual_verify`, `--option virtualjaguar_cd_boot_mode=bios`, 1800 frames per
title, then a census of `DSA_TX` high bytes. Seven titles, covering the whole
sensitive set:
| Title (bios mode, 1800 frames) | DSA command high bytes observed | `$03` | `$14` |
|---|---|---|---|
| Myst (USA) | `$12`×9, `$11`×9, `$10`×9, `$15`×2, `$70`, `$18`, `$05`, `$04` | no | no |
| Hover Strike - Unconquered Lands | `$15`×11, `$12`×9, `$11`×9, `$10`×9, `$70`×3 | no | no |
| Primal Rage (USA) | `$12`×17, `$11`×17, `$10`×17, `$15`×10, `$70`×5, `$02` | no | no |
| Battle Morph (USA) | `$12`×3, `$11`×3, `$10`×3, `$15`×2, `$05`×2, `$04`×2, `$70`, `$18` | no | no |
| Iron Soldier 2 (Songbird) | `$12`×7, `$11`×7, `$10`×7, `$15`×4, `$70` | no | no |
| Blue Lightning (USA) | `$12`×4, `$11`×4, `$10`×4, `$15`×2, `$70`, `$18`, `$05`, `$04` | no | no |
| Vid Grid (USA) | `$12`×5, `$11`×5, `$10`×5, `$15`×4, `$70`×2 | no | no |
Zero `$03xx` and zero `$14xx` across all seven, in real-BIOS mode.
Why: on a 2-session game disc, boot-stub extraction succeeds and `src/core/jaguar.c`
redirects the 68K past the BIOS's disc-probe code, so the BIOS's own TOC read never
runs. The TOC path is reached **only** when boot-stub extraction fails, i.e. exactly
the single-session audio-disc case.
Corroborating static check: `minTrack`/`maxTrack` (`cdrom.c:1900-1902`), the only
other consumers of `CDIntfGetSessionInfo()` inside `cdrom.c`, are assigned solely
on a `$1400` write — which nothing sends.
Caveat, stated honestly: each census is an 1800-frame window from cold boot. It
covers boot and early gameplay, not every minute of every title. It does not cover
`hle` mode, but `hle` never executes BIOS code by construction.
---
## 2. Current behaviour — where it stalled, with evidence
### 2.1 Test asset
Synthesised, no copyrighted audio. No file was added to the repo — the generator is
inline in the repro (§7) and writes to a scratch dir. Two 20-second audio tracks of
tremolo'd sine, 2352-byte Red Book sectors, single session, no `ATRI` header:
```
FILE "track01.bin" BINARY
TRACK 01 AUDIO
INDEX 01 00:00:00
FILE "track02.bin" BINARY
TRACK 02 AUDIO
INDEX 01 00:00:00
```
### 2.2 The loader is *not* the blocker
`CDIntfLoadDisc()` handles the one-session case correctly (`src/cd/cdintf.c:401-443`):
`numSessions = 1`, `sessions[0].firstTrack = 1`, `lastTrack = 2`, and the
"single session: lead-out after last track" branch computes `leadOutLBA = 3000`
(→ absolute MSF 00:42:00). `CDIntfGetSessionInfo(0, 0..4)` therefore returns
`1, 2, 0, 42, 0` — a perfectly good session TOC.
The `numSessions >= 2` gates do fire, but only where they should:
`CDIntfExtractBootStub()` (`cdintf.c:1335`) early-exits with
```
[CD-BOOTSTUB] Early exit: loaded=1 numSessions=1
[CD-BOOTSTUB] CDIntfExtractBootStub failed
```
which is *correct behaviour* for an audio disc — there is no boot stub. In `bios`
mode the BIOS then falls through to its own disc probe, which is what we want.
(In `hle` mode the same failure aborts `retro_load_game()`; see §5, Phase 3.)
### 2.3 The actual stall: `$03nn` never delivers word 2
Unfiltered trace ring, `develop` before the fix:
```
[CD-TRACE-LIVE] tick=247972 kind=DSA_TX value=$7001 Set DAC Mode
[CD-TRACE-LIVE] tick=247986 kind=DSA_RX value=$7001
[CD-TRACE-LIVE] tick=247986 kind=DSA_TX value=$150A Set Mode: 2x, data
[CD-TRACE-LIVE] tick=247990 kind=DSA_RX value=$170A
[CD-TRACE-LIVE] tick=248008 kind=DSA_TX value=$0300 Read session TOC, session 0
[CD-TRACE-LIVE] tick=248009 kind=DSA_RX value=$0301 ... and nothing, ever again
```
Temporary instrumentation on the `BUTCH+2` and `DSCNTRL` read paths (since removed)
pinned it exactly. (`who` is the access-source tag from `src/core/vjag_memory.h:59`;
the `68K_PC` field is sampled unconditionally and so shows where the 68K is at the
time of the read, not necessarily who issued it.)
```
[VLMDIAG] BUTCH+2 read data=$3000 dsaRdy=1 cdPtr=0 68K_PC=$05046A GPU_PC=$F03A8A who=2
[CD-TRACE-LIVE] tick=248009 kind=DSA_RX value=$0301
[VLMDIAG] DSCNTRL read (ack) dsaRdy=1->0 cdPtr=1 multi=1 qcount=0 68K_PC=$05046A who=2
[VLMDIAG] DSCNTRL read (ack) dsaRdy=0->0 cdPtr=1 multi=1 qcount=0 68K_PC=$05046A who=2
[VLMDIAG] BUTCH+2 read data=$1000 dsaRdy=0 cdPtr=1 68K_PC=$050468 GPU_PC=$F03A8C who=2
[VLMDIAG] BUTCH+2 read data=$1000 dsaRdy=0 cdPtr=1 68K_PC=$050478 GPU_PC=$F03A8C who=2
... identical line forever, PCs cycling over $050464-$050484 ...
```
Read: the BIOS's DSA read sequence is **poll `BUTCH+2` bit 13 → read `DS_DATA` →
read `DSCNTRL` (ack)**. The ack path at `cdrom.c:1146` unconditionally clears
`dsaResponseReady`, and re-arms only when `dsaQueueCount > 0`. TOC words are
synthesised on demand from the disc layout, never pushed onto `dsaQueue`
(`qcount=0` above), so nothing ever re-raised bit 13. `data=$1000` is TX-empty
only; bit 13 (`$2000`) is gone for good. `BUTCH+2` is re-read indefinitely while the
68K cycles a tight loop at `$050464-$050484` (BIOS code running from RAM) for the
rest of the run, and the boot animation keeps drawing — which is exactly the
reported symptom.
**Note this contradicts the "edge starvation" reading** that the same trace also
admits (a level-held `dsaResponseReady` would yield exactly one IRQ, because
`BUTCHExec`'s delivery is edge-gated at `cdrom.c:1050/1082`). The measurement
settles it: the BIOS **polls**, it does not wait on the IRQ, and the flag was
cleared rather than pinned. The fix is "re-arm after ack", not "re-pace the edge".
### 2.4 The second, deeper bug: wrong response tags
With the re-arm in place, all five words flowed — as `$0301 $0302 $0300 $032A $0300` —
and the BIOS **still** spun, now at `cdPtr=5`. Adding a sixth `$0300` end-marker
word did not help either (it spun at `cdPtr=6`).
Ground truth resolved it. `test/mister_ground_truth.h:230-234`, transcribed from
MiSTer's `butch.v`, says a `$03` Read-TOC response is **not** a run of `$03nn`
echoes — each word carries its own response opcode:
```
#define DSA_RSP_TOC_MIN_TRK 0x20
#define DSA_RSP_TOC_MAX_TRK 0x21
#define DSA_RSP_TOC_LO_MIN 0x22
#define DSA_RSP_TOC_LO_SEC 0x23
#define DSA_RSP_TOC_LO_FRM 0x24
```
Our code echoed `$03nn` for all five (`cdrom.c:1246` pre-fix) and terminated with a
sixth `$0300`. The BIOS's collector discarded every word and kept asking. The
`$14nn` long-TOC path next door already used the correct `$60..$64` tags — the
short-TOC path was simply never exercised, so the bug sat unnoticed.
The terminator is likewise `$0400`, not `$0300`: the Primal Rage 68K CD driver's
multi-word collector at `$C20C` uses an `$04xx` word as end-of-response (already
documented at `cdrom.c:1703-1715`).
### 2.5 After the fix — full disc dialogue, and the VLM
```
DSA_TX $7001 Set DAC Mode
DSA_TX $150A -> $170A Set Mode 2x/data
DSA_TX $0300 -> $2001 $2102 $2200 $232A $2400 session 0 TOC: trk 1..2, lead-out 00:42:00
DSA_TX $1400 -> $6001 $6101 $6200 $6302 $6400 long TOC track 1 @ 00:02:00
$6002 $6102 $6200 $6316 $6400 long TOC track 2 @ 00:22:00
DSA_TX $0301 -> $0400 session 1 TOC: absent (correct — single-session disc)
DSA_TX $1501 -> $1701 Set Mode: single speed, AUDIO <- the is-this-a-music-CD verdict
DSA_TX $0200 Stop
I2S_CTRL $000F I2S path to JERRY enabled
DSA_RX $0200
```
The BIOS probes the disc, finds one session with no data, re-modes the drive to
**single-speed audio**, and hands off. On screen at frame 1200 (`cd_visual_verify
--outdir ... --shot-every 600`, PPM → PNG) is the Jaguar CD **audio-CD player
front-end**: STOP / REW / PLAY / FF / PAUSE transport, `TRK 2`, `TIME 0:42`,
`NORMAL` / `NO REPEAT` / **`VLM`** buttons, and a track grid with cells 1 and 2 lit.
With `--press 600:a`, the player accepts input, switches to a **distinct BIOS
screen** — a black field carrying a `1-4` indicator and the `2 0:42` track/time
readout — and issues `DSA_TX $0101` — **Play Title, track 1**.
Stated precisely: the disc is accepted, the TOC is correct, the player front-end
runs and responds to input, and pressing A leaves the player for another BIOS
screen while starting playback. That screen is *consistent with* the VLM (the
player's own `VLM` button, the mode indicator, the black visualisation field with
no audio to draw), but "this is VLM code executing" is an inference, not a
measurement — nothing here checked the PC region. Confirming it is Phase 4 work,
and Phase 4's gate ("the visualisation reacts to the audio") is the real proof.
Nothing else in this plan depends on the identification.
What is missing either way is the audio itself.
---
## 3. The minimal change set
### 3.1 Implemented on this branch (`src/cd/cdrom.c`, 3 hunks)
1. **`$03nn` response tags** — emit `$20/$21/$22/$23/$24` for the five session-TOC
data words instead of five `$03nn` echoes, and terminate with `$0400` instead of
a sixth `$0300`. Deleted the now-unreachable `cdPtr >= 5` clear branch: the
`$0400` terminator hits the existing `data == 0x0400` clear one line above.
2. **Re-arm RX-full across a multi-word response** — `DSCNTRL` ack path
(`cdrom.c:1152`) and the `BUTCHExec` turnaround countdown (`cdrom.c:960`) now
treat `isMultiWordResponse` like a non-empty `dsaQueue`.
No new state, no savestate fields, no version bump: `isMultiWordResponse` and
`cdPtr` were already serialised.
One adjacent hazard was checked and is **not** present: `trackNum` (file-scope
`static`, `cdrom.c:1095`) walks up to `maxTrack + 1` during a `$14nn` long-TOC
read and is not reset by the CDROM reset block, which would make a *second*
`$1400` return an immediate `$0400`. The `$1400` write handler does reset it
(`trackNum = minTrack;`, `cdrom.c:1900`). This matters because `$14` was as dead
as `$03` before this change and is now newly reachable.
**Loader changes required: none.** The `numSessions >= 2` gates are all correct as
written; audio discs simply have no boot stub, and the BIOS handles that itself.
### 3.2 Still required for a working VLM (not implemented)
> **WITHDRAWN — see §8.** The paragraph below is wrong. It inferred "no CDDA"
> from the output RMS not moving, and inferred from `cdrom.c:1852` that only a
> `$12xx` seek can position the head. Direct instrumentation of
> `SetSSIWordsXmittedFromButch()` shows the head *is* primed and streaming (it
> refills from `ssiBlock` on its own when `cdPlaying` goes true) and delivers
> the disc's samples to `lrxd`/`rrxd` at 44.1 kHz, correctly, including the
> silence of an inter-track pregap at exactly the right offset. The RMS did not
> move because the DSP never forwards the samples to LTXD/RTXD — for a reason
> that has nothing to do with head position. **Do not implement this.** The
> only surviving item from this section is the `$61nn` `LONG_TOC_CA` nit at the
> end, which remains true and remains non-blocking.
**CDDA routing on `$01nn` Play Title.** The SSI head that feeds JERRY's I2S port
(`SetSSIWordsXmittedFromButch`, `cdrom.c:2110+`) is positioned **only by a `$12xx`
Goto-Frame seek** (`ssiBlock = block + 1; memcpy(ssiBuf, cdBuf, ...)` at
`cdrom.c:1852-1854`). The VLM plays via `$01nn` Play Title, which sets
`cdPlaying = true` but never positions the head. Result today: RMS stays at 980 —
identical to the boot animation, byte-for-byte the same non-silent sample count —
i.e. no CDDA at all.
The fix is to make `$01nn` position the drive like a seek does: resolve track `nn`
to its `dataLBA` via the existing `disc.tracks[]` table, set `block`, load
`cdBuf`/`ssiBuf`, reset `cdBufPtr`/`ssiBufPtr`/`ssiBlock`. All the machinery below
that point already works — this is the same path Primal Rage's CDDA uses, and it
was fixed and validated in that context (`docs/cd-diagnosis/primal-rage-cdda-diagnosis.md`).
Open sub-questions for that phase:
- Does the drive stop at the track's lead-out, or run on into the next track?
`NORMAL` / `NO REPEAT` in the player UI implies the *BIOS* sequences tracks, so
the drive should probably free-run and the BIOS re-issue `$01nn`. Verify against
the trace rather than guessing.
- Does the VLM's FFT read the DAC path (`lrxd`/`rrxd`) or the BUTCH FIFO? The
`I2S_CTRL $000F` write in the handoff says the I2S-to-JERRY path is on, which
points at the DAC path.
- `$04`/`$05` Pause/Unpause and `$51` Set Volume from the transport buttons should
be checked once audio flows.
- The `$61nn` word of the `$14nn` long-TOC response is `LONG_TOC_CA` (control/adr)
per `test/mister_ground_truth.h:243`; we return the track number again
(`cdrom.c`, `cdPtr < 0x62` branch). The BIOS accepted it, so this is
non-blocking, but it is wrong and should be corrected when the audio work
touches that path.
---
## 4. CDDA routing plan
The Jaguar CD drive in audio mode streams Red Book samples over I2S straight into
JERRY's serial port; the DSP mixes them with synth output. We already model this:
```
CDIntfReadBlock(ssiBlock, ssiBuf) src/cd/cdrom.c
-> SetSSIWordsXmittedFromButch() lrxd/rrxd (sample-aligned, L then R, LE)
-> JERRY SSI / I2S receive src/jerry/dac.c (LRXD/RRXD reads)
-> DSP mix -> LTXD/RTXD -> DAC src/jerry/dsp.c, dac.c
-> libretro audio batch cb libretro.c
```
`ButchIsReadyToSend()` gates on `ssiBufPtr < 2352`, and
`SetSSIWordsXmittedFromButch()` already returns silence when `!cdPlaying ||
seekDelay > 0` — the pause/stop semantics the transport buttons need are in place.
Plan:
1. **Position the head on `$01nn`** (§3.2). Smallest correct change; makes CDDA flow.
2. **Verify SMODE.** Red Book playback wants JERRY's SSI in *slave* mode (the drive
clocks it). `dac.c:301-305` already traces SMODE master/slave transitions —
confirm the BIOS/VLM sets slave, and that our first-I2S-IRQ deferral
(`project_cd_dsp_sclk_irq_fix`, commit `b63fb7d`) does not swallow the start.
3. **Level/mix.** Once samples arrive, run `test/test_audio_clipping` and
`test/test_audio_presence` against the synthetic disc: a pure sine at ±20000
is a clean saturation probe, and the presence test catches a silencing
regression. Do not adjust either threshold.
4. **Track sequencing and transport.** `$02` Stop, `$04`/`$05` Pause/Unpause,
`$51` Set Volume, and end-of-track behaviour, driven from the player UI via
`--press`.
---
## 5. Regression risk
**Assessed low for the implemented change, on direct evidence rather than
inspection.** The changed code is reachable only via `$03nn`/`$14nn`, which §1
shows no title sends in either mode — the seven-title census includes the whole
sensitive set (Hover Strike, Myst, Primal Rage, Iron Soldier 2, Battle Morph) plus
Blue Lightning and Vid Grid.
Risk is nonetheless *not* zero and the gate is the boot matrix, not this document:
| Risk | Why it is small | Residual |
|---|---|---|
| `isMultiWordResponse` re-arm leaks into the seek/stop queue path | The two conditions are OR'd; the queue path's behaviour is unchanged when `isMultiWordResponse` is false, which it is for every `$10/$11/$12/$02/$70/$15/$18/$50/$54` write | A title that sends `$03`/`$14` *after* frame 1800 would newly get a paced multi-word stream instead of a stall — strictly closer to hardware |
| `dsaResponseReady` pinned true forever if software issues `$03nn` and never reads | Delivery is edge-gated (`cdrom.c:1050`), so no IRQ storm; one re-arm per ack, same as the queue | Bounded; matches existing queue semantics |
| Response-tag change breaks a driver that expected `$03nn` | Nothing consumes `$03nn` today (§1); MiSTer's `butch.v` table is the hardware reference | Would surface as a matrix regression |
| Savestate | No fields added or reordered | None |
Evidence actually collected on this branch: `cd_visual_verify`, 1800 frames, five
titles (Myst, Hover Strike, Primal Rage, Iron Soldier 2, Battle Morph), **`bios`
mode before vs after the change is byte-identical** — same avg RMS, same non-silent
sample counts, same resolutions, all `PASS`. The same five in `hle` mode also pass
(as expected: `hle` never executes BIOS code, so `$03nn` is unreachable there).
`make TEST_EXPORTS=1 test` exits 0.
**Non-negotiable gate before merge:** `test/tools/cd_boot_matrix.sh` — all 22 rows
still `GAME_CODE` in both `hle` and `bios`. That has *not* been run for this branch;
a full matrix run is the first thing the PR needs. Spot checks done here (Myst,
Hover Strike via `cd_visual_verify`) are necessary, not sufficient.
Also required per `CLAUDE.md`: verification in RetroArch, not only headless — the
headless framebuffer read path is not proof of what is presented.
---
## 6. Phased PR breakdown
| Phase | Scope | Effort | Gate |
|---|---|---|---|
| **1. DSA session-TOC protocol fix** *(implemented on this branch)* | `src/cd/cdrom.c`: `$20..$24` response tags, `$0400` terminator, multi-word RX-full re-arm | ~0.5 day incl. validation | `make TEST_EXPORTS=1 test` green; full `cd_boot_matrix.sh` all-`GAME_CODE`; audio disc reaches the CD player UI |
| ~~**2. CDDA routing for `$01nn` Play Title**~~ **CANCELLED (§8)** | Nothing to do: the SSI head already streams correct CD-DA on Play Title, verified sample-for-sample against the disc image. Superseded by "unmute the VLM's DSP audio path" — see §8.4 | — | Now pinned by `test/test_cd_synth_cdda.c` |
| **3. Loader/frontend acceptance for audio discs** | Let `hle` mode stop rejecting audio-only images — either force the real-BIOS path when `numSessions == 1 && no ATRI` (cheap, honest: the VLM is BIOS code and `hle` can never produce it), or fail with a clear message. Touches `libretro.c:1507-1517`, `jagcd_hle.c:1586-1591` | 0.5–1 day | Audio disc boots regardless of the `CD Boot Mode` setting |
| **4. VLM polish + transport** | Track sequencing, Pause/Unpause/Volume, repeat modes, end-of-disc | 1–2 days | Player transport works end to end; VLM visualisation reacts to the audio |
| **5. Follow-ons (separate tickets, do not bundle)** | `supports_no_game` (boot BIOS with no disc), libretro disk control — both correctly disabled today and blocked on a disc-status DSA opcode the jump table does not have | — | File only after Phase 4 lands |
Phases 1 and 2 are the feature. Phase 3 is a frontend nicety. Phase 4 is what makes
it pleasant. Phase 5 is explicitly out of scope for this issue.
---
## 7. Repro
```bash
# Build core (wide test ABI) + harness
DEVELOPER_DIR=/Library/Developer/CommandLineTools make TEST_EXPORTS=1 -j$(getconf _NPROCESSORS_ONLN)
DEVELOPER_DIR=/Library/Developer/CommandLineTools cc -O2 -Wall -std=c99 \
-I./libretro-common/include -I./src -o test/tools/cd_visual_verify \
test/tools/cd_visual_verify.c test/harness/harness.c -lm
# Synthesise a music CD (two audio tone tracks, one session, no ATRI header)
mkdir -p /tmp/vlm_musiccd && cd /tmp/vlm_musiccd && python3 - <<'EOF'
import math, struct
SR, SECTOR, SPS = 44100, 2352, 75
def tone(path, freq, secs):
buf = bytearray()
for i in range(secs * SPS * SECTOR // 4):
t = i / SR
a = 0.55*math.sin(2*math.pi*freq*t) + 0.25*math.sin(2*math.pi*freq*2*t)
a *= 0.6 + 0.4*math.sin(2*math.pi*1.5*t)
v = int(max(-1.0, min(1.0, a)) * 20000)
buf += struct.pack('<hh', v, v)
open(path, 'wb').write(bytes(buf))
tone('track01.bin', 440.0, 20)
tone('track02.bin', 660.0, 20)
open('musiccd.cue', 'w').write(
'FILE "track01.bin" BINARY\n TRACK 01 AUDIO\n INDEX 01 00:00:00\n'
'FILE "track02.bin" BINARY\n TRACK 02 AUDIO\n INDEX 01 00:00:00\n')
EOF
cd -
# Boot it with the real CD BIOS; press A at frame 600 to enter the VLM
mkdir -p /tmp/vlm_shots
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) VJ_CD_TRACE=1 VJ_CD_TRACE_LIVE=1 VJ_HARNESS_LOG_INFO=1 \
./test/tools/cd_visual_verify ./virtualjaguar_libretro.dylib /tmp/vlm_musiccd/musiccd.cue \
--option virtualjaguar_cd_boot_mode=bios --frames 2400 \
--outdir /tmp/vlm_shots --shot-every 300 --press 600:a
for f in /tmp/vlm_shots/*.ppm; do sips -s format png "$f" --out "${f%.ppm}.png"; done
```
`VJ_CD_TRACE_LIVE=1` is what makes the drive dialogue visible — the `[CDDA] DSA cmd`
log line filters on command high byte (`cdrom.c:1608-1610`) and can never show
`$03`, `$14`, `$10`, `$11`, `$12`, `$02` or `$18`. Do not infer absence from it.
---
## 8. Second pass — the VLM works; one bit in its DSP code keeps it muted
Investigated on `develop` @ `b399eb1` (post #300, #305, #307), macOS/clang,
embedded retail CD BIOS, 2026-08-04. Every number below came from the runs
described in §8.6.
### 8.1 The disc
Still synthetic — no real audio CD exists in the corpus (`find -L
test/roms/private -name '*.cue'` returns 35 CUEs, all Jaguar game discs). But a
much more informative one than §2.1's two-tone pair, built so that **the decoded
audio itself says what is playing and where**:
- **multi-file CUE, one BIN per track**, `REM SESSION 01` — the exact shape
every CUE in the corpus uses, so `dataLBA != startLBA` and the loader's
pregap handling is exercised rather than bypassed;
- **6 tracks** (manual repro), each preceded (2..6) by a real **150-sector
`INDEX 00` pregap** of digital silence;
- **track *t* is a square-wave chirp sweeping `882*t` → `882*t + 441` Hz**, so
a zero-crossing count identifies the track, and the instantaneous frequency
identifies the position within it;
- **left ≠ right**: right is the second harmonic at a different amplitude, so a
channel swap — invisible on a mono disc, and this repo has SSI channel
history — is glaring;
- **constant known amplitude** (±20000 L / ±12000 R), never zero, so the pregap
is the only silence anywhere on the disc.
Generator: §8.6. The waveform/PCM model is shared with
`test/test_cd_synth_cdda.c`; note that the committed CI test uses **4 tracks**
(2 s each) to keep runtime and disc size small.
### 8.2 What the BIOS does with it — both modes, mode taken from the log
`bios` (`[BOOT] CD game, mode=BIOS -- boot ROM forced on`): the full drive
dialogue completes, including the six-track long TOC —
```
DSA_TX $7001 $150A $0300 $1400 $0301 $1501 $0200 $0101
```
— and the **CD player front-end renders correctly for a 6-track disc**:
STOP/REW/PLAY/FF/PAUSE transport, `TRK 6`, a `TIME` readout showing the disc's
lead-out, `NORMAL` / `NO REPEAT` / `VLM`, and a track grid with cells 1–6 lit.
(The player-UI screenshot was read on an earlier 6-track disc whose lead-out is
`1:21`; the chirp disc's is `1:00`. Everything else about the screen is
identical — the readout tracks the disc, which is the point.) Navigating with **B** (right) to
PLAY and pressing **A** issues `$0101` Play Title and switches to the VLM
screen. That screen is black apart from a `1-4` preset indicator and a
track/time readout. RMS 980, 240573 non-silent samples — *byte-identical to a
run where PLAY is never pressed*. No CD audio reaches the output.
`hle` (`[BOOT] CD game, mode=HLE`): `retro_load_game()` fails —
`[CD-BOOTSTUB] Early exit: loaded=1 numSessions=1` → "unsupported or invalid
content format". This is the documented pre-existing state, Phase 3 scope, and
is **not** a regression from anything here. The VLM is BIOS code; `hle` can
never produce it (§5 trap). There is therefore no HLE run to compare against
for an audio disc, and any "both modes" comparison for the VLM itself is
meaningless by construction.
### 8.3 The SSI head is fine — §3.2 refuted
Instrumenting `SetSSIWordsXmittedFromButch()` (`src/cd/cdrom.c`) directly:
```
[VLMDIAG] SSI live #1 nonzero=0 lrxd=$0000 rrxd=$0000 ssiBlock=1 ptr=0
[VLMDIAG] SSI live #44101 nonzero=44094 lrxd=$0378 rrxd=$0378 ssiBlock=76 ptr=0
[VLMDIAG] SSI live #88201 nonzero=88188 lrxd=$B756 rrxd=$B756 ssiBlock=151 ptr=0
...
[VLMDIAG] SSI live #617401 nonzero=617283 ... ssiBlock=1051
[VLMDIAG] SSI live #661501 nonzero=617283 ... ssiBlock=1126 <- nonzero frozen
[VLMDIAG] SSI live #705601 nonzero=617283 ... ssiBlock=1201 <- inter-track pregap
[VLMDIAG] SSI live #749701 nonzero=661376 ... ssiBlock=1276 <- audio resumes
```
44100 samples/s, `ssiBlock` advancing 75 sectors/s (the drive rate), nearly all
samples non-zero — and the plateau in `nonzero` lands exactly on the 150-sector
pregap the disc has between tracks. The head is streaming the right bytes from
the right place. Positioning it on `$01nn` would fix nothing.
`cdPlaying` is true, `seekDelay` is 0, `ButchIsReadyToSend()` is true (the
`I2S_CTRL $000F` write sets the I2CNTRL bit), JERRY is in slave mode
(`[CDDA] SMODE $0015 -> $0014 (slave: CD -> I2S)`), and the DSP takes the SSI
interrupt 44100 times a second (`ssiAssert` and `ssiDispatch` both advance
44060 per 60 frames). Every stage up to the DSP is working.
### 8.4 The actual blocker: alt-bank R13 bit 30 in the VLM's DSP code
The output is silent because **the DSP writes nothing to LTXD/RTXD**. Counting
`DACWriteWord` calls per frame (`i2sWrites`, reset each frame in
`DACPrepareFrame`):
```
frame 360: i2sWrites=348 dspRun=1 dspPC=$F1B11E flags=$00004461 <- BIOS boot audio
frame 420: i2sWrites=348 dspRun=1 dspPC=$F1B120 flags=$00004461
frame 480: i2sWrites=2 dspRun=0 dspPC=$F1B26E flags=$00000000 <- DSP reloaded: VLM
frame 540: i2sWrites=2 dspRun=1 dspPC=$F1BA5A flags=$00000420
... (2 = the two seed values DACPrepareFrame writes; zero real writes)
```
Disassembling the DSP RAM at that point: the SSI vector `$F1B010` jumps to
`$F1BC54`, and the handler opens with
```
$F1BC54 LOAD (R26), R27
$F1BC56 MOVEFA R13, R30
$F1BC58 BTST #30, R30
$F1BC5A JR EQ, $00F1BC66 ; bit 30 clear -> pass-through path
$F1BC5E LOAD (R28), R23 ; bit 30 set -> read LRXD, discard
$F1BC60 MOVEQ #0, R30 ; zero the FFT input
$F1BC64 MOVEQ #0, R29
$F1BC66 MOVEFA R11, R25 ; pass-through path:
$F1BC6A LOAD (R28), R23 ; R28 = $F1A148
$F1BC6E IMULT R25, R23 ; scale by volume
$F1BC74 STORE R23, (R28) ; R28 += 4 -> write RTXD
$F1BC80 STORE R23, (R28) ; R28 -= 4 -> write LTXD
```
so **bit 30 of alternate-bank R13 gates both the audio pass-through and the FFT
input**. Measured at every ISR entry, it is `$40000000` — set — for the whole
VLM session. It is set by the VLM's own init:
```
$F1BA1A MOVEI #$40000000, R00
$F1BA20 MOVETA R00, R13
```
i.e. **the VLM starts muted by design and something must clear the bit**. The
only other writer of alt-R13 in the whole 8 KB of DSP RAM is `MOVETA R25, R13`
at `$F1BD92`, inside the block at `$F1BD34..$F1BE04` — and that block polls
`$DFFF1A`:
```
$F1B9CA MOVEI #$00DFFF1A, R00
$F1B9D0 MOVETA R00, R21
...
$F1BD44 MOVEFA R21, R30
$F1BD4C LOADW (R30), R29
$F1BD4E BTST #4, R29
```
`$DFFF1A` is `SUBDATA+2` — **BUTCH's CD subcode data register**, which this core
does not implement (RAM-backed only; `src/cd/cdrom.c:1486` has had a
`[SUBCODE] read` diagnostic on it for a while). Confirmed live:
```
[SUBCODE] write SBCNTRL+0 = $0000 who=6 68kpc=$080082
[SUBCODE] write SBCNTRL+2 = $00F2 who=6 68kpc=$080082
[SUBCODE] read SUBDATA+2 -> $0000 who=2 68kpc=$193040 (who=2 = DSP)
```
The VLM's 68K side arms subcode capture via `SBCNTRL`, and its DSP side polls
`SUBDATA` and always reads zero. The `0 0:01` readout on the VLM screen —
**track 0** with a ticking time — is the same story from the other end.
**Not yet proven:** that supplying valid subcode is what clears bit 30. The
static read of `$F1BD92` places the writer inside the subcode block, and the
DSP demonstrably polls the register, but the delay-slot/computed-jump structure
of that block is not safely readable statically. A crude probe (forcing
`SUBDATA+2` reads to return `$0010`, `$0011`, `$FFFF`) changed nothing —
output stayed byte-identical at RMS 980 / 240573 non-silent — so a naive
bit-4 stub is not enough, and the real Q-channel frame format Butch presents
has no local ground truth (MiSTer's `butch.v`, our reference for the DSA
protocol, does not implement subcode either; `test/mister_ground_truth.h`
has the register addresses and nothing more).
### 8.5 Everything downstream of that bit is correct
Diagnostic hack, **not shipped**: clear bit 30 of `dsp_reg_bank_1[13]` on every
SSI interrupt. Same disc, same input script, `bios` mode confirmed from the log:
| | muted (as shipped) | bit 30 forced clear |
|---|---|---|
| non-silent samples / 1200000 | 240573 | **785123** |
| average RMS | 980 | **1268** |
| frame motion, VLM window | 0/60 | **60/60** |
| avg frame change | 0.00 % | **9.8–14.8 %** |
| non-black pixels | 0.3 % | **up to 16.5 %** |
and on screen: a full radial spectrum analyser — concentric rings of coloured
cells pulsing outward, a cyan waveform trace across the middle, the `1-4`
preset indicator and the track/time readout — visibly changing between
screenshots as the chirp climbs. **The Virtual Light Machine runs, renders, and
reacts to the audio.** The one thing between it and working is the mute bit.
That table is the oracle for the eventual fix: a correct implementation must
reproduce those numbers with no hack in the DSP.
### 8.6 Regression test, and the repro
`test/test_cd_synth_cdda.c` (new, in `make test`) pins the part that works, on
its own synthetic disc — it never touches `test/roms/private`, so unlike
`test/test_cd_ssi_stream.c` (which SKIPs without `VJ_SSI_DISC`) it actually
runs on a fresh clone and in CI:
| assertion | negative control | result |
|---|---|---|
| LRXD = left, RRXD = right, sample-aligned from byte 0, across sector boundaries | swap the two reads in `SetSSIWordsXmittedFromButch` | 4 of 5 tests red |
| measured frequency identifies the seeked track, right = 2× left | `CDIntfGetTrackInfo` uses `startLBA` instead of `dataLBA` | 5 of 5 red |
| a redundant `$12xx` does not rewind the head; the chirp keeps climbing | restore `ssiBufPtr = 0` on the redundant-seek branch (pre-#307) | exactly that one test red |
| the pregap is silence and the tone starts at `INDEX 01` | as row 2 | red |
Each control was run at the same git rev with `src/cd/*.o` and the dylib
deleted first (`make` skips rebuilds on second-identical mtimes, and
`VJ_EXPECT_BUILD` cannot catch that when both builds are dirty at the same
rev). Verified green with `test/roms/private` removed, then restored.
Manual repro of §8.2 and §8.5:
```bash
DEVELOPER_DIR=/Library/Developer/CommandLineTools make TEST_EXPORTS=1 -j8
DEVELOPER_DIR=/Library/Developer/CommandLineTools cc -O2 -Wall -std=c99 \
-I./libretro-common/include -I./src -o test/tools/cd_visual_verify \
test/tools/cd_visual_verify.c test/harness/harness.c -lm
# 6-track chirp audio CD, real pregaps, L != R -- same PCM model as
# test/test_cd_synth_cdda.c
mkdir -p /tmp/vlmcd && python3 - /tmp/vlmcd <<'EOF'
import os, struct, sys
SECTOR, SAMP_SEC, BASE_INC, MASK = 2352, 588, 85899346, 0xFFFFFFFF
outdir, ntracks, sectors, pregap = sys.argv[1], 6, 600, 150
def gen(track, sectors):
n = sectors * SAMP_SEC; dinc = (BASE_INC // 2) // n
ph, inc, buf = 0, track * BASE_INC, bytearray()
for _ in range(n):
buf += struct.pack('<hh', -20000 if ph & 0x80000000 else 20000,
-12000 if ph & 0x40000000 else 12000)
ph = (ph + inc) & MASK; inc = (inc + dinc) & MASK
return bytes(buf)
cue = ['REM SESSION 01']
for t in range(1, ntracks + 1):
name = 'track%02d.bin' % t; pcm = gen(t, sectors)
cue += ['FILE "%s" BINARY' % name, ' TRACK %02d AUDIO' % t]
if t > 1:
pcm = b'\x00' * (pregap * SECTOR) + pcm
cue += [' INDEX 00 00:00:00', ' INDEX 01 00:02:00']
else:
cue += [' INDEX 01 00:00:00']
open(os.path.join(outdir, name), 'wb').write(pcm)
open(os.path.join(outdir, 'musiccd.cue'), 'w').write('\n'.join(cue) + '\n')
EOF
# Boot the real CD BIOS. B moves the transport cursor right (STOP -> REW ->
# PLAY), A activates -- that is what issues $0101 Play Title. Pressing A
# from the default cursor position goes straight to the VLM WITHOUT playing.
mkdir -p /tmp/vlmshots
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) VJ_CD_TRACE=1 VJ_CD_TRACE_LIVE=1 \
VJ_HARNESS_LOG_INFO=1 VJ_HARNESS_LOG_DEBUG=1 \
./test/tools/cd_visual_verify ./virtualjaguar_libretro.dylib \
/tmp/vlmcd/musiccd.cue --option virtualjaguar_cd_boot_mode=bios \
--frames 1500 --outdir /tmp/vlmshots --shot-every 300 \
--press 700:b --press 760:b --press 820:a
for f in /tmp/vlmshots/*.ppm; do sips -s format png "$f" --out "${f%.ppm}.png"; done
```
Traps, both of which cost time here:
- **`--bios` does not select the CD boot mode.** Only
`--option virtualjaguar_cd_boot_mode=bios` does. Confirm from the run's own
`[BOOT] CD game, mode=...` line, never from the flag.
- **Pressing A immediately enters the VLM without starting playback** (`$0101`
never goes out), which looks exactly like the failure being investigated.
Drive PLAY explicitly: `--press N:b --press N+60:b --press N+120:a`.
### 8.7 What is left
*(Items 1 and 2 are RESOLVED — see §8.8. Items 3 and 4 remain.)*
1. ~~**Find what clears alt-R13 bit 30**~~ — answered in §8.8: a CRC-valid
Q-subcode frame whose CONTROL nibble says "audio track".
2. ~~**Probably: model BUTCH's subcode Q-channel.**~~ — implemented, data-only
as prescribed (no SBCNTRL bit 2/3 interrupts are asserted).
3. **Phase 3 (loader/frontend acceptance in `hle` mode)** and **Phase 4
(transport: `$02` Stop, `$04`/`$05` Pause/Unpause, `$51` Volume, track
sequencing, repeat modes)** are unchanged from §6.
4. **RetroArch verification is still owed.** Everything above is headless.
### 8.8 Third pass — RESOLVED: bit 30 is the Q-channel "data track" flag, and the VLM unmutes with real subcode
Investigated and implemented on `feat/291-vlm-subcode` off `develop` @
`f36ae03` (v3.1.0), 2026-08-05. The §8.4 hypothesis is **proven**, by
implementation: serving Q subcode — nothing else changed, no DSP hack —
clears bit 30 and reproduces the §8.5 oracle numbers.
**Why the static read failed before, in one line:** the bit-30 writer
`$F1BD92 MOVETA R25, R13` is the **delay slot of the `JUMP T,(R30)` at
`$F1BD90`** — it belongs to the code path that jumps *to* `$F1BD8E`, not to
any fall-through — and that path is the tail of the store-a-CRC-valid-Q-frame
handler at `$F1BD94`. R25 there holds the **first 4 assembled Q bytes**
(CONTROL/ADR, track, index, min), so bit 30 of alt-R13 is bit 6 of Q byte 0 =
**Q CONTROL bit 2, the Red Book "data track" flag**. The VLM boots with
alt-R13 = `$40000000` — "assume data track, stay muted" — and unmutes the
instant a CRC-valid frame with an audio CONTROL nibble arrives. It is a
real CD player's data-track mute, working exactly as designed.
**The recovered SUBDATA wire format** (from disassembling the deserializer
state machine at `$F1BD42..$F1BE02` in a live DSP RAM dump, plus the
constants its init left in the alternate bank):
- The DSP `LOADW`s **`$DFFF1A`** (SUBDATA low word) and expects
`(Q_byte << 8) | $10 | seq`: bit 4 = valid, bits 3..0 = byte sequence
0..11 within the 12-byte Q frame. It polls with a ~1 ms cooldown
(40 ISR entries) and dedupes on the sequence tag, so re-reads are free.
- Bytes are CRC'd as they arrive with table-driven **CRC-16/CCITT, poly
`$1021`, init 0** (table at DSP `$F1C400` — verified identical to the
standard table), and after byte 11 the running CRC over ALL 12 bytes must
equal **`$1D0F`** (alt-R10) — the standard residue when the stored CRC is
**inverted** per Red Book.
- A CRC-valid frame's 12 bytes are stored to **`$F1C000 + ADR*16`**
(alt-R09 base; ADR 1 = position frames → `$F1C010`), which is where the
68K reads the on-screen track/time readout — the `0 0:01` display was
this buffer never being written.
- The 68K side arms capture by writing **`$00F2` to SBCNTRL's low word**;
the individual bits remain undocumented.
**Implemented** (`src/cd/cdrom.c` + a lookup helper in `src/cd/cdintf.c`):
a data-only Q serializer clocked off the SSI sample stream — 588 samples
per sector / 12 bytes = one Q byte per 49 samples, so Q position is locked
to the audio actually playing. Reads of SUBDATA+2 return the word above
when armed (nonzero SBCNTRL low-word write) and playing; disarmed, stopped,
seeking, or paused reads fall through to the RAM-backed zeros exactly as
before, and **no subcode interrupt is ever asserted** (BUTCH bits 2/3
untouched, per §8.7's warning). Frames are mode-1/ADR-1, BCD, CONTROL
`$01` for audio tracks / `$41` for data tracks, INDEX 00 with countdown in
pregaps, CRC inverted. No savestate fields: arming re-derives from the
serialized SBCNTRL register on load, everything else resyncs within one
sector because the DSP ignores unexpected sequence numbers.
**Proof, same disc and input script as §8.5, no forced-unmute hack:**
- alt-R13 at the SSI ISR: `$40000000` until Play, then `$01010100`
(audio/ADR1, track 01, index 01) — bit 30 clear. Later `$01020000`
(track 2 pregap) and `$01020100` (track 2 program) as playback crosses
tracks: the mute bit is being driven by live Q data.
- `cd_visual_verify`, 2100 frames: **avg RMS 1267** (oracle: 1268),
VLM-window motion **60/60**, avg frame change **10.4–16.5%** (oracle
9.8–14.8%), non-black up to 26%. The motion dips land exactly on the
disc's two inter-track pregaps — the spectrum collapses during pregap
silence and resumes, i.e. the FFT is chewing on the actual CD audio.
- Screenshots (read, not inferred): full radial spectrum analyser with the
`1-4` preset indicator, and the track/time readout now advancing —
`1 0:03` → `2 0:03` → `3 0:03` across the run (track-relative time, per
Q bytes 3..5). It was `0 0:01` frozen.
**Regression pinning:** `test/test_cd_synth_subq.c` (in `make test`)
re-implements the DSP's consumer contract independently and asserts the
word format, the 49-sample pacing, the `$1D0F` residual, BCD position
content, pregap INDEX 00, advancement, disarmed-silence and stop-mutes.
All six tests have run-verified negative controls (gate without the arm
check, valid bit dropped, CRC not inverted, frame never rebuilt → each
turns exactly the documented tests red at the same rev with `cdrom.o` and
the dylib deleted between builds).
**Still open** (unchanged): §8.7 items 3 and 4 — HLE-mode loader acceptance,
transport polish, and RetroArch (non-headless) verification. On real
hardware subcode also flows during data-track streaming and while the VLM
runs on game-disc session-1 audio; nothing in the boot matrix arms SBCNTRL,
so game discs take byte-identical paths (gate = the matrix run, not this
paragraph).
+2
View File
@@ -62,3 +62,5 @@ _JoystickState*
_MTState*
_NVMBios*
_NVM_*
_JGD*
_jgd*
+35 -1
View File
@@ -2,9 +2,43 @@ LOCAL_PATH := $(call my-dir)/..
CORE_DIR := $(LOCAL_PATH)
# Blitter SIMD arch, picked from the ABI ndk-build is currently building.
# Makefile.common's own detection can't do it here: `platform` and `ARCH`
# are unset under ndk-build and $(CC) isn't a cross-compiler triple it
# recognises, so every Android ABI -- including x86_64 -- silently landed
# on the scalar blitter.
#
# Reset first. With APP_ABI=all, ndk-build re-includes this file once per
# ABI in the same make process, so a stale value from the previous ABI
# would leak (arm64-v8a then riscv64 would try to build the NEON source
# for RISC-V).
#
# Why each mapping is safe:
# arm64-v8a -- NEON is mandatory in ARMv8-A.
# armeabi-v7a -- NDK r21+ builds armeabi-v7a with NEON by default and
# r26 dropped non-NEON ARMv7 entirely; CI pins r26d.
# x86 / x86_64 -- the NDK x86 ABI baseline is SSSE3, which subsumes
# SSE2, so no -msse2 is needed on either.
# Anything else (riscv64, ...) leaves BLITTER_SIMD empty and falls
# through to Makefile.common's scalar default.
BLITTER_SIMD :=
ifeq ($(TARGET_ARCH_ABI),arm64-v8a)
BLITTER_SIMD := neon
else ifeq ($(TARGET_ARCH_ABI),armeabi-v7a)
BLITTER_SIMD := neon
else ifeq ($(TARGET_ARCH_ABI),x86_64)
BLITTER_SIMD := sse2
else ifeq ($(TARGET_ARCH_ABI),x86)
BLITTER_SIMD := sse2
endif
include $(CORE_DIR)/Makefile.common
COREFLAGS := -DINLINE="inline" -D__LIBRETRO__ $(INCFLAGS)
# $(BLITTER_SIMD_DEFINE) is what makes blitter.c inline the same ops the
# arch .c compiles. Without it blitter.c falls back to the scalar inline
# set -- which links fine against the neon/sse2 vtable and just runs
# slower, so nothing would catch its absence at build time.
COREFLAGS := -DINLINE="inline" -D__LIBRETRO__ $(INCFLAGS) $(BLITTER_SIMD_DEFINE)
# libretro.c includes the generated src/core/version.h. Generate it
# at parse time -- ndk-build doesn't go through the project Makefile,
+123 -9
View File
@@ -18,6 +18,7 @@ int64_t rfread(void* buffer, size_t elem_size, size_t elem_count, RFILE* stream)
#include "cheat.h"
#include "crash_detect.h"
#include "crc32.h"
#include "bus_arbiter.h"
#include "file.h"
#include "jagbios.h"
@@ -38,6 +39,7 @@ int64_t rfread(void* buffer, size_t elem_size, size_t elem_count, RFILE* stream)
#include "tom.h"
#include "eeprom.h"
#include "memtrack.h"
#include "jaggd.h"
#include "nvmbios.h"
#include "vjag_memory.h"
#include "state.h"
@@ -559,6 +561,45 @@ static void check_variables(void)
busArbiter.contention_scale = (uint8_t)dram_scale;
}
/* Clock-scale enhancement levers (issue #314). Config, not state:
* never serialized. Stored in percent so 1x is an exact integer
* identity (see jaguar.h). Defaults to 100 whenever the option is
* absent so nothing in the test suite ever runs at non-1x. */
var.key = "virtualjaguar_m68k_clock_scale";
var.value = NULL;
m68kClockScalePct = 100;
if (environ_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &var) && var.value)
{
if (strcmp(var.value, "0.5x") == 0)
m68kClockScalePct = 50;
else if (strcmp(var.value, "1.5x") == 0)
m68kClockScalePct = 150;
else if (strcmp(var.value, "2x") == 0)
m68kClockScalePct = 200;
else if (strcmp(var.value, "3x") == 0)
m68kClockScalePct = 300;
}
/* Drop any carried sub-cycle remainder when the scale (possibly)
* changed, so a new scale starts from a clean accumulator. */
M68KClockScaleReset();
var.key = "virtualjaguar_risc_clock_scale";
var.value = NULL;
riscClockScalePct = 100;
if (environ_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &var) && var.value)
{
if (strcmp(var.value, "0.5x") == 0)
riscClockScalePct = 50;
else if (strcmp(var.value, "1.5x") == 0)
riscClockScalePct = 150;
else if (strcmp(var.value, "2x") == 0)
riscClockScalePct = 200;
}
if (m68kClockScalePct != 100 || riscClockScalePct != 100)
LOG_INF("[CLOCK] Non-stock clock scales active: M68K %u%%, RISC %u%% (enhancement mode; timing-sensitive bug reports are only valid at 1x)\n",
(unsigned)m68kClockScalePct, (unsigned)riscClockScalePct);
var.key = "virtualjaguar_netlink";
var.value = NULL;
if (environ_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &var) && var.value)
@@ -603,6 +644,23 @@ static void check_variables(void)
if (environ_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &var) && var.value)
opt_memory_track = (strcmp(var.value, "disabled") != 0);
/* Jaguar GameDrive: mode is latched here; activation happens at
* content load (JGDLoadROM), so mid-game toggles apply on restart. */
var.key = "virtualjaguar_jgd";
var.value = NULL;
if (environ_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &var) && var.value)
{
if (strcmp(var.value, "enabled") == 0)
JGDSetMode(JGD_MODE_ENABLED);
else if (strcmp(var.value, "disabled") == 0)
JGDSetMode(JGD_MODE_DISABLED);
else
JGDSetMode(JGD_MODE_AUTO);
}
else
JGDSetMode(JGD_MODE_AUTO);
var.key = "virtualjaguar_cd_bios_type";
var.value = NULL;
@@ -991,6 +1049,10 @@ bool retro_serialize(void *data, size_t size)
STATE_SAVE_VAR(buf, busArbiter.op_clk_accum);
STATE_SAVE_VAR(buf, busArbiter.m68k_pending_stall);
/* v8: Jaguar GameDrive chunk (bank pages + SPI engine; all-zero for
* non-GD content). */
buf += JGDStateSave(buf);
written = (size_t)(buf - start);
if (written > STATE_SIZE)
return false;
@@ -1071,6 +1133,13 @@ bool retro_unserialize(const void *data, size_t size)
busArbiter.op_clk_accum = 0;
busArbiter.m68k_pending_stall = 0;
}
if (version >= STATE_VERSION_JAGGD)
buf += JGDStateLoad(buf);
else
/* Pre-v8 states carry no GameDrive chunk: reset mapping (identity
* pages, write protect, idle SPI) — the game re-installs. */
JGDReset();
/* tomRam8 was restored raw above; recompute the DRAM/refresh timing
* that bus_arbiter derives from MEMCON1/MEMCON2 so it matches the
* loaded state (dram_row_miss/rom_clocks/dram_refresh_clks from
@@ -1134,13 +1203,22 @@ static bool has_extension(const char *path, const char *ext)
return strcasecmp(dot + 1, ext) == 0;
}
/* CRC32s of known-good 256 KB CD BIOS dumps. The developer BIOS has
* $FFFFFFFF at the $404 run-address slot, so it can only be recognized
* by checksum -- a header check alone always rejects it. */
#define CD_BIOS_CRC_RETAIL 0x687068D5u
#define CD_BIOS_CRC_DEV 0x55A0669Cu
/* Try to load a 256 KB CD BIOS image from the given path.
* Returns true on success and sets cd_bios_loaded_externally. */
static bool try_load_cd_bios_file(const char *path)
* Returns true on success and sets cd_bios_loaded_externally.
* A file that exists but fails validation bumps *rejected so the
* caller's final warning can distinguish "no file" from "bad file". */
static bool try_load_cd_bios_file(const char *path, int *rejected)
{
RFILE *f;
int64_t size;
uint32_t run_addr;
uint32_t crc;
f = rfopen(path, "rb");
if (!f)
@@ -1155,16 +1233,38 @@ static bool try_load_cd_bios_file(const char *path)
LOG_DBG("[CD-BIOS] wrong size (%lld, need 262144): %s\n",
(long long)size, path);
rfclose(f);
(*rejected)++;
return false;
}
if (rfread(external_cd_bios, 1, 0x40000, f) != 0x40000)
{
LOG_DBG("[CD-BIOS] short read (need 262144): %s\n", path);
rfclose(f);
(*rejected)++;
return false;
}
rfclose(f);
/* Known dumps are accepted by checksum, no header check needed. */
crc = (uint32_t)crc32_calcCheckSum(external_cd_bios, 0x40000);
if (crc == CD_BIOS_CRC_RETAIL)
{
LOG_INF("[CD-BIOS] using external %s (recognized retail CD BIOS, crc32=%08X)\n",
path, (unsigned)crc);
cd_bios_loaded_externally = true;
return true;
}
if (crc == CD_BIOS_CRC_DEV)
{
LOG_INF("[CD-BIOS] using external %s (recognized developer CD BIOS, crc32=%08X)\n",
path, (unsigned)crc);
cd_bios_loaded_externally = true;
return true;
}
/* Unknown checksum: fall back to the header sanity check so genuine
* revisions/regions we don't have CRCs for still load. */
run_addr = ((uint32_t)external_cd_bios[0x404] << 24)
| ((uint32_t)external_cd_bios[0x405] << 16)
| ((uint32_t)external_cd_bios[0x406] << 8)
@@ -1172,11 +1272,17 @@ static bool try_load_cd_bios_file(const char *path)
if (run_addr < 0x800000 || run_addr > 0x840000)
{
LOG_DBG("[CD-BIOS] bad run addr $%08X: %s\n",
(unsigned)run_addr, path);
LOG_DBG("[CD-BIOS] bad run addr $%08X (crc32=%08X): %s\n",
(unsigned)run_addr, (unsigned)crc, path);
(*rejected)++;
return false;
}
LOG_WRN("[CD-BIOS] %s is an unrecognized CD BIOS revision "
"(crc32=%08X, plausible run=$%06X) -- accepting it, but if boot "
"black-screens right after this line, this file is the prime "
"suspect: verify it is a genuine Jaguar CD BIOS dump\n",
path, (unsigned)crc, (unsigned)run_addr);
LOG_INF("[CD-BIOS] using external %s (run=$%06X)\n",
path, (unsigned)run_addr);
cd_bios_loaded_externally = true;
@@ -1188,9 +1294,11 @@ static bool try_load_cd_bios_file(const char *path)
static bool load_external_cd_bios(void)
{
/* Filenames conventionally used for each 'CD BIOS Type', plus generic
* names that could hold either image. Selection is by NAME only — the
* loader does not inspect the image to confirm which BIOS it actually
* is, so a mislabelled file is taken at its filename's word.
* names that could hold either image. The name drives the SEARCH ORDER
* only; the contents are validated by try_load_cd_bios_file(), which
* checksums the image and logs which revision it actually is. So a
* mislabelled file is still loaded (its name only decided when it was
* tried), but the log names the real revision rather than the label.
*
* The selected type's names are searched FIRST. The previous code used
* one flat list with the retail names ahead of the developer ones, so a
@@ -1228,6 +1336,7 @@ static bool load_external_cd_bios(void)
};
const char *system_dir = NULL;
int s, i, g;
int rejected = 0;
if (!environ_cb(RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY, &system_dir)
|| !system_dir)
@@ -1268,13 +1377,18 @@ static bool load_external_cd_bios(void)
snprintf(path, sizeof(path), "%s/%s",
system_dir, name_groups[g][i]);
if (try_load_cd_bios_file(path))
if (try_load_cd_bios_file(path, &rejected))
return true;
}
}
}
LOG_WRN("[CD-BIOS] CD BIOS not found in %s\n", system_dir);
if (rejected > 0)
LOG_WRN("[CD-BIOS] no usable CD BIOS in %s: %d candidate file(s) "
"rejected (size or header); using embedded BIOS\n",
system_dir, rejected);
else
LOG_WRN("[CD-BIOS] CD BIOS not found in %s\n", system_dir);
return false;
}
+48
View File
@@ -169,6 +169,39 @@ struct retro_core_option_v2_definition option_defs_us[] = {
},
"disabled"
},
{
"virtualjaguar_m68k_clock_scale",
"M68K Clock Scale (Overclock)",
NULL,
"Run the 68000 CPU at a multiple of its stock ~13.3 MHz. An enhancement lever, not an accuracy fix: overclocking can smooth framerate-limited games (Doom, AvP, Checkered Flag) but may break titles that rely on stock CPU timing, and underclocking is for experimentation only. Bus/DRAM costs and all timers stay at stock speed. Leave at 1x unless a specific game benefits; bug reports are only valid at 1x.",
NULL,
"timing",
{
{ "0.5x", NULL },
{ "1x", "1x (stock)" },
{ "1.5x", NULL },
{ "2x", NULL },
{ "3x", NULL },
{ NULL, NULL },
},
"1x"
},
{
"virtualjaguar_risc_clock_scale",
"RISC (GPU/DSP) Clock Scale (Overclock)",
NULL,
"Run the GPU and DSP RISC processors at a multiple of their stock ~26.6 MHz. An enhancement lever, not an accuracy fix: extra RISC cycles can lift GPU-bound framerates. Audio sample pacing (I2S/DAC) and all timers stay at stock speed, so audio does not pitch-shift -- the DSP simply gets more compute per sample. May break titles that rely on stock RISC timing. Leave at 1x unless a specific game benefits; bug reports are only valid at 1x.",
NULL,
"timing",
{
{ "0.5x", NULL },
{ "1x", "1x (stock)" },
{ "1.5x", NULL },
{ "2x", NULL },
{ NULL, NULL },
},
"1x"
},
{
"virtualjaguar_netlink",
"Network Link (JagLink / CatBox)",
@@ -258,6 +291,21 @@ struct retro_core_option_v2_definition option_defs_us[] = {
},
"disabled"
},
{
"virtualjaguar_jgd",
"Jaguar GameDrive (Restart)",
NULL,
"Emulate the Jaguar GameDrive (JagGD) flash cartridge: its detection/install interface and 1 MB bank switching of up to 16 MB of cart SDRAM. 'Auto' turns it on only for ROM images larger than the 6 MB cartridge window. 'Enabled' forces it on for smaller images too, for GD-locked homebrew that refuses to boot without the cart (equivalent to BigPEmu's Force JGD). Without it, GD-locked titles hang at boot exactly as on a stock console.",
NULL,
"bios_boot",
{
{ "auto", "Auto (images over 6 MB)" },
{ "disabled", NULL },
{ "enabled", "Enabled (force, for GD-locked images)" },
{ NULL, NULL },
},
"auto"
},
{
"virtualjaguar_pal",
"PAL (Restart)",
+2
View File
@@ -65,5 +65,7 @@
MTState*;
NVMBios*;
NVM_*;
JGD*;
jgd*;
local: *;
};
+79
View File
@@ -23,11 +23,17 @@ skip_file() {
case "$1" in
src/m68000/cpu*.c|src/m68000/read*.c) return 0 ;;
src/bios/jag*bios*.c) return 0 ;;
# Skipped only by the default pass, which has no BLITTER_SIMD_<ARCH>
# define and so cannot compile them (blitter_simd.h would inline the
# scalar ops and the arch header would redefine every one). They are
# checked, with the right define and target, by check_simd_arch below.
src/tom/blitter_simd_neon.c|src/tom/blitter_simd_sse2.c) return 0 ;;
# Depends on rcheevos headers fetched at runtime by the e2e shell wrapper.
test/tools/test_rcheevos_e2e.c) return 0 ;;
# Diagnostic tools — not part of the libretro core build.
test/tools/flicker_detect.c) return 0 ;;
# Builds against test/harness/, which is outside $INCLUDES; C99 harness.
test/tools/fmv_seek_probe.c) return 0 ;;
esac
return 1
}
@@ -40,9 +46,12 @@ else
FILES="libretro.c $(find src -name '*.c')"
fi
CHECK_SIMD=0
for f in $FILES; do
[ -f "$f" ] || continue
case "$f" in *.c) ;; *) continue ;; esac
case "$f" in src/tom/blitter.c|src/tom/blitter_simd_*.c) CHECK_SIMD=1 ;; esac
if skip_file "$f"; then continue; fi
if ! $CC $CFLAGS $INCLUDES $DEFINES "$f" 2>&1; then
@@ -50,6 +59,76 @@ for f in $FILES; do
fi
done
# --------------------------------------------------------------------
# Per-arch SIMD pass.
#
# blitter.c includes blitter_simd.h, which inlines whichever
# blitter_simd_<arch>.h the build selected. The loop above runs with no
# BLITTER_SIMD_<ARCH> define, so it only ever sees the scalar header --
# yet the buildbot compiles the sse2 one inside blitter.c on every MSVC
# and x86 target, and the neon one on every ARM target. A C99-ism in
# either arch header would sail past the lint and break there instead.
#
# Re-check blitter.c (and the arch's own .c) once per arch, cross-
# targeting when the host can't do it natively. A host that can't
# cross-target says so out loud rather than passing silently.
check_simd_arch() {
arch="$1" # sse2 | neon
define="$2" # BLITTER_SIMD_SSE2 | BLITTER_SIMD_NEON
target="$3" # extra flags, may be empty
probe="$4" # intrinsics header to probe for
cc="$CC"
case "$target" in
*--target=*) command -v clang >/dev/null 2>&1 && cc=clang ;;
esac
probe_c="${TMPDIR:-/tmp}/c89lint_probe_$$.c"
echo "#include <$probe>" > "$probe_c"
echo "int main(void) { return 0; }" >> "$probe_c"
if ! $cc $CFLAGS $target "$probe_c" >/dev/null 2>&1; then
rm -f "$probe_c"
echo "C89 lint: SKIP $arch — $cc cannot target it here ($probe unavailable)"
echo "C89 lint: the $arch header is NOT checked on this host; CI covers it."
return 0
fi
rm -f "$probe_c"
arch_failed=0
for f in src/tom/blitter.c "src/tom/blitter_simd_$arch.c"; do
[ -f "$f" ] || continue
if ! $cc $CFLAGS $target $INCLUDES $DEFINES "-D$define" "$f" 2>&1; then
echo "C89 lint: $arch pass failed on $f"
arch_failed=1
FAILED=1
fi
done
[ "$arch_failed" = "0" ] && echo "C89 lint: $arch pass OK"
return 0
}
# Only worth running when blitter.c is actually in scope.
case "$CHECK_SIMD" in
1)
case "$(uname -m)" in
x86_64|amd64|i686|i386) SSE2_TARGET="-msse2" ;;
*) case "$(uname -s)" in
Darwin) SSE2_TARGET="--target=x86_64-apple-macos11 -msse2" ;;
*) SSE2_TARGET="--target=x86_64-linux-gnu -msse2" ;;
esac ;;
esac
case "$(uname -m)" in
aarch64|arm64) NEON_TARGET="" ;;
*) case "$(uname -s)" in
Darwin) NEON_TARGET="--target=arm64-apple-macos11" ;;
*) NEON_TARGET="--target=aarch64-linux-gnu" ;;
esac ;;
esac
check_simd_arch sse2 BLITTER_SIMD_SSE2 "$SSE2_TARGET" emmintrin.h
check_simd_arch neon BLITTER_SIMD_NEON "$NEON_TARGET" arm_neon.h
;;
esac
if [ "$FAILED" = "1" ]; then
echo "C89 lint FAILED — fix mid-block declarations"
exit 1
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# find-rom.sh -- locate a private test ROM / disc image by name pattern.
#
# The private corpus (test/roms/private, a symlink to a tree outside every
# git checkout) uses inconsistent naming: "Iron Soldier 2 (World).j64",
# "Iron Soldier (World) (v1.04).j64", "Atari Karts (1995).jag", and disc
# images nest as "<Title>/<Title>/*.cue". Hardcoding one exact spelling in
# the Makefile turns a check into a silent SKIP the moment the corpus is
# laid out differently -- which is exactly how the Skyhammer clipping
# sentinel went inert (it looked for "Skyhammer_(1999).jag").
#
# Usage: find-rom.sh PATTERN [PATTERN...]
#
# Patterns are shell globs matched case-insensitively against the FILE NAME
# only (not the path). Patterns are tried in order and the first one that
# matches anything wins, so callers pass the exact expected spelling first
# and looser fallbacks after.
#
# Two filters keep the result honest:
# * Only real loadable media are considered. Without this, a bare
# "Atari Karts*" matches "7zips/Atari Karts (1995).7z" and the harness is
# handed an archive it cannot load -- a check that appears to run but
# tests nothing.
# * Matches are ranked by path DEPTH first, so the canonical top-level copy
# beats a duplicate buried in "ROMS/Atari Jaguar Rom Collection/ROMS/".
# Depth ties break by name, so the answer is deterministic across runs.
#
# Prints the path of the single best match on stdout and exits 0.
# Prints nothing and exits 1 when no pattern matches -- callers test for an
# empty result and record a skip.
#
# Honours ROMS_PRIVATE_ROOT for callers that keep the corpus elsewhere.
set -u
root="${ROMS_PRIVATE_ROOT:-test/roms/private}"
if [ "$#" -eq 0 ]; then
echo "usage: find-rom.sh PATTERN [PATTERN...]" >&2
exit 2
fi
# -L: test/roms/private is a SYMLINK; find does not follow it otherwise and
# would report the corpus as empty. A missing corpus is not an error here --
# it is the normal case in CI, which has none of the private ROMs.
[ -e "$root" ] || exit 1
# Loadable media only -- never archives (.zip/.7z) or stray sidecar files.
is_loadable() {
case "$(printf '%s' "$1" | tr 'A-Z' 'a-z')" in
*.jag|*.j64|*.rom|*.bin|*.abs|*.cof|*.cue|*.cdi|*.chd) return 0 ;;
*) return 1 ;;
esac
}
for pat in "$@"; do
match=$(
find -L "$root" -type f -iname "$pat" 2>/dev/null |
while IFS= read -r f; do
is_loadable "$f" || continue
# depth = number of path separators; shallower sorts first.
depth=$(printf '%s' "$f" | tr -cd '/' | wc -c | tr -d ' ')
printf '%s\t%s\n' "$depth" "$f"
done |
sort -t"$(printf '\t')" -k1,1n -k2,2 |
head -1 |
cut -f2-
)
if [ -n "$match" ]; then
printf '%s\n' "$match"
exit 0
fi
done
exit 1
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# test-skip.sh -- skip ledger for `make test`.
#
# WHY THIS EXISTS
# ---------------
# Several checks in the suite only run when an optional private ROM / disc
# image is present. Historically each such site just printed "SKIP: ..." and
# fell through, so the suite still exited 0 -- a skip was indistinguishable
# from a pass. That let the Skyhammer clipping sentinel sit inert for an
# unknown number of releases: the Makefile looked for a filename that does not
# exist in the corpus, printed one SKIP line among hundreds of lines of test
# output, and reported success.
#
# This is the same masked-failure class the repo was burned by in PR #170,
# where a "fix" silenced Iron Soldier 2 and the clipping test passed because
# silence has 0% saturation. A skip that reads as a pass is that bug wearing
# a different hat.
#
# The ledger makes skips additive and visible: every skip site records one
# line, and the suite prints a consolidated summary at the end. CI has none
# of the private ROMs, so skipping stays non-fatal by default -- but it is no
# longer silent, and `VJ_REQUIRE_ROMS=1` turns it into a hard failure for
# anyone who does have the corpus and wants the full suite enforced.
#
# Usage:
# test-skip.sh reset truncate the ledger (call FIRST)
# test-skip.sh record "<check>" "<why>" print + append one skip
# test-skip.sh summary print the roll-up; exit 1 under
# VJ_REQUIRE_ROMS=1 if any skips
#
# Ledger path defaults to test/.skipped-checks; override with VJ_SKIP_LOG.
set -u
log="${VJ_SKIP_LOG:-test/.skipped-checks}"
mode="${1:-}"
case "$mode" in
reset)
# Truncate at the START of the run, never at the end. A ledger left over
# from a previous invocation would resurrect old rows as fresh skips --
# the same stale-row failure mode documented for cd_boot_matrix.sh, where
# a resumed sweep replayed an ancient Battle Morph result as a new one.
mkdir -p "$(dirname "$log")" 2>/dev/null
: > "$log"
;;
record)
check="${2:-unnamed check}"
reason="${3:-no reason given}"
# Print inline too, so the skip is visible in-context as well as in the
# roll-up. Both come from this one call, so they cannot drift apart.
printf ' SKIP: %s -- %s\n' "$check" "$reason"
mkdir -p "$(dirname "$log")" 2>/dev/null
printf '%s\t%s\n' "$check" "$reason" >> "$log"
;;
summary)
echo ""
if [ ! -s "$log" ]; then
echo "=== Skipped checks: none -- every optional check ran ==="
exit 0
fi
count=$(wc -l < "$log" | tr -d ' ')
echo "=== Skipped checks ($count) ==="
# Two columns: check name, then why. awk (not column) so the output is
# identical on macOS and Linux.
awk -F'\t' '{ printf " %-42s %s\n", $1, $2 }' "$log"
echo ""
echo " These checks did NOT run. They are not failures, but they are"
echo " not passes either -- most need optional private ROMs/discs that"
echo " CI does not have. Re-run with VJ_REQUIRE_ROMS=1 to make any"
echo " skip a hard failure once you have the corpus in place."
if [ "${VJ_REQUIRE_ROMS:-0}" = "1" ]; then
echo ""
echo "ERROR: VJ_REQUIRE_ROMS=1 and $count check(s) were skipped." >&2
exit 1
fi
;;
*)
echo "usage: test-skip.sh {reset|record <check> <reason>|summary}" >&2
exit 2
;;
esac
+114 -9
View File
@@ -1173,6 +1173,61 @@ bool CDIntfIsSession2Sector(uint32_t sector)
return false;
}
/* Q-channel subcode position lookup — see cdintf.h. Track containment
* uses the same [startLBA, startLBA + lengthLBA) rule as
* CDIntfReadBlock() so the Q data always describes the sector actually
* being streamed. */
bool CDIntfGetQPosition(uint32_t lba, uint32_t *trackNum, uint32_t *idx,
uint32_t *relLBA, bool *isData)
{
int i;
struct CDIntfTrack *track = NULL;
if (!disc.loaded)
return false;
for (i = (int)disc.numTracks - 1; i >= 0; i--)
{
uint32_t tStart = disc.tracks[i].startLBA;
uint32_t tEnd = tStart + disc.tracks[i].lengthLBA;
if (lba >= tStart && lba < tEnd)
{
track = &disc.tracks[i];
break;
}
}
if (!track)
return false;
if (trackNum)
*trackNum = track->number;
/* Q CONTROL bit 2 (data track). track->type alone is not enough:
* Jaguar CD CUE sheets routinely mark the session-2 DATA track as
* AUDIO (the game data is mastered inside an audio-type track), so
* trusting the type would report a data track as audio -- and that
* is exactly the bit the CD player's VLM uses as its mute gate.
* Treat anything in session 2 as data regardless of declared type,
* matching CDIntfIsSession2Sector's reasoning. */
if (isData)
*isData = (track->type != CDINTF_TRACK_AUDIO) || (track->session == 2);
if (lba < track->dataLBA)
{
/* INDEX 00 pregap: relative time counts down to 0 at INDEX 01. */
if (idx)
*idx = 0;
if (relLBA)
*relLBA = track->dataLBA - lba;
}
else
{
if (idx)
*idx = 1;
if (relLBA)
*relLBA = lba - track->dataLBA;
}
return true;
}
// Returns session info for use by cdrom.c
// Session numbering matches the DSA command operand (per MiSTer FPGA):
// Session 0 → disc.sessions[0] (first session, typically audio)
@@ -1428,19 +1483,69 @@ bool CDIntfExtractBootStub(uint8_t *outBuf, uint32_t outBufSize,
swapped[i + 1] = raw[i];
}
LOG_DBG("[CD-BOOTSTUB] Raw bytes 0x40-0x6F (pre-swap): ");
for (i = 0x40; i < 0x70 && i < (uint32_t)bytesRead; i++)
LOG_DBG("%02X ", raw[i]);
LOG_DBG("\n");
LOG_DBG("[CD-BOOTSTUB] Swapped bytes 0x40-0x6F: ");
for (i = 0x40; i < 0x70 && i < (uint32_t)bytesRead; i++)
LOG_DBG("%02X ", swapped[i]);
LOG_DBG("\n");
/* One LOG_DBG per byte produced 48 separate log lines: the frontend's
* log callback prefixes and newline-terminates every call, so a
* partial-line idiom does not concatenate. Build each dump with
* fixed indexing (no sprintf in a loop: the bounds are then obvious
* by construction and no format string is involved) and emit it as a
* single line. */
{
static const char hexd[] = "0123456789ABCDEF";
char rawhex[0x30 * 3 + 1];
char swphex[0x30 * 3 + 1];
uint32_t last = (0x70 < (uint32_t)bytesRead) ? 0x70 : (uint32_t)bytesRead;
uint32_t n = 0;
for (i = 0x40; i < last; i++, n += 3)
{
rawhex[n] = hexd[(raw[i] >> 4) & 0x0F];
rawhex[n + 1] = hexd[raw[i] & 0x0F];
rawhex[n + 2] = ' ';
swphex[n] = hexd[(swapped[i] >> 4) & 0x0F];
swphex[n + 1] = hexd[swapped[i] & 0x0F];
swphex[n + 2] = ' ';
}
rawhex[n] = '\0';
swphex[n] = '\0';
LOG_DBG("[CD-BOOTSTUB] Raw bytes 0x40-0x%02X (pre-swap): %s\n",
(unsigned)(last ? last - 1 : 0), rawhex);
LOG_DBG("[CD-BOOTSTUB] Swapped bytes 0x40-0x%02X: %s\n",
(unsigned)(last ? last - 1 : 0), swphex);
}
LOG_DBG("[CD-BOOTSTUB] Swapped as text: '%.32s'\n", swapped + 0x42);
if (memcmp(swapped + 0x42, MAGIC, sizeof(MAGIC)) != 0)
{
LOG_ERR("[CD-BOOTSTUB] Magic mismatch at +0x42 of session-2 track BIN\n");
uint32_t matched;
uint32_t all_zero;
uint32_t j;
matched = 0;
all_zero = 1;
for (j = 0; j < (uint32_t)sizeof(MAGIC); j++)
{
if (swapped[0x42 + j] == MAGIC[j])
matched++;
if (swapped[0x42 + j] != 0)
all_zero = 0;
}
if (all_zero)
{
/* Bad CDI V2 rips: boot header region is zeros in the file itself.
* No offset fix recovers absent data - refuse with an actionable
* message so users stop re-filing this as an unsupported format. */
LOG_ERR("[CD-BOOTSTUB] Boot header region is zero-filled at +0x42 - "
"this image is an incomplete / bad rip, not an unsupported "
"format\n");
}
else
{
LOG_ERR("[CD-BOOTSTUB] Magic mismatch at +0x42 of session-2 track BIN "
"(matched %u/%u bytes)\n",
(unsigned)matched, (unsigned)sizeof(MAGIC));
}
return false;
}
+10
View File
@@ -77,6 +77,16 @@ uint8_t CDIntfGetTrackSession(uint32_t track);
// (Jaguar CD game data is in session 2; session 1 is audio)
bool CDIntfIsSession2Sector(uint32_t sector);
/* Q-channel subcode position lookup (VLM / audio-CD, issue #291): for a
* disc-image LBA, return the containing track's number, the Red Book
* index (0 = INDEX 00 pregap, 1 = program area), the track-relative LBA
* (distance to/from INDEX 01: counts DOWN to the index in the pregap,
* UP from it in the program area) and whether the track is a data track
* (Q CONTROL bit 2). Returns false when the LBA falls in no track
* (inter-session gap / past the last track). */
bool CDIntfGetQPosition(uint32_t lba, uint32_t *trackNum, uint32_t *idx,
uint32_t *relLBA, bool *isData);
// True if the most recent CDIntfReadBlock() landed in an inter-session gap
// (typically the BIOS's pregap authentication read). Consumed by cdrom.c
// to instrument the auth-fail STOP path and identify the BIOS's auth branch.
+335 -39
View File
@@ -39,13 +39,16 @@
#endif
// Timing constants for seek and FIFO simulation (in half-line ticks, ~31.8μs each)
// Per MiSTer FPGA: seek has a multi-tier delay (30-315ms), FIFO fills at I2S rate.
// These values are shortened for software emulation but preserve the required ordering:
// SEEK_DELAY_TICKS is a fixed ORDERING delay only; the actual seek-time
// magnitude is the distance-dependent term below (CDROMSeekDistanceTicks,
// calibrated to a reference capture -- see that block). FIFO fills at I2S
// rate. The ordering this fixed delay preserves:
// seek response MUST arrive via interrupt AFTER DSA_tx returns, and FIFO MUST NOT
// be ready during the DSARX phase (or the 68K handler sends STOP).
// The BIOS polls BUTCH+2 once after $12xx (no response expected yet), then sends
// STOP. On real hardware the seek continues internally despite STOP — the drive
// completes the seek and queues the $0100 response 30-300ms later. The BIOS's
// completes the seek and queues the $0100 response once the sled arrives
// (distance-dependent; see CDROMSeekDistanceTicks below). The BIOS's
// main loop (or DSP) detects the seek completion and initiates data transfer.
// STOP must NOT cancel the seek delay. Value chosen to be short enough to complete
// within a few frames but long enough to occur AFTER the BIOS's single poll.
@@ -53,6 +56,42 @@
#define FIFO_FILL_TICKS 8 // ~254μs before FIFO half-full after play starts
#define FIFO_DRAIN_READS 16 // 16 word-reads = 8 GPU longword loads = 32 bytes
/* Distance-dependent seek term (reference parity, #297).
*
* On top of the fixed SEEK_DELAY_TICKS ordering delay above, a real seek
* costs time proportional to how far the sled travels. The old "30-315ms
* multi-tier" comment was unsourced and its top end is REFUTED by
* measurement: a BigPEmu reference capture of Dragon's Lair's death branch
* (2026-08-05, issue #297) shows 558 ms ~= 33.4 fields of black for a
* near-full-stroke seek where we showed ~30 fields — i.e. the reference
* charges roughly +60 ms on ~138k sectors, tens of ms, not hundreds.
*
* Model: linear in sector distance, one extra halfline tick (31.78 us)
* per SEEK_SECTORS_PER_TICK sectors. Calibration: the measured branch
* seek travels 137,936 sectors (head LBA 16125 -> 154061); 137,936 / 72
* = 1,915 ticks = 60.9 ms = 3.65 fields, moving our 30-field gap to
* ~33.7 — matching the 33.4-field reference within a field. Short
* streaming seeks (339-428 sectors) add 4-5 ticks (~0.15 ms): nothing.
* Boot seeks (15k-23k sectors from the parked head at LBA 0) add 7-10 ms,
* which real drives also pay. No tiers — there is no data for tiers.
*
* This is REFERENCE PARITY against another emulator, not silicon ground
* truth: no hardware capture of Jaguar CD seek time exists yet (the JTRM
* CD-ROM manual gives landing tolerance, not access time). See
* docs/fmv-drift-notes.md §11.
*
* Deliberately NOT applied to the redundant-seek path ($12xx to the block
* already playing): that path must not disturb the in-flight stream
* (#306/#307), and a no-op Goto moves no sled. */
#define SEEK_SECTORS_PER_TICK 72
uint32_t CDROMSeekDistanceTicks(uint32_t fromLBA, uint32_t toLBA)
{
uint32_t dist = (toLBA >= fromLBA) ? (toLBA - fromLBA)
: (fromLBA - toLBA);
return dist / SEEK_SECTORS_PER_TICK;
}
/* Refill pacing: the real drive streams data at double-speed CD-DA rate,
* 150 sectors/s x 2352 bytes = 352,800 B/s (= the 2x I2S rate). One
* half-full batch (8 x 32-bit FIFO entries = 32 bytes) therefore arrives
@@ -300,6 +339,47 @@ static uint8_t ssiBuf[2352 + 96];
static uint32_t ssiBufPtr = 2352;
static uint32_t ssiBlock = 0;
/* --- Q-channel subcode serializer (VLM / audio-CD, issue #291) ---------
*
* BUTCH deserializes the disc's Q subcode channel and presents it one
* byte at a time through SUBDATA. The consumer contract was recovered
* at runtime from the VLM's own DSP handler ($F1BD42..$F1BE02, retail
* CD BIOS, docs/vlm-audio-cd-plan.md §8): the DSP polls the low word of
* SUBDATA ($DFFF1A, LOADW) every ~1 ms and expects
*
* bits 15..8 the current Q-channel byte
* bit 4 "valid" flag
* bits 3..0 byte sequence number 0..11 within the Q frame
*
* i.e. word = (qbyte << 8) | $10 | seq. It assembles the 12 bytes of a
* frame in sequence order, runs CRC-16/CCITT (poly $1021, table at DSP
* $F1C400) across ALL 12 and requires the residual $1D0F — the standard
* residue of a Red Book Q frame whose stored CRC is inverted. On a
* CRC-valid frame it copies the packed Q data to $F1C000+ADR*16 (the
* 68K reads track/time for the on-screen readout from there) and loads
* the first Q word into alternate-bank R13, whose bit 30 — Q CONTROL
* bit 2, the "data track" flag — gates the VLM's audio pass-through and
* FFT input. Audio control nibble -> bit 30 clear -> unmuted; the $40000000
* the VLM boots with means "assume data track, stay muted".
*
* The serializer is clocked off the SSI sample stream: 588 stereo
* samples per sector / 12 Q bytes = one byte every 49 samples, so Q
* position stays exactly in step with the audio actually playing. Data
* only — no BUTCH bit 2/3 (subcode frame / time-match) interrupt is
* ever raised, and reads while disarmed/stopped fall through to the old
* RAM-backed zeros.
*
* None of this state is serialized: subQArmed is re-derived from the
* saved SBCNTRL register in CDROMStateLoad(), and the rest resyncs
* within one sector (~13 ms) because the DSP's collector simply ignores
* sequence numbers it is not expecting. */
#define SUBQ_SAMPLES_PER_BYTE 49
static uint8_t subQFrame[12];
static uint32_t subQSeq = 0; /* current byte within the frame, 0..11 */
static uint32_t subQSampleCnt = 0; /* SSI samples since last byte boundary */
static bool subQArmed = false; /* nonzero SBCNTRL low-word write seen */
static bool subQValid = false; /* subQFrame describes a real position */
// NM93C14 EEPROM: 64 x 16-bit words (128 bytes)
// Exposed so libretro.c can pack/unpack it into the .srm save buffer.
uint16_t cdrom_eeprom_ram[64];
@@ -800,6 +880,10 @@ void CDROMReset(void)
fifoRefillAccum = 0;
cdDriveSpeed = CD_SPEED_DOUBLE; /* power-on default, see the constant */
cdPrevShouldIRQ = false;
subQSeq = 0;
subQSampleCnt = 0;
subQArmed = false;
subQValid = false;
dsaQueueHead = 0;
dsaQueueTail = 0;
dsaQueueCount = 0;
@@ -957,7 +1041,7 @@ void BUTCHExec(uint32_t cycles)
if (dsaResponseDelay > 0)
{
dsaResponseDelay--;
if (dsaResponseDelay == 0 && dsaQueueCount > 0)
if (dsaResponseDelay == 0 && (dsaQueueCount > 0 || isMultiWordResponse))
dsaResponseReady = true;
}
@@ -1148,8 +1232,15 @@ uint16_t CDROMReadWord(uint32_t offset, uint32_t who/*=UNKNOWN*/)
* after another serial-word delay: the ack clears the interrupt
* latch, but the drive MCU still has data to hand over, and on real
* hardware bit 13 (rec buffer full) rises again when the next word
* lands in the receive buffer. */
if (dsaQueueCount > 0 && dsaResponseDelay <= 0)
* lands in the receive buffer.
*
* isMultiWordResponse ($03nn / $14nn TOC reads) needs the same
* re-arm: those words are synthesized on demand from the disc layout
* rather than pushed onto dsaQueue, so the queue is empty and the
* ack would otherwise leave bit 13 low forever after word 1 -- the
* audio-CD stall, with the BIOS re-reading BUTCH+2 forever while the
* 68K cycles a tight loop at $050464-$050484. */
if ((dsaQueueCount > 0 || isMultiWordResponse) && dsaResponseDelay <= 0)
dsaResponseDelay = DSA_RESPONSE_DELAY_TICKS;
}
else if (offset == I2CNTRL || offset == I2CNTRL + 2)
@@ -1228,12 +1319,22 @@ TOC: 2 10 00 a 00:00:00 00 49:50:06 <-- Track #10
TOC: 2 10 00 b 00:00:00 00 54:26:17 <-- Track #11
*/
/* $0300 short TOC: BIOS polls DS_DATA for $03xx responses
* (echoes the command prefix). Each of the 5 response words has
* high byte $03 and low byte = session info value. The BIOS
* checks bit 0 of each word: if set, more data follows; if clear,
* TOC transfer is complete. After all 5 data words, return $0300
* as end-of-data marker (bit 0 clear). */
/* $03nn Read session TOC: five data words, each TAGGED with its own
* response opcode in the high byte -- the drive does NOT echo the
* $03 command prefix (MiSTer butch.v DSA table, mirrored in
* test/mister_ground_truth.h):
*
* $20nn TOC_MIN_TRK first track of the session
* $21nn TOC_MAX_TRK last track of the session
* $22nn TOC_LO_MIN lead-out absolute minutes
* $23nn TOC_LO_SEC lead-out absolute seconds
* $24nn TOC_LO_FRM lead-out absolute frames
*
* The sixth read yields the $0400 end-of-response terminator, the
* same word every other multi-word DSA response ends on. We used
* to answer $03nn for all five and never terminate; the CD BIOS
* discarded the lot and spun forever in its DSA read loop, which
* is why an audio disc never reached the CD player / VLM. */
if (cdPtr < 5)
{
data = CDIntfGetSessionInfo(cdCmd & 0xFF, cdPtr);
@@ -1243,13 +1344,13 @@ TOC: 2 10 00 b 00:00:00 00 54:26:17 <-- Track #11
data = 0x0400;
else
{
data = 0x0300 | (data & 0xFF);
data = (uint16_t)(((0x20 + cdPtr) << 8) | (data & 0xFF));
cdPtr++;
}
}
else
{
data = 0x0300; /* end-of-data: high byte $03, bit 0 clear */
data = 0x0400; /* end-of-response terminator */
}
}
// Seek: only $12xx (Goto Frame) generates a response ($0100 = Found).
@@ -1361,11 +1462,9 @@ TOC: 2 10 00 b 00:00:00 00 54:26:17 <-- Track #11
dsaResponseReady = false;
isMultiWordResponse = false;
}
else if ((cdCmd & 0xFF00) == 0x0300 && cdPtr >= 5)
{
dsaResponseReady = false; // Session TOC: 5 data words delivered
isMultiWordResponse = false;
}
/* Session TOC ($03nn) needs no explicit end condition: after the five
* $20..$24 data words the next read yields the $0400 terminator, which
* the branch above clears on. */
else if ((cdCmd & 0xFF00) == 0x1400 && trackNum > maxTrack)
{
dsaResponseReady = false; // Full TOC: all tracks delivered
@@ -1465,10 +1564,24 @@ TOC: 2 10 00 b 00:00:00 00 54:26:17 <-- Track #11
}
}
}
else if (offset == SUBDATA + 2 && subQArmed && subQValid &&
cdPlaying && seekDelay <= 0)
{
/* Q-subcode serial window (see the serializer comment block near
* subQFrame): current Q byte in the high byte, bit 4 = valid,
* low nibble = byte sequence 0..11. Reads never pop -- the
* consumer dedupes on the sequence tag, matching a latch the
* hardware refreshes as each byte deserializes. Disarmed /
* stopped / seeking reads keep falling through to the RAM-backed
* zeros below, exactly the pre-#291 behavior. */
data = (uint16_t)(((uint16_t)subQFrame[subQSeq] << 8) |
0x0010 | (subQSeq & 0x0F));
}
else
data = GET16(cdRam, offset);
/* SUBCODE-DIAG: reads of the (unimplemented) subcode registers. */
/* SUBCODE-DIAG: subcode register reads (SUBDATA low word is live when
* the serializer is armed and playing; everything else is RAM-backed). */
if (offset >= SBCNTRL && offset < SB_TIME + 4)
{
static uint32_t subReads = 0;
@@ -1515,6 +1628,18 @@ void CDROMWriteByte(uint32_t offset, uint8_t data, uint32_t who/*=UNKNOWN*/)
void CDROMWriteWord(uint32_t offset, uint16_t data, uint32_t who/*=UNKNOWN*/)
{
/* $12xx (Goto Frame) redundancy decision. A redundant Goto is handled in
* two places below -- the response block queues Found, the side-effect
* block must leave the stream alone -- and the decision has to be made
* ONCE, here, rather than re-tested in each. Re-testing cannot work: the
* response block ends in DSAQueuePush(0x0100) (the Philia fix), which
* leaves dsaQueueCount == 1, so an identical guard in the side-effect
* block can never match on a redundant seek. It fell through to the
* full-seek path and rewound both heads to the start of the sector being
* streamed -- cdBufPtr back to 2 and the SSI audio head to 0, up to 2352
* bytes (~13 ms) of replayed CD-DA. See #306. */
bool seekRedundant = false;
offset &= 0xFF;
// BUTCH+2 (low word of ICR): only enable bits (0-6) are writable.
@@ -1560,8 +1685,8 @@ void CDROMWriteWord(uint32_t offset, uint16_t data, uint32_t who/*=UNKNOWN*/)
}
/* SUBCODE-DIAG: any traffic on the subcode registers (SBCNTRL $14,
* SUBDATA $18, SUBDATB $1C, SB_TIME $20) -- currently RAM-backed
* no-ops in this emulator. */
* SUBDATA $18, SUBDATB $1C, SB_TIME $20). SBCNTRL's low word arms
* the Q serializer below; the rest stay RAM-backed no-ops. */
if (offset >= SBCNTRL && offset < SB_TIME + 4)
{
static uint32_t subWrites = 0;
@@ -1574,6 +1699,17 @@ void CDROMWriteWord(uint32_t offset, uint16_t data, uint32_t who/*=UNKNOWN*/)
offset & 3, data, who, m68k_get_reg(NULL, M68K_REG_PC));
}
/* Any nonzero SBCNTRL low-word write arms Q-subcode capture (the VLM
* writes $00F2 there before Play); zero disarms. The individual bits
* are undocumented, so nothing finer is decoded — see the serializer
* comment near subQFrame. */
if (offset == SBCNTRL + 2)
{
subQArmed = (data != 0);
if (!subQArmed)
subQValid = false;
}
/* I2CNTRL bit 4 (FIFO-not-empty) is read-only status ("When read: b4 -
* FIFO state is not empty if 1" -- BUTCH register doc above), computed
* from fifoDataReady in the read handler. Games do read-modify-write
@@ -1639,7 +1775,9 @@ void CDROMWriteWord(uint32_t offset, uint16_t data, uint32_t who/*=UNKNOWN*/)
// Restarting seekDelay each time would keep dsaResponseReady cycling
// true, preventing the GPU ISR from ever taking the FIFO data path
// (bit 13 stays set, masking bit 9).
if (cdPlaying && newBlock == block && seekDelay <= 0 && dsaQueueCount == 0)
seekRedundant = (cdPlaying && newBlock == block && seekDelay <= 0 &&
dsaQueueCount == 0);
if (seekRedundant)
{
CD_LOG("Skipping redundant seek to block %u (already playing)\n", block);
/* The drive still answers a no-op Goto with Found ($0100) —
@@ -1663,7 +1801,21 @@ void CDROMWriteWord(uint32_t offset, uint16_t data, uint32_t who/*=UNKNOWN*/)
CDTracePush(CD_TRACE_SEEK_START, data, newBlock);
dsaResponseReady = false;
isMultiWordResponse = false;
seekDelay = SEEK_DELAY_TICKS;
/* Base ordering delay + distance term (see SEEK_SECTORS_PER_TICK
* above). `block` still holds the current head position here —
* the position block below updates it. (An out-of-range seek
* gets redirected there; the distance term then overestimates
* by the redirect delta, which only affects a pathological
* seek that was already being rewritten.) */
seekDelay = SEEK_DELAY_TICKS
+ (int32_t)CDROMSeekDistanceTicks(block, newBlock);
/* Q position moves with the head: restart the frame at the
* seek target (rebuilt from the new ssiBlock on the first
* streamed sample). A redundant seek leaves it alone, same
* as the stream itself. */
subQSeq = 0;
subQSampleCnt = 0;
subQValid = false;
}
}
else if ((data & 0xFF00) == 0x1000 || (data & 0xFF00) == 0x1100)
@@ -1785,8 +1937,11 @@ void CDROMWriteWord(uint32_t offset, uint16_t data, uint32_t who/*=UNKNOWN*/)
int32_t absBlock = (((min * 60) + sec) * 75) + newFrm;
uint32_t newBlock = (absBlock >= 150) ? (uint32_t)(absBlock - 150) : 0;
// Skip redundant seek (same guard as the seekDelay handler above)
if (cdPlaying && newBlock == block && seekDelay <= 0 && dsaQueueCount == 0)
/* Skip redundant seek. Consults the decision the response block
* already made (see seekRedundant at the top of this function);
* re-testing the guard here would always fail, because that block's
* DSAQueuePush(0x0100) has since made dsaQueueCount non-zero. */
if (seekRedundant)
{
frm = newFrm;
// Don't re-read block, don't reset cdBufPtr — data is already flowing
@@ -1837,7 +1992,7 @@ void CDROMWriteWord(uint32_t offset, uint16_t data, uint32_t who/*=UNKNOWN*/)
if (diag_firstSeekBlock == 0xFFFFFFFFu)
diag_firstSeekBlock = block;
CD_LOG("Seek started: block=%u (MSF %02u:%02u:%02u), delay=%d ticks\n",
block, min, sec, frm, SEEK_DELAY_TICKS);
block, min, sec, frm, seekDelay);
}
}
else if ((data & 0xFF00) == 0x1500) // Set Mode
@@ -2091,6 +2246,59 @@ bool ButchIsReadyToSend(void)
//
static uint32_t ssiXmitCount = 0;
/* CRC-16/CCITT (poly $1021, init 0), bitwise — 10 bytes once per sector
* is far too cold to justify a table. Matches the table-driven update
* in the VLM's DSP handler ($F1BD68: crc = (crc<<8) ^ tbl[(crc>>8) ^ b]). */
static uint16_t SubQCRC16(const uint8_t *data, uint32_t len)
{
uint16_t crc = 0;
uint32_t i, b;
for (i = 0; i < len; i++)
{
crc ^= (uint16_t)((uint16_t)data[i] << 8);
for (b = 0; b < 8; b++)
crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021)
: (uint16_t)(crc << 1);
}
return crc;
}
static uint8_t SubQBCD(uint32_t v)
{
return (uint8_t)(((v / 10) << 4) | (v % 10));
}
/* Rebuild subQFrame for the sector the SSI head is currently streaming
* (ssiBlock - 1: the head buffers a sector, then pre-increments). Mode-1
* (ADR 1) position frame, all BCD, CRC stored inverted per Red Book. */
static void CDROMBuildSubQ(void)
{
uint32_t lba, trk, idx, rel, abs_;
bool isData;
uint16_t crc;
subQValid = false;
lba = (ssiBlock > 0) ? ssiBlock - 1 : 0;
if (!CDIntfGetQPosition(lba, &trk, &idx, &rel, &isData))
return;
abs_ = lba + 150; /* absolute time offset */
subQFrame[0] = (uint8_t)(isData ? 0x41 : 0x01); /* CONTROL/ADR */
subQFrame[1] = SubQBCD(trk);
subQFrame[2] = SubQBCD(idx);
subQFrame[3] = SubQBCD(rel / 4500); /* MIN (track-relative) */
subQFrame[4] = SubQBCD((rel / 75) % 60); /* SEC */
subQFrame[5] = SubQBCD(rel % 75); /* FRAME */
subQFrame[6] = 0; /* ZERO */
subQFrame[7] = SubQBCD(abs_ / 4500); /* AMIN */
subQFrame[8] = SubQBCD((abs_ / 75) % 60); /* ASEC */
subQFrame[9] = SubQBCD(abs_ % 75); /* AFRAME */
crc = (uint16_t)~SubQCRC16(subQFrame, 10); /* stored inverted */
subQFrame[10] = (uint8_t)(crc >> 8);
subQFrame[11] = (uint8_t)(crc & 0xFF);
subQValid = true;
}
void SetSSIWordsXmittedFromButch(void)
{
ssiXmitCount++;
@@ -2140,6 +2348,32 @@ void SetSSIWordsXmittedFromButch(void)
ssiBlock++;
ssiBufPtr = 0;
}
/* Q-subcode serializer: one Q byte per 49 streamed samples (588/12),
* so the Q position advances exactly with the audio. Only clocked
* while real samples move -- pause/stop/seek freezes it with the
* stream (the gate above returned already). */
if (subQArmed)
{
/* A build can legitimately fail (LBA in an inter-session gap /
* virtual pregap, so CDIntfGetQPosition() declines). subQSampleCnt
* is 0 on the first sample after arming and again at each byte
* boundary, so this retries at most once per byte period instead of
* re-running the whole frame build at 44.1 kHz. */
if (!subQValid && subQSampleCnt == 0)
CDROMBuildSubQ();
subQSampleCnt++;
if (subQSampleCnt >= SUBQ_SAMPLES_PER_BYTE)
{
subQSampleCnt = 0;
subQSeq++;
if (subQSeq >= 12)
{
subQSeq = 0;
CDROMBuildSubQ();
}
}
}
}
/*
@@ -2555,6 +2789,13 @@ storew TEMP,(r24)
#include "state.h"
/* Size of the two staging buffers (`static uint8_t cdBuf2[2532 + 96],
* cdBuf3[2532 + 96];`) that the pre-STATE_VERSION_CDROM_RESTRUCTURE CDROM
* chunk carried after `firstTime`. The arrays were deleted with the
* unfinished BUTCH stub, so the legacy loader has to skip them by an
* explicit byte count rather than a sizeof. */
#define CDROM_LEGACY_STAGING_BYTES ((size_t)((2532 + 96) * 2))
size_t CDROMStateSave(uint8_t *buf)
{
uint8_t *start = buf;
@@ -2627,18 +2868,63 @@ size_t CDROMStateLoad(const uint8_t *buf, uint32_t stateVersion)
STATE_LOAD_VAR(buf, txData);
STATE_LOAD_VAR(buf, rxDataBit);
STATE_LOAD_VAR(buf, firstTime);
STATE_LOAD_BUF(buf, cdrom_eeprom_ram, sizeof(cdrom_eeprom_ram));
STATE_LOAD_VAR(buf, dsaResponseReady);
STATE_LOAD_VAR(buf, isMultiWordResponse);
STATE_LOAD_VAR(buf, txBufferEmpty);
STATE_LOAD_VAR(buf, cdPlaying);
STATE_LOAD_VAR(buf, seekDelay);
STATE_LOAD_VAR(buf, fifoDataReady);
STATE_LOAD_VAR(buf, fifoReadCount);
STATE_LOAD_VAR(buf, fifoFillDelay);
STATE_LOAD_BUF(buf, ssiBuf, sizeof(ssiBuf));
STATE_LOAD_VAR(buf, ssiBufPtr);
STATE_LOAD_VAR(buf, ssiBlock);
/* Layout fork. Everything above is common to every format version;
* from here the pre-CD-support layout (STATE_VERSION_CDROM_RESTRUCTURE)
* diverges completely. Releases v2.2.0 (v1), v2.3.0/v2.3.1 (v2) and
* v2.3.2 (v3) wrote two staging buffers here — cdBuf2 and cdBuf3, both
* `uint8_t [2532 + 96]` — which the CD-support work removed and
* replaced with the BUTCH/FIFO/DSA/SSI working set below. Consuming
* the new field list from an old blob would read 5256 bytes of stale
* sector data as flags and then leave the cursor 2627 bytes short,
* desyncing the Joystick, Memory Track and DAC chunks that follow.
* Skip the dead buffers by their byte count (the arrays no longer
* exist, so the size has to be spelled out) and start the drive from
* the same idle state CDROMReset() establishes. Those cores had no
* working CD path — cdBuf2/cdBuf3 were only ever written by the
* unfinished BUTCH stub — so nothing emulated is lost. */
if (stateVersion < STATE_VERSION_CDROM_RESTRUCTURE)
{
buf += CDROM_LEGACY_STAGING_BYTES;
/* Start the drive from the same clean idle state CDROMReset()
* establishes. The fields loaded above from the blob (cdRam,
* cdBufPtr, etc.) come from cores that had no working CD path,
* so any BIOS_OVRD or data-ready flag they carry is stale noise.
* Overwrite all drive-state fields unconditionally; the one
* exception is cdrom_eeprom_ram — persistent NVM backed by a
* file on disk that an old blob has nothing to say about, so
* leave whatever the session already loaded in place. */
memset(cdRam, 0x00, 0x100);
cdBufPtr = 2352;
dsaResponseReady = false;
isMultiWordResponse = false;
txBufferEmpty = true;
cdPlaying = false;
seekDelay = 0;
fifoDataReady = false;
fifoReadCount = 0;
fifoFillDelay = 0;
fifoRefillAccum = 0;
cdPrevShouldIRQ = false;
memset(ssiBuf, 0x00, sizeof(ssiBuf));
ssiBufPtr = 2352;
ssiBlock = 0;
cdTraceLastI2SEnable = -1;
}
else
{
STATE_LOAD_BUF(buf, cdrom_eeprom_ram, sizeof(cdrom_eeprom_ram));
STATE_LOAD_VAR(buf, dsaResponseReady);
STATE_LOAD_VAR(buf, isMultiWordResponse);
STATE_LOAD_VAR(buf, txBufferEmpty);
STATE_LOAD_VAR(buf, cdPlaying);
STATE_LOAD_VAR(buf, seekDelay);
STATE_LOAD_VAR(buf, fifoDataReady);
STATE_LOAD_VAR(buf, fifoReadCount);
STATE_LOAD_VAR(buf, fifoFillDelay);
STATE_LOAD_BUF(buf, ssiBuf, sizeof(ssiBuf));
STATE_LOAD_VAR(buf, ssiBufPtr);
STATE_LOAD_VAR(buf, ssiBlock);
}
/* v3 and older states predate the DSA response queue (see
* STATE_VERSION_CDROM_DSA_QUEUE): leave those fields at a safe empty
* state instead of consuming bytes the layout never carried. */
@@ -2675,5 +2961,15 @@ size_t CDROMStateLoad(const uint8_t *buf, uint32_t stateVersion)
else
cdDriveSpeed = CD_SPEED_DOUBLE;
/* Q-subcode serializer: nothing is serialized on purpose. Arming is
* re-derived from the SBCNTRL register saved inside cdRam above, and
* the frame/sequence state resyncs within one sector of streaming --
* the DSP-side collector ignores sequence numbers it is not
* expecting, so a restart at seq 0 is invisible beyond ~13 ms. */
subQArmed = (GET16(cdRam, SBCNTRL + 2) != 0);
subQSeq = 0;
subQSampleCnt = 0;
subQValid = false;
return (size_t)(buf - start);
}
+9
View File
@@ -18,6 +18,15 @@ void CDROMReset(void);
void CDROMSetHeadPosition(uint32_t lba);
void CDROMDone(void);
/* Distance-dependent seek cost in halfline ticks (31.78 us each): one tick
* per 72 sectors of head travel, calibrated against a BigPEmu reference
* capture of Dragon's Lair's death branch (#297) — ~138k sectors ~= 60.9 ms.
* Pure function, shared by the real-BIOS seek state machine (cdrom.c, on
* top of its fixed base delay) and the HLE CD_read stream arm
* (jagcd_hle.c, which otherwise has no seek at all) so both boot modes
* land the same branch gap. */
uint32_t CDROMSeekDistanceTicks(uint32_t fromLBA, uint32_t toLBA);
void BUTCHExec(uint32_t cycles);
uint8_t CDROMReadByte(uint32_t offset, uint32_t who);
+61
View File
@@ -184,6 +184,15 @@ static struct
* rationale as statusBase: flipping the core option
* while a transfer is in flight must not change the
* rate the game is already pacing itself against. */
uint32_t startDelay; /* halfline ticks the stream sits idle before its
* first byte: the distance-dependent seek term
* (CDROMSeekDistanceTicks, #297). HLE intercepts
* CD_read above BUTCH, so it never runs cdrom.c's
* seek state machine — without this the head
* teleports and HLE branch gaps run ~60 ms shorter
* than the real-BIOS path on full-stroke seeks.
* Zero when the latched speed is CDSPEED_INSTANT
* (the user asked for instant transfers). */
uint8_t buf[2352];
} hleStream;
@@ -527,6 +536,7 @@ static void HLEHandleCDRead(void)
uint32_t startLBA;
bool wasRedirected = false;
uint32_t phase;
uint32_t seekFromLBA = 0;
lba = ((uint32_t)min * 60 + sec) * 75 + frm;
if (lba >= 150)
@@ -955,6 +965,37 @@ hle_cd_read_post_scan:
* rationale above hleStream. (Per-CD_read cart-space mirror removed —
* HLEPopulateCartBuffer already covers BrainDead 13's "ATRI" cart-scan
* path at boot time.) */
/* Arming over a still-running stream silently discards the undelivered
* tail of the previous CD_read (no HLEStreamFinish, no completion
* flags). It is legitimate -- a game abandoning a read to branch does
* exactly this -- but it is also how any starvation regression would
* turn into corrupted movie data instead of a visible stall, so make it
* greppable rather than invisible (#297, fmv-drift-notes.md §7 lead 4).
*
* Compare against hleStream.total, not reqTotal: the stream delivers the
* long-rounded size, and games checksum through those extra tail bytes
* (see the delivery comment below), so a discard in [reqTotal, total) is
* still lost disc data and must not read as a complete transfer. */
if (hleStream.active && hleStream.written < hleStream.total)
LOG_WRN("[CD-HLE] CD_read armed over in-flight stream: "
"%u/%u bytes delivered (%u requested), dest $%06X "
"-- tail discarded\n",
hleStream.written, hleStream.total, hleStream.reqTotal,
hleStream.dest);
/* Head position BEFORE this read repositions it, for the seek-distance
* term latched below (must be sampled before the hleStream fields are
* overwritten): a still-streaming read leaves the head at its current
* streaming LBA; otherwise the drive parked one sector past the last
* read (hle_post_read_lba). A cold drive (no read yet) sits at the
* disc start, matching the real path's reset state (block = 0). */
if (hleStream.active && hleStream.written < hleStream.total)
seekFromLBA = hleStream.lba;
else if (hle_post_read_lba != 0xFFFFFFFFu)
seekFromLBA = hle_post_read_lba;
else
seekFromLBA = 0;
hleStream.active = true;
hleStream.lba = scanLBA;
hleStream.bufOff = scanOff;
@@ -983,6 +1024,16 @@ hle_cd_read_post_scan:
hleStream.sigA0 = a0;
hleStream.sigA1 = a1;
hleStream.speedMult = vjs.cdReadSpeed; /* latch for this transfer */
/* Distance-dependent seek term (#297, reference parity — see
* CDROMSeekDistanceTicks in cdrom.c for the model + calibration).
* Only the distance term, NOT cdrom.c's fixed SEEK_DELAY_TICKS base:
* HLE's zero-latency arm already lands branch frames on the same
* field as the real path (fmv-drift-notes.md §10.4 — 100 ticks =
* 0.19 fields is sub-resolution), so adding the distance term to
* both paths preserves that parity exactly. */
hleStream.startDelay = (hleStream.speedMult == CDSPEED_INSTANT)
? 0
: CDROMSeekDistanceTicks(seekFromLBA, scanLBA);
hle_stream_arm_count++;
hle_read_dest = destAddr;
@@ -1213,6 +1264,16 @@ void JaguarCDHLEStreamTick(void)
if (!hleStream.active)
return;
/* Seek in flight: the sled is still traveling (distance term latched
* at arm time, zero for CDSPEED_INSTANT). No bytes flow and no byte
* budget accrues until it lands — mirrors cdrom.c, where the FIFO
* fill starts only after SEEK_DONE. */
if (hleStream.startDelay > 0)
{
hleStream.startDelay--;
return;
}
if (hleStream.speedMult == CDSPEED_INSTANT)
{
/* Instant: deliver the whole remaining transfer this tick. Still
+23 -1
View File
@@ -21,6 +21,7 @@
#include "crc32.h"
#include "filedb.h"
#include "eeprom.h"
#include "jaggd.h"
#include "jaguar.h"
#include "log.h"
#include "vjag_memory.h"
@@ -245,8 +246,29 @@ static bool JaguarLoadFileInternal(uint8_t *buffer, size_t bufsize)
if (fileType == JST_ROM)
{
/* The flat cart window holds 6 MB ($800000-$DFFFFF). Larger
* images are Jaguar GameDrive content: the first 6 MB fill the
* flat window as the GD's identity page mapping would, and the
* full image (up to 16 MB) goes to the banked path. */
uint32_t flatSize = jaguarROMSize;
if (jaguarROMSize > JGD_ROM_SIZE)
{
LOG_ERR("[CART] image is %u bytes; the largest supported cartridge is the 16 MB Jaguar GameDrive\n",
(unsigned)jaguarROMSize);
return false;
}
/* Cap the flat copy at the dispatchable cart window ($800000-
* $DFFEFF): the final $100 bytes ($DFFF00-$DFFFFF) are the CDROM
* overlay and must not receive ROM bytes. Same constant the
* auto-enable threshold uses, so loader and banking agree on
* where the flat window ends. */
if (flatSize > JGD_AUTO_THRESHOLD)
flatSize = JGD_AUTO_THRESHOLD;
jaguarCartInserted = true;
memcpy(jagMemSpace + 0x800000, buffer, jaguarROMSize);
memcpy(jagMemSpace + 0x800000, buffer, flatSize);
JGDLoadROM(buffer, jaguarROMSize);
// Checking something...
jaguarRunAddress = GET32(jagMemSpace, 0x800404);
return true;
+629
View File
@@ -0,0 +1,629 @@
/*
* jaggd.c -- Jaguar GameDrive (JagGD) flash cartridge emulation
*
* Implements the two things game code actually touches on a GameDrive
* (issue #312, spec: docs/jgd-interface-notes.md):
*
* 1. The ASIC SPI mailbox at $F16002/$F16004/$F16005 (JERRY GPIO2),
* exactly as driven by RetroHQ's published gdbios_bindings.s:
* the GDWaitData handshake (HAVE_DATA bit 3 sequencing), the
* byte-exchange data register (bit 15 busy, bit 5 rx latch), and
* the two micro commands games issue raw: 12 (HW version) and
* $80 (fetch the GDBIOS blob).
*
* 2. 1 MB-granular bank switching of a 16 MB SDRAM image into the
* six cart-window pages ($8xxxxx..$Dxxxxx), reached exclusively
* through the GDBIOS blob ABI (functions 5/6) -- and since the
* emulated micro serves the blob, we serve our own ~200-byte 68K
* blob whose banking functions poke an emulator-defined backdoor
* register at $F16006. BigPEmu does the same thing structurally
* (it cannot ship RetroHQ's copyrighted blob either).
*
* Out of scope (matches BigPEmu): GD menu, SD/FAT filesystem, GPU
* async reads, encrypted .jgd images, .MRQ sidecars. File/dir/async
* blob functions return -1; serial numbers return zeros-with-success.
*/
#include "jaggd.h"
#include <stdlib.h>
#include <string.h>
#include "log.h"
/* ------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------ */
uint8_t jgdActive = 0;
uint8_t jgdWriteEnabled = 0;
uint8_t jgdPage[6] = { 0, 1, 2, 3, 4, 5 };
uint8_t *jgdROM = NULL;
static int jgdMode = JGD_MODE_AUTO;
/* SPI engine. Transactions complete instantly, so the busy bit (15)
* always reads clear; HAVE_DATA (bit 3) and LATCH_FULL (bit 5) are the
* only synthesized status bits. State machine mirrors the exact code
* shape of gdbios_bindings.s (see JGDStatusWrite below). */
enum
{
JGD_SPI_IDLE = 0, /* nothing in flight */
JGD_SPI_START, /* $0010 seen, awaiting the $0011 handshake ack */
JGD_SPI_CMD, /* collecting cmd lo/hi + param-size lo/hi */
JGD_SPI_PARAMS, /* collecting declared parameter bytes */
JGD_SPI_PROC, /* header+params complete, awaiting "process it" */
JGD_SPI_RESP /* serving the response FIFO */
};
#define JGD_ST_SLAVE_SELECT 0x0001
#define JGD_ST_HAVE_DATA 0x0008
#define JGD_ST_PACKET_START 0x0010
#define JGD_ST_LATCH_FULL 0x0020
#define JGD_PARAM_MAX 64
static uint8_t spiState = JGD_SPI_IDLE;
static uint8_t spiHaveData = 0;
static uint8_t spiLatchFull = 0;
static uint8_t spiRxLatch = 0;
static uint8_t spiHdrCount = 0;
static uint16_t spiCmd = 0;
static uint16_t spiParamLen = 0;
static uint16_t spiParamCount = 0;
static uint8_t spiParams[JGD_PARAM_MAX];
static uint16_t spiFifoLen = 0;
static uint16_t spiFifoPos = 0;
static uint8_t spiFifo[JGD_FIFO_SIZE];
/* Backdoor pages latch: op $9xxx stages pages 0-2, op $Axxx applies
* pages 3-5 together with the staged half. */
static uint16_t pagesLatch = 0;
/* ------------------------------------------------------------------ */
/* The served GDBIOS blob */
/* ------------------------------------------------------------------ */
/*
* Hand-assembled 68000, position independent (bra.w only), poking the
* $F16006 backdoor for banking / write enable. Layout per the blob
* ABI in docs/jgd-interface-notes.md section 4:
* +0 u16 version ($0100), +2 u16 highest function (26),
* +N*4 four-byte entry slot for function N (jsr N*4(a6)).
*
* Backdoor word encoding (JGDControlWriteWord):
* $8000 | page<<4 | bank set one page (GD_ROMSetPage)
* $9000 | nibbles 0-2 stage pages 0-2 (GD_ROMSetPages)
* $A000 | nibbles 3-5 apply pages 3-5 + staged half
* $B000 | enable bit 0 ROM write enable (GD_ROMWriteEnable)
*/
static const uint8_t jgdBlob[] =
{
/* 000 */ 0x01, 0x00, /* dc.w $0100 version */
/* 002 */ 0x00, 0x1A, /* dc.w 26 function count */
/* 004 */ 0x4E, 0x75, 0x4E, 0x71, /* f1 GD_Init: rts */
/* 008 */ 0x4E, 0x75, 0x4E, 0x71, /* f2 GD_InitGPURead: rts */
/* 00C */ 0x60, 0x00, 0x00, 0x5E, /* f3 bra.w biosver (06C) */
/* 010 */ 0x60, 0x00, 0x00, 0x60, /* f4 bra.w wren (072) */
/* 014 */ 0x60, 0x00, 0x00, 0x6C, /* f5 bra.w setpage (082) */
/* 018 */ 0x60, 0x00, 0x00, 0x84, /* f6 bra.w setpages(09E) */
/* 01C */ 0x60, 0x00, 0x00, 0xA6, /* f7 bra.w serial (0C4) */
/* 020 */ 0x60, 0x00, 0x00, 0xA2, /* f8 bra.w serial (0C4) */
/* 024 */ 0x70, 0x00, 0x4E, 0x75, /* f9 GD_CardIn: moveq #0,d0; rts */
/* 028 */ 0x70, 0xFF, 0x4E, 0x75, /* f10 GD_FileOpen: moveq #-1,d0; rts */
/* 02C */ 0x70, 0xFF, 0x4E, 0x75, /* f11 GD_FileClose */
/* 030 */ 0x70, 0xFF, 0x4E, 0x75, /* f12 GD_FileSeek */
/* 034 */ 0x70, 0xFF, 0x4E, 0x75, /* f13 GD_FileRead */
/* 038 */ 0x70, 0xFF, 0x4E, 0x75, /* f14 GD_FileWrite */
/* 03C */ 0x70, 0xFF, 0x4E, 0x75, /* f15 GD_FileTell */
/* 040 */ 0x70, 0xFF, 0x4E, 0x75, /* f16 GD_FileSize */
/* 044 */ 0x70, 0xFF, 0x4E, 0x75, /* f17 GD_FileAsyncPos */
/* 048 */ 0x70, 0xFF, 0x4E, 0x75, /* f18 GD_FileAsyncWait */
/* 04C */ 0x70, 0xFF, 0x4E, 0x75, /* f19 GD_FileAsyncActive */
/* 050 */ 0x70, 0xFF, 0x4E, 0x75, /* f20 GD_FileInfo */
/* 054 */ 0x70, 0xFF, 0x4E, 0x75, /* f21 GD_DirOpen */
/* 058 */ 0x70, 0xFF, 0x4E, 0x75, /* f22 GD_DirRead */
/* 05C */ 0x70, 0xFF, 0x4E, 0x75, /* f23 GD_DirClose */
/* 060 */ 0x4E, 0x75, 0x4E, 0x71, /* f24 GD_Reset: rts */
/* 064 */ 0x4E, 0x75, 0x4E, 0x71, /* f25 GD_SetLED: rts */
/* 068 */ 0x4E, 0x75, 0x4E, 0x71, /* f26 GD_DebugString: rts */
/* biosver: */
/* 06C */ 0x30, 0x3C, 0x01, 0x00, /* move.w #$0100,d0 */
/* 070 */ 0x4E, 0x75, /* rts */
/* wren: (d0 = 1 enable / 0 disable) */
/* 072 */ 0x02, 0x40, 0x00, 0x01, /* andi.w #1,d0 */
/* 076 */ 0x00, 0x40, 0xB0, 0x00, /* ori.w #$B000,d0 */
/* 07A */ 0x33, 0xC0, 0x00, 0xF1, 0x60, 0x06, /* move.w d0,$F16006 */
/* 080 */ 0x4E, 0x75, /* rts */
/* setpage: (d0 = page<<16 | bank) */
/* 082 */ 0x32, 0x00, /* move.w d0,d1 */
/* 084 */ 0x02, 0x41, 0x00, 0x0F, /* andi.w #$F,d1 */
/* 088 */ 0x48, 0x40, /* swap d0 */
/* 08A */ 0xE9, 0x48, /* lsl.w #4,d0 */
/* 08C */ 0x02, 0x40, 0x00, 0x70, /* andi.w #$70,d0 */
/* 090 */ 0x82, 0x40, /* or.w d0,d1 */
/* 092 */ 0x00, 0x41, 0x80, 0x00, /* ori.w #$8000,d1 */
/* 096 */ 0x33, 0xC1, 0x00, 0xF1, 0x60, 0x06, /* move.w d1,$F16006 */
/* 09C */ 0x4E, 0x75, /* rts */
/* setpages: (d0 = u32, one bank nibble per page, LSN = page 0) */
/* 09E */ 0x22, 0x00, /* move.l d0,d1 */
/* 0A0 */ 0x02, 0x41, 0x0F, 0xFF, /* andi.w #$FFF,d1 */
/* 0A4 */ 0x00, 0x41, 0x90, 0x00, /* ori.w #$9000,d1 */
/* 0A8 */ 0x33, 0xC1, 0x00, 0xF1, 0x60, 0x06, /* move.w d1,$F16006 */
/* 0AE */ 0x22, 0x00, /* move.l d0,d1 */
/* 0B0 */ 0xE0, 0x89, /* lsr.l #8,d1 */
/* 0B2 */ 0xE8, 0x89, /* lsr.l #4,d1 */
/* 0B4 */ 0x02, 0x41, 0x0F, 0xFF, /* andi.w #$FFF,d1 */
/* 0B8 */ 0x00, 0x41, 0xA0, 0x00, /* ori.w #$A000,d1 */
/* 0BC */ 0x33, 0xC1, 0x00, 0xF1, 0x60, 0x06, /* move.w d1,$F16006 */
/* 0C2 */ 0x4E, 0x75, /* rts */
/* serial: (a0 = 16-byte buffer; zeros-with-success) */
/* 0C4 */ 0x72, 0x03, /* moveq #3,d1 */
/* 0C6 */ 0x42, 0x98, /* .l: clr.l (a0)+ */
/* 0C8 */ 0x51, 0xC9, 0xFF, 0xFC, /* dbra d1,.l */
/* 0CC */ 0x70, 0x00, /* moveq #0,d0 */
/* 0CE */ 0x4E, 0x75 /* rts */
};
#define JGD_BLOB_SIZE ((uint16_t)sizeof(jgdBlob))
/* HW version response (cmd 12): high word firmware, low word ASIC,
* BCD. Any firmware >= $0111 satisfies the GD_Install gate. */
#define JGD_FW_VERSION 0x0300
#define JGD_ASIC_VERSION 0x0102
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
void JGDSetMode(int mode)
{
jgdMode = mode;
}
int JGDGetMode(void)
{
return jgdMode;
}
static void JGDSpiReset(void)
{
spiState = JGD_SPI_IDLE;
spiHaveData = 0;
spiLatchFull = 0;
spiRxLatch = 0;
spiHdrCount = 0;
spiCmd = 0;
spiParamLen = 0;
spiParamCount = 0;
spiFifoLen = 0;
spiFifoPos = 0;
memset(spiParams, 0, sizeof(spiParams));
memset(spiFifo, 0, sizeof(spiFifo));
pagesLatch = 0;
}
void JGDReset(void)
{
unsigned i;
for (i = 0; i < 6; i++)
jgdPage[i] = (uint8_t)i;
jgdWriteEnabled = 0;
JGDSpiReset();
}
void JGDUnload(void)
{
JGDReset();
jgdActive = 0;
if (jgdROM)
{
free(jgdROM);
jgdROM = NULL;
}
}
void JGDDone(void)
{
/* iOS cannot dlclose cores: every static must return to its
* power-on value here (see CLAUDE.md / feedback_ios_static_state). */
JGDUnload();
jgdMode = JGD_MODE_AUTO;
}
void JGDLoadROM(const uint8_t *buffer, uint32_t size)
{
int wantActive;
JGDUnload();
if (!buffer || size == 0 || size > JGD_ROM_SIZE)
return;
wantActive = (jgdMode == JGD_MODE_ENABLED)
|| (jgdMode == JGD_MODE_AUTO && size > JGD_AUTO_THRESHOLD);
if (!wantActive)
return;
jgdROM = (uint8_t *)malloc(JGD_ROM_SIZE);
if (!jgdROM)
{
LOG_ERR("[JGD] failed to allocate 16 MB SDRAM image; GameDrive disabled\n");
return;
}
memcpy(jgdROM, buffer, size);
if (size < JGD_ROM_SIZE)
memset(jgdROM + size, 0, JGD_ROM_SIZE - size);
jgdActive = 1;
LOG_INF("[JGD] GameDrive emulation active (%s, image %u bytes)\n",
jgdMode == JGD_MODE_ENABLED ? "forced" : "auto", (unsigned)size);
}
void JGDWriteROM8(uint32_t off, uint8_t v)
{
jgdROM[((uint32_t)jgdPage[off >> 20] << 20) | (off & 0xFFFFF)] = v;
}
/* ------------------------------------------------------------------ */
/* SPI engine */
/* ------------------------------------------------------------------ */
static void JGDFifoPush(uint8_t b)
{
if (spiFifoLen < JGD_FIFO_SIZE)
spiFifo[spiFifoLen++] = b;
}
static uint8_t JGDFifoPop(void)
{
if (spiFifoPos < spiFifoLen)
return spiFifo[spiFifoPos++];
return 0;
}
/* Fill the response FIFO for the completed command. Responses stream
* high byte first (GDExchangeWord reassembles rx1<<8 | rx2). */
static void JGDProcessCommand(void)
{
uint16_t i;
spiFifoLen = 0;
spiFifoPos = 0;
switch (spiCmd)
{
case 12: /* HW version: u32, firmware word then ASIC word */
JGDFifoPush((uint8_t)(JGD_FW_VERSION >> 8));
JGDFifoPush((uint8_t)(JGD_FW_VERSION & 0xFF));
JGDFifoPush((uint8_t)(JGD_ASIC_VERSION >> 8));
JGDFifoPush((uint8_t)(JGD_ASIC_VERSION & 0xFF));
break;
case 0x80: /* Fetch GDBIOS: u16 size, then the blob */
JGDFifoPush((uint8_t)(JGD_BLOB_SIZE >> 8));
JGDFifoPush((uint8_t)(JGD_BLOB_SIZE & 0xFF));
for (i = 0; i < JGD_BLOB_SIZE; i++)
JGDFifoPush(jgdBlob[i]);
break;
default: /* unknown command: zero-filled response */
break;
}
}
/* ASIC_SPI_STATUS write. Decoded per the gdbios_bindings.s sequences:
*
* $0000 end of packet / deselect -> engine idle
* $0010 (START) "attention": begin a packet, or "process it GD!"
* after the header/params, or a block boundary in
* the response -> HAVE_DATA drops
* $0011 (START|SS) GDWaitData handshake ack -> HAVE_DATA rises
*/
static void JGDStatusWrite(uint16_t v)
{
if (v == 0)
{
/* clr.w ASIC_SPI_STATUS: end of packet. */
spiState = JGD_SPI_IDLE;
spiHaveData = 0;
spiFifoLen = 0;
spiFifoPos = 0;
spiHdrCount = 0;
return;
}
if (!(v & JGD_ST_PACKET_START))
return;
if (v & JGD_ST_SLAVE_SELECT)
{
/* Handshake ack: slave raises HAVE_DATA. */
spiHaveData = 1;
if (spiState == JGD_SPI_START || spiState == JGD_SPI_IDLE)
{
spiState = JGD_SPI_CMD;
spiHdrCount = 0;
}
/* JGD_SPI_RESP: stay -- next block ready. */
return;
}
/* Plain PACKET_START. */
spiHaveData = 0;
if (spiState == JGD_SPI_IDLE)
{
spiState = JGD_SPI_START;
spiFifoLen = 0;
spiFifoPos = 0;
spiHdrCount = 0;
}
else if (spiState == JGD_SPI_PROC)
{
JGDProcessCommand();
spiState = JGD_SPI_RESP;
}
/* JGD_SPI_RESP / others: block boundary, nothing to do. */
}
/* ASIC_SPI_DATA write: clock one byte exchange (tx = low byte). */
static void JGDDataWrite(uint16_t v)
{
uint8_t tx = (uint8_t)(v & 0xFF);
uint8_t rx = 0;
switch (spiState)
{
case JGD_SPI_CMD:
switch (spiHdrCount)
{
case 0: spiCmd = tx; break;
case 1: spiCmd |= (uint16_t)tx << 8; break;
case 2: spiParamLen = tx; break;
default:
spiParamLen |= (uint16_t)tx << 8;
spiParamCount = 0;
spiState = spiParamLen ? JGD_SPI_PARAMS : JGD_SPI_PROC;
break;
}
if (spiHdrCount < 3)
spiHdrCount++;
break;
case JGD_SPI_PARAMS:
if (spiParamCount < JGD_PARAM_MAX)
spiParams[spiParamCount] = tx;
spiParamCount++;
if (spiParamCount >= spiParamLen)
spiState = JGD_SPI_PROC;
break;
case JGD_SPI_RESP:
rx = JGDFifoPop();
break;
default:
break;
}
spiRxLatch = rx;
spiLatchFull = 1;
}
/* Backdoor register (see the blob's op encoding above). */
static void JGDBackdoorWrite(uint16_t v)
{
unsigned page;
switch (v & 0xF000)
{
case 0x8000:
page = (v >> 4) & 0x7;
if (page < 6)
jgdPage[page] = (uint8_t)(v & 0xF);
break;
case 0x9000:
pagesLatch = v & 0x0FFF;
break;
case 0xA000:
jgdPage[0] = (uint8_t)(pagesLatch & 0xF);
jgdPage[1] = (uint8_t)((pagesLatch >> 4) & 0xF);
jgdPage[2] = (uint8_t)((pagesLatch >> 8) & 0xF);
jgdPage[3] = (uint8_t)(v & 0xF);
jgdPage[4] = (uint8_t)((v >> 4) & 0xF);
jgdPage[5] = (uint8_t)((v >> 8) & 0xF);
break;
case 0xB000:
jgdWriteEnabled = (uint8_t)(v & 1);
break;
default:
break;
}
}
/* ------------------------------------------------------------------ */
/* JERRY register window */
/* ------------------------------------------------------------------ */
uint16_t JGDControlReadWord(uint32_t offset)
{
switch (offset & 0xFFFFFFFE)
{
case JGD_ASIC_SPI_STATUS:
/* Busy (bit 15) never set: exchanges complete instantly. */
return (uint16_t)((spiHaveData ? JGD_ST_HAVE_DATA : 0)
| (spiLatchFull ? JGD_ST_LATCH_FULL : 0));
case JGD_ASIC_SPI_DATA:
/* Word read drains the rx latch (the pre-install "in case of
* FIFO DMA termination" drain loop in GD_Install). */
spiLatchFull = 0;
return spiRxLatch;
default:
return 0;
}
}
uint8_t JGDControlReadByte(uint32_t offset)
{
uint16_t w;
if (offset == JGD_ASIC_SPI_DATA + 1)
{
/* ASIC_SPI_DATA_BYTE: the received byte. */
spiLatchFull = 0;
return spiRxLatch;
}
w = JGDControlReadWord(offset & 0xFFFFFFFE);
return (offset & 1) ? (uint8_t)(w & 0xFF) : (uint8_t)(w >> 8);
}
void JGDControlWriteWord(uint32_t offset, uint16_t data)
{
switch (offset & 0xFFFFFFFE)
{
case JGD_ASIC_SPI_STATUS:
JGDStatusWrite(data);
break;
case JGD_ASIC_SPI_DATA:
JGDDataWrite(data);
break;
case JGD_BACKDOOR:
JGDBackdoorWrite(data);
break;
default:
break;
}
}
void JGDControlWriteByte(uint32_t offset, uint8_t data)
{
/* Byte writes are not used by the published bindings; approximate
* them as a word write carrying the byte in the low lane. */
JGDControlWriteWord(offset & 0xFFFFFFFE, data);
}
/* ------------------------------------------------------------------ */
/* Savestate */
/* ------------------------------------------------------------------ */
#include "state.h"
/* Fixed-size chunk. Deliberately all-zero while inactive so states of
* non-GD content keep a zero tail (test_state_compat's dac_block_is_last
* structural check relies on trailing chunks being zero there). */
size_t JGDStateSave(uint8_t *buf)
{
uint8_t *start = buf;
uint8_t zero8 = 0;
uint16_t zero16 = 0;
unsigned i;
if (!jgdActive)
{
static const uint8_t zeros[16] = { 0 };
STATE_SAVE_BUF(buf, zeros, 16); /* flags/pages/spi bytes */
for (i = 0; i < 5; i++)
STATE_SAVE_VAR(buf, zero16); /* latch/cmd/len/count/fifoLen */
STATE_SAVE_VAR(buf, zero16); /* fifoPos */
{
uint8_t zfifo[JGD_FIFO_SIZE];
memset(zfifo, 0, sizeof(zfifo));
STATE_SAVE_BUF(buf, zfifo, JGD_FIFO_SIZE);
}
return (size_t)(buf - start);
}
STATE_SAVE_VAR(buf, jgdActive);
STATE_SAVE_VAR(buf, jgdWriteEnabled);
STATE_SAVE_BUF(buf, jgdPage, 6);
STATE_SAVE_VAR(buf, spiState);
STATE_SAVE_VAR(buf, spiHaveData);
STATE_SAVE_VAR(buf, spiLatchFull);
STATE_SAVE_VAR(buf, spiRxLatch);
STATE_SAVE_VAR(buf, spiHdrCount);
STATE_SAVE_VAR(buf, zero8); /* pad */
STATE_SAVE_VAR(buf, zero8); /* pad */
STATE_SAVE_VAR(buf, zero8); /* pad */
STATE_SAVE_VAR(buf, pagesLatch);
STATE_SAVE_VAR(buf, spiCmd);
STATE_SAVE_VAR(buf, spiParamLen);
STATE_SAVE_VAR(buf, spiParamCount);
STATE_SAVE_VAR(buf, spiFifoLen);
STATE_SAVE_VAR(buf, spiFifoPos);
STATE_SAVE_BUF(buf, spiFifo, JGD_FIFO_SIZE);
return (size_t)(buf - start);
}
size_t JGDStateLoad(const uint8_t *buf)
{
const uint8_t *start = buf;
uint8_t active, pad;
unsigned i;
STATE_LOAD_VAR(buf, active);
STATE_LOAD_VAR(buf, jgdWriteEnabled);
STATE_LOAD_BUF(buf, jgdPage, 6);
STATE_LOAD_VAR(buf, spiState);
STATE_LOAD_VAR(buf, spiHaveData);
STATE_LOAD_VAR(buf, spiLatchFull);
STATE_LOAD_VAR(buf, spiRxLatch);
STATE_LOAD_VAR(buf, spiHdrCount);
STATE_LOAD_VAR(buf, pad);
STATE_LOAD_VAR(buf, pad);
STATE_LOAD_VAR(buf, pad);
STATE_LOAD_VAR(buf, pagesLatch);
STATE_LOAD_VAR(buf, spiCmd);
STATE_LOAD_VAR(buf, spiParamLen);
STATE_LOAD_VAR(buf, spiParamCount);
STATE_LOAD_VAR(buf, spiFifoLen);
STATE_LOAD_VAR(buf, spiFifoPos);
STATE_LOAD_BUF(buf, spiFifo, JGD_FIFO_SIZE);
if (!active || !jgdROM)
{
/* State written without GD banking (or the current session has
* no 16 MB image to bank): back to the reset mapping. */
JGDReset();
}
else
{
/* Sanitize untrusted fields so a corrupt state cannot index out
* of range. */
for (i = 0; i < 6; i++)
jgdPage[i] &= 0xF;
jgdWriteEnabled &= 1;
if (spiState > JGD_SPI_RESP)
spiState = JGD_SPI_IDLE;
if (spiFifoLen > JGD_FIFO_SIZE)
spiFifoLen = JGD_FIFO_SIZE;
if (spiFifoPos > spiFifoLen)
spiFifoPos = spiFifoLen;
if (spiParamCount > JGD_PARAM_MAX)
spiParamCount = JGD_PARAM_MAX;
if (spiHdrCount > 3)
spiHdrCount = 3;
}
return (size_t)(buf - start);
}
+82
View File
@@ -0,0 +1,82 @@
#ifndef _VIRTUALJAGUAR_JAGGD_H
#define _VIRTUALJAGUAR_JAGGD_H
/*
* jaggd.h: Jaguar GameDrive (JagGD) flash cartridge emulation
*
* Register/protocol/ABI ground truth: docs/jgd-interface-notes.md
* (synthesized from RetroHQ's published gdbios_bindings.s, JagStudio
* v1.11 and BigPEmu behavior). Issue #312.
*/
#include <stdint.h>
#include <stddef.h>
/* Core option modes (virtualjaguar_jgd) */
#define JGD_MODE_DISABLED 0
#define JGD_MODE_AUTO 1 /* enable when the image exceeds the 6 MB cart window */
#define JGD_MODE_ENABLED 2
/* 16 MB of onboard SDRAM presented as cartridge ROM. */
#define JGD_ROM_SIZE 0x1000000
/* Auto mode threshold: the usable flat cart window ($800000-$DFFEFF). */
#define JGD_AUTO_THRESHOLD 0x5FFF00
/* SPI response FIFO: sized for the largest response we serve (the
* GDBIOS blob plus its u16 size prefix). */
#define JGD_FIFO_SIZE 512
/* GD registers in JERRY's GPIO2 decode range (gdbios_bindings.s). */
#define JGD_REG_FIRST 0xF16000
#define JGD_REG_LAST 0xF16007
#define JGD_ASIC_SPI_STATUS 0xF16002
#define JGD_ASIC_SPI_DATA 0xF16004
/* Emulator-defined backdoor the served GDBIOS blob pokes for banking /
* write enable. Inside the GPIO2 range we already own; nothing else
* decodes it. See JGDControlWriteWord for the op encoding. */
#define JGD_BACKDOOR 0xF16006
/* Banking state. jgdActive gates every hook; when it is 0 the cart
* read path and the JERRY register windows behave exactly as before
* (reads of $F16002 fall into the EEPROM catch-all and return 0, which
* reproduces the stock-console "GD absent" hang in GD-locked titles). */
extern uint8_t jgdActive;
extern uint8_t jgdWriteEnabled;
extern uint8_t jgdPage[6];
extern uint8_t *jgdROM; /* 16 MB when active, NULL otherwise */
/* Hot-path helpers for the cart read dispatch in jaguar.c: one table
* lookup + shift, same cost class as the MEMTRACK_PRESENT() gate. */
#define JGD_BANKING() (jgdActive)
#define JGDReadROM8(off) \
(jgdROM[((uint32_t)jgdPage[(uint32_t)(off) >> 20] << 20) \
| ((uint32_t)(off) & 0xFFFFF)])
void JGDSetMode(int mode);
int JGDGetMode(void);
/* Called for every JST_ROM cartridge load; decides activation from the
* mode + image size and keeps the full (up to 16 MB) image. */
void JGDLoadROM(const uint8_t *buffer, uint32_t size);
/* Drop the image + deactivate (fresh content load boundary). */
void JGDUnload(void);
/* Console reset: identity pages, write-protect, idle SPI engine.
* Keeps the loaded image and the active flag. */
void JGDReset(void);
/* Full teardown incl. statics (iOS cannot dlclose; retro_deinit path). */
void JGDDone(void);
/* GD write path for ROMWriteEnable'd cart space (offset = addr - $800000). */
void JGDWriteROM8(uint32_t off, uint8_t v);
/* JERRY register window ($F16000-$F16007), called only when jgdActive. */
uint16_t JGDControlReadWord(uint32_t offset);
uint8_t JGDControlReadByte(uint32_t offset);
void JGDControlWriteWord(uint32_t offset, uint16_t data);
void JGDControlWriteByte(uint32_t offset, uint8_t data);
/* Savestate (fixed-size chunk; all-zero when inactive). */
size_t JGDStateSave(uint8_t *buf);
size_t JGDStateLoad(const uint8_t *buf);
#endif
+121 -8
View File
@@ -35,6 +35,7 @@
#include "m68000/cpudefs.h" /* regs.remainingCycles — 68K DRAM self-cost */
#include "bus_arbiter.h"
#include "memtrack.h"
#include "jaggd.h"
#include "nvmbios.h"
#include "settings.h"
#include "tom.h"
@@ -256,6 +257,26 @@ extern uint8_t jagMemSpace[];
uint32_t jaguarMainROMCRC32, jaguarROMSize, jaguarRunAddress;
uint32_t jaguarLoadedRAMStart, jaguarLoadedRAMEnd;
/* Clock-scale enhancement levers (issue #314), percent of stock rate.
* Statically 100 so any path that runs the core before check_variables()
* (or a harness that never sets the options) gets stock timing. See the
* block comment in jaguar.h for where these do and do not apply. */
uint32_t m68kClockScalePct = 100;
/* Error-diffusion remainder for the M68K scale (hundredths of a cycle,
* same pattern as cdrom.c's fifoRefillAccum). Without it, a sub-1x
* scale rounds each small slice down independently -- a 1-cycle slice
* at 0.5x scales to 0, and the UAE do/while then executes one full
* instruction anyway, quietly defeating underclocking. Carrying the
* remainder makes the scale exact over time and lets zero-budget
* slices genuinely skip. Reset whenever the scale changes. */
static uint32_t m68kScaleAccum = 0;
void M68KClockScaleReset(void)
{
m68kScaleAccum = 0;
}
uint32_t riscClockScalePct = 100;
bool jaguarCartInserted = false;
/* Memory Track cartridge presence. On hardware the MT cart plugs into the
* cartridge slot while the CD unit sits on top, so a disc and an MT cart are
@@ -405,6 +426,8 @@ unsigned int m68k_read_memory_8(unsigned int address)
{
if (MEMTRACK_PRESENT() && MTClaimsRead(address))
return MTReadByte(address);
if (JGD_BANKING())
return JGDReadROM8(address - 0x800000);
return jaguarMainROM[address - 0x800000];
}
else if ((address >= 0xE00000) && (address <= 0xE3FFFF))
@@ -445,9 +468,15 @@ unsigned int m68k_read_memory_16(unsigned int address)
/* Memory Track reading... */
if (MEMTRACK_PRESENT() && MTClaimsRead(address))
return MTReadWord(address);
else
return (jaguarMainROM[address - 0x800000] << 8)
| jaguarMainROM[address - 0x800000 + 1];
if (JGD_BANKING())
{
/* Byte-composed so a read straddling a 1 MB page boundary
* pulls each half from its own bank. */
uint32_t off = address - 0x800000;
return (JGDReadROM8(off) << 8) | JGDReadROM8(off + 1);
}
return (jaguarMainROM[address - 0x800000] << 8)
| jaguarMainROM[address - 0x800000 + 1];
}
else if ((address >= 0xE00000) && (address <= 0xE3FFFE))
return (jagMemSpace[address] << 8) | jagMemSpace[address + 1];
@@ -485,6 +514,16 @@ unsigned int m68k_read_memory_32(unsigned int address)
if (MEMTRACK_PRESENT() && MTClaimsRead(address))
return MTReadLong(address);
if (JGD_BANKING())
{
/* Byte-composed: a long can straddle a 1 MB page boundary. */
uint32_t off = address - 0x800000;
return ((uint32_t)JGDReadROM8(off) << 24)
| ((uint32_t)JGDReadROM8(off + 1) << 16)
| ((uint32_t)JGDReadROM8(off + 2) << 8)
| JGDReadROM8(off + 3);
}
return GET32(jaguarMainROM, address - 0x800000);
}
@@ -533,6 +572,11 @@ void m68k_write_memory_8(unsigned int address, unsigned int value)
// Note that the Jaguar only has 2M of RAM, not 4!
if ((address >= 0x000000) && (address <= 0x1FFFFF))
jaguarMainRAM[address] = value;
/* GameDrive: GD_ROMWriteEnable makes the SDRAM-backed "ROM" writable
* (the GD menu loads through this; homebrew uses cart space as RAM). */
else if (jgdActive && jgdWriteEnabled
&& (address >= 0x800000) && (address <= 0xDFFEFF))
JGDWriteROM8(address - 0x800000, (uint8_t)value);
/* Memory Track byte writes: cart space is otherwise read-only, so this
* branch exists purely for the device (command window + NVRAM). */
else if (((address >= 0x800000) && (address <= 0x87FFFF))
@@ -571,6 +615,13 @@ void m68k_write_memory_16(unsigned int address, unsigned int value)
{
SET16(jaguarMainRAM, address, value);
}
/* GameDrive write-enabled cart space (see the byte handler). */
else if (jgdActive && jgdWriteEnabled
&& (address >= 0x800000) && (address <= 0xDFFEFE))
{
JGDWriteROM8(address - 0x800000, (uint8_t)(value >> 8));
JGDWriteROM8(address - 0x800000 + 1, (uint8_t)(value & 0xFF));
}
/* Memory Track device writes: the flash command addresses live inside the
* $8xxxxx ROM window, but the NVRAM itself is a separate window at
* $900000 -- both have to be routed here or saves silently go nowhere. */
@@ -659,7 +710,11 @@ uint8_t JaguarReadByte(uint32_t offset, uint32_t who)
if (offset < 0x800000)
return jaguarMainRAM[offset & 0x1FFFFF];
else if ((offset >= 0x800000) && (offset < 0xDFFF00))
{
if (JGD_BANKING())
return JGDReadROM8(offset - 0x800000);
return jaguarMainROM[offset - 0x800000];
}
else if ((offset >= 0xDFFF00) && (offset <= 0xDFFFFF))
return CDROMReadByte(offset, who);
else if ((offset >= 0xE00000) && (offset < 0xE40000))
@@ -684,6 +739,8 @@ uint16_t JaguarReadWord(uint32_t offset, uint32_t who)
else if ((offset >= 0x800000) && (offset < 0xDFFF00))
{
offset -= 0x800000;
if (JGD_BANKING())
return (JGDReadROM8(offset) << 8) | JGDReadROM8(offset + 1);
return (jaguarMainROM[offset+0] << 8) | jaguarMainROM[offset+1];
}
// else if ((offset >= 0xDFFF00) && (offset < 0xDFFF00))
@@ -723,6 +780,13 @@ void JaguarWriteByte(uint32_t offset, uint8_t data, uint32_t who)
}
else if (offset < 0x800000)
return;
/* GameDrive write-enabled cart space (GPU/DSP/blitter writers). */
else if (jgdActive && jgdWriteEnabled
&& (offset >= 0x800000) && (offset < 0xDFFF00))
{
JGDWriteROM8(offset - 0x800000, data);
return;
}
else if ((offset >= 0xDFFF00) && (offset <= 0xDFFFFF))
{
CDROMWriteByte(offset, data, who);
@@ -756,6 +820,14 @@ void JaguarWriteWord(uint32_t offset, uint16_t data, uint32_t who)
}
else if (offset <= 0x7FFFFE)
return;
/* GameDrive write-enabled cart space (GPU/DSP/blitter writers). */
else if (jgdActive && jgdWriteEnabled
&& offset >= 0x800000 && offset < 0xDFFF00)
{
JGDWriteROM8(offset - 0x800000, (uint8_t)(data >> 8));
JGDWriteROM8(offset - 0x800000 + 1, (uint8_t)(data & 0xFF));
return;
}
else if (offset >= 0xDFFF00 && offset <= 0xDFFFFE)
{
CDROMWriteWord(offset, data, who);
@@ -859,6 +931,10 @@ void JaguarInit(void)
JERRYInit();
CDROMInit();
/* Fresh content boundary: drop any GameDrive image/state a previous
* load in this process left behind (JGDLoadROM re-arms it for JST_ROM
* content when the option + image size call for it). */
JGDUnload();
}
/* New timer based code stuffola... */
@@ -976,6 +1052,10 @@ void JaguarReset(void)
if (bootConfig.strategy && bootConfig.strategy->reset)
bootConfig.strategy->reset();
/* GameDrive: console reset returns the ASIC to identity pages,
* write-protected, SPI idle; the game re-runs GD_Install itself. */
JGDReset();
// Contents of local RAM are quasi-stable; we simulate this by randomizing RAM contents.
// Skip over any region where a RAM-loaded executable resides so we don't wipe it out.
// In HLE (no-BIOS) mode, zero-fill instead: the real BIOS clears most of RAM
@@ -1160,6 +1240,7 @@ void JaguarDone(void)
DSPDone();
TOMDone();
JERRYDone();
JGDDone();
m68k_done();
}
@@ -1193,6 +1274,29 @@ static void M68KExecuteWithStalls(uint32_t cycles)
if (cycles == 0)
return;
}
/* The M68K clock scale applies to the cycles the CPU actually
* executes, AFTER the stall deduction: the stall models real bus
* occupancy (OP fetch, DRAM refresh) in wall time, which an
* overclocked CPU on modified hardware would still sit out in
* full. At 100 the else branch is the exact pre-existing path.
*
* Non-100 uses error diffusion: the remainder in hundredths of a
* cycle carries to the next slice, so 0.5x is exact over time and a
* slice whose scaled budget is zero genuinely skips -- passing 0 to
* m68k_execute() would run one instruction regardless (UAE
* do/while), which is precisely the underclock-defeating edge the
* review flagged. */
if (m68kClockScalePct != 100u)
{
uint64_t budget = (uint64_t)cycles * m68kClockScalePct
+ m68kScaleAccum;
uint32_t scaled = (uint32_t)(budget / 100u);
m68kScaleAccum = (uint32_t)(budget % 100u);
if (scaled == 0)
return;
m68k_execute(scaled);
return;
}
m68k_execute(cycles);
}
@@ -1216,11 +1320,20 @@ void JaguarExecuteNew(void)
/* GPUBeginSlice/GPUSliceRemaining: part of the GPU's slice may already
* have been run from GPUSyncToM68K(), so the end-of-slice call runs only
* what is left. The total per slice is unchanged -- see the comment on
* gpuSliceBudget in gpu.c. */
* gpuSliceBudget in gpu.c.
*
* Clock scales (issue #314) apply here, where the budgets are
* handed out: the RISC scale widens the GPU+DSP compute budget per
* slice, the M68K scale is applied inside M68KExecuteWithStalls()
* after the (unscaled, wall-time) bus-occupancy stall. Event
* scheduling (timeDelta, EVENT_MAIN/EVENT_JERRY) stays on the real
* sysclock, so video, PIT/UART timers and I2S sample pacing are
* untouched -- more DSP cycles run between I2S interrupts, but the
* interrupts (and thus audio pitch) keep their stock rate. */
if (timeToJerryEvent < timeToMainEvent)
{
timeDelta = timeToJerryEvent;
riscCycles = USEC_TO_RISC_CYCLES(timeDelta);
riscCycles = SCALE_RISC_CYCLES(USEC_TO_RISC_CYCLES(timeDelta));
GPUBeginSlice(riscCycles);
M68KExecuteWithStalls(USEC_TO_M68K_CYCLES(timeDelta));
GPUExec(GPUSliceRemaining());
@@ -1231,7 +1344,7 @@ void JaguarExecuteNew(void)
else
{
timeDelta = timeToMainEvent;
riscCycles = USEC_TO_RISC_CYCLES(timeDelta);
riscCycles = SCALE_RISC_CYCLES(USEC_TO_RISC_CYCLES(timeDelta));
GPUBeginSlice(riscCycles);
M68KExecuteWithStalls(USEC_TO_M68K_CYCLES(timeDelta));
GPUExec(GPUSliceRemaining());
@@ -1239,7 +1352,7 @@ void JaguarExecuteNew(void)
SubtractEventTimes(timeDelta, EVENT_JERRY);
HandleNextEvent(EVENT_MAIN);
}
PERF_ADD(timing_m68k_cycles, (unsigned long long)USEC_TO_M68K_CYCLES(timeDelta));
PERF_ADD(timing_risc_cycles, (unsigned long long)USEC_TO_RISC_CYCLES(timeDelta));
PERF_ADD(timing_m68k_cycles, (unsigned long long)SCALE_M68K_CYCLES(USEC_TO_M68K_CYCLES(timeDelta)));
PERF_ADD(timing_risc_cycles, (unsigned long long)SCALE_RISC_CYCLES(USEC_TO_RISC_CYCLES(timeDelta)));
} while(!frameDone);
}
+17
View File
@@ -59,6 +59,23 @@ extern uint32_t jaguarLoadedRAMStart, jaguarLoadedRAMEnd;
#define SYSTEM_CLOCK_RATE (vjs.hardwareTypeNTSC ? RISC_CLOCK_RATE_NTSC : RISC_CLOCK_RATE_PAL)
#define M68K_CLOCK_RATE (vjs.hardwareTypeNTSC ? M68K_CLOCK_RATE_NTSC : M68K_CLOCK_RATE_PAL)
/* Clock-scale enhancement levers (issue #314), in percent of the stock
* rate (100 = stock). Config, not state: set from the core options in
* libretro.c::check_variables(), never serialized. Applied ONLY where
* execution budgets are handed out (JaguarExecuteNew() timeslices and
* the 68K->GPU 2:1 coupling in GPUSyncToM68K()) -- bus costs
* (bus_arbiter_m68k_access() DRAM latencies, OP-fetch/refresh occupancy,
* blitter occupancy) and all event scheduling (video, PIT, UART, I2S)
* stay on the real, unscaled sysclock. At 100 the integer arithmetic
* (c * 100 / 100) is an exact identity, so 1x is bit-identical to the
* unscaled build. */
extern uint32_t m68kClockScalePct;
void M68KClockScaleReset(void);
extern uint32_t riscClockScalePct;
#define SCALE_M68K_CYCLES(c) ((uint32_t)(((uint64_t)(c) * m68kClockScalePct) / 100u))
#define SCALE_RISC_CYCLES(c) ((uint32_t)(((uint64_t)(c) * riscClockScalePct) / 100u))
// Stuff for IRQ handling
#define ASSERT_LINE 1
+48 -7
View File
@@ -18,26 +18,50 @@ extern "C" {
#endif
/* Save state format identifier and version.
* v4: CDROM chunk gained the DSA response queue + serial-delay counter.
* v1: original layout, written only by release v2.2.0 (savestate support
* first shipped there; the bump to v2 shipped in v2.3.0).
* v2: DAC chunk gained the I2S resampler fields (i2sWritePos,
* i2sWriteCount, i2sPhase, i2sRateRatio).
* v3: DAC chunk gained i2sNonZeroCount.
* v4: CDROM chunk restructured — the two 2628-byte staging buffers
* (cdBuf2/cdBuf3) the pre-CD-support layout carried were dropped and
* replaced by the BUTCH/FIFO/DSA/SSI working set, and the chunk gained
* the DSA response queue + serial-delay counter.
* v5: CDROM chunk gained the latched drive speed (DSA Set Mode $15nn).
* v6: new UART chunk (JERRY async serial + jlink RX ring).
* v7: Memory Track chunk gained the latched $80AAA8 override flag and
* the NVM BIOS dispatcher state (nvmbios.c); trailing bus-arbiter
* 68K self-cost carry (symmetric DRAM timing). One shared bump —
* all in-flight changes since the last release use v7. */
* all in-flight changes since the last release use v7.
* v8: trailing Jaguar GameDrive chunk (bank pages + SPI mailbox engine,
* jaggd.c). One shared bump — all in-flight changes since the
* v3.1.0 release use v8. */
#define STATE_MAGIC 0x564A5353 /* "VJSS" */
#define STATE_VERSION 7
#define STATE_VERSION 8
/* Oldest layout retro_unserialize still accepts. States between
* STATE_MIN_VERSION and STATE_VERSION load by skipping the fields added
* after them (see DACStateLoad, CDROMStateLoad); STATE_VERSION is always
* what we write. */
#define STATE_MIN_VERSION 2
* STATE_MIN_VERSION and STATE_VERSION load by reading each chunk in the
* layout the header version names (see DACStateLoad, CDROMStateLoad);
* STATE_VERSION is always what we write.
*
* Released cores wrote exactly four versions: 1 (v2.2.0), 2 (v2.3.0 and
* v2.3.1), 3 (v2.3.2) and 7 (v3.0.0, v3.1.0). Versions 4-6 existed only
* on develop/nightlies. All four released layouts load (issue #268). */
#define STATE_MIN_VERSION 1
/* Per-field version gates. A module loader that has to skip a field an
* older layout did not carry compares the header version against the
* constant naming that field, never a bare literal. */
/* First version whose DAC block carries the I2S resampler fields
* (i2sWritePos, i2sWriteCount, i2sPhase, i2sRateRatio). */
#define STATE_VERSION_DAC_I2S_RESAMPLER 2
/* First version whose DAC block carries i2sNonZeroCount. */
#define STATE_VERSION_DAC_I2S_NONZEROCOUNT 3
/* First version whose CDROM block carries the post-CD-support working set
* (cdrom_eeprom_ram, the BUTCH/FIFO/DSA flags and the SSI head) in place
* of the two cdBuf2/cdBuf3 staging buffers the original layout carried.
* Every released core below this wrote the old shape; see
* CDROMStateLoad. */
#define STATE_VERSION_CDROM_RESTRUCTURE 4
/* First version whose CDROM block carries the DSA response queue and
* serial-delay counter. */
#define STATE_VERSION_CDROM_DSA_QUEUE 4
@@ -50,6 +74,23 @@ extern "C" {
/* First version carrying the trailing bus-arbiter 68K carry (symmetric
* DRAM self-cost). */
#define STATE_VERSION_BUS_ARBITER 7
/* First version whose DAC block carries the I2S hardware registers
* themselves (LTXD/RTXD/SCLK/SMODE at $F1A148-$F1A157, plus the dual
* read-side registers LRXD/RRXD/SSTAT).
*
* These sit in jagMemSpace, which no STATE_SAVE_BUF covers: the state
* saves jaguarMainRAM (the low 2 MB of jagMemSpace), tomRam8, and
* jerry_ram_8 — and jerry_ram_8 is a separate array in jerry.c, not the
* $F10000 window of jagMemSpace. So every DAC register was restored as
* whatever the previous run happened to leave behind. DACPrepareFrame
* seeds the resampler's interpolation endpoints from LTXD/RTXD, which
* made the first frame after a rollback differ (see
* test/tools/test_runahead_determinism.c). */
#define STATE_VERSION_DAC_REGISTERS 8
/* First version carrying the trailing Jaguar GameDrive chunk. Older
* states load with the GD at its reset state (identity pages, write
* protect, idle SPI) — see JGDStateLoad / the caller's else branch. */
#define STATE_VERSION_JAGGD 8
/* Header flags */
#define STATE_FLAG_MEMTRACK 0x01
+62 -4
View File
@@ -358,6 +358,19 @@ size_t DACStateSave(uint8_t *buf)
STATE_SAVE_VAR(buf, i2sPhase);
STATE_SAVE_VAR(buf, i2sRateRatio);
/* v8: the I2S hardware registers themselves. They live in
* jagMemSpace at $F1A148-$F1A157, which no STATE_SAVE_BUF covers —
* jerry_ram_8 is a separate array, not that window — so before this
* they survived a load only by accident, as whatever the previous
* run left behind. See STATE_VERSION_DAC_REGISTERS. */
STATE_SAVE_VAR(buf, *ltxd);
STATE_SAVE_VAR(buf, *rtxd);
STATE_SAVE_VAR(buf, *sclk);
STATE_SAVE_VAR(buf, *smode);
STATE_SAVE_VAR(buf, lrxd);
STATE_SAVE_VAR(buf, rrxd);
STATE_SAVE_VAR(buf, sstat);
return (size_t)(buf - start);
}
@@ -368,8 +381,25 @@ size_t DACStateLoad(const uint8_t *buf, uint32_t stateVersion)
STATE_LOAD_VAR(buf, bufferIndex);
STATE_LOAD_VAR(buf, numberOfSamples);
STATE_LOAD_VAR(buf, bufferDone);
STATE_LOAD_VAR(buf, i2sWritePos);
STATE_LOAD_VAR(buf, i2sWriteCount);
/* The I2S resampler fields were added in
* STATE_VERSION_DAC_I2S_RESAMPLER; a v1 state (written only by
* release v2.2.0) carries none of them. Consume nothing and fall
* back to the DACInit() defaults — DACPrepareFrame re-seeds
* writePos/writeCount, truncates the phase, and re-derives the rate
* ratio from the restored SMODE/SCLK registers at the top of the
* next retro_run, before any sample is resampled, so the defaults
* never reach the audio output. Reading fields the layout does not
* carry would desync every module that follows. */
if (stateVersion >= STATE_VERSION_DAC_I2S_RESAMPLER)
{
STATE_LOAD_VAR(buf, i2sWritePos);
STATE_LOAD_VAR(buf, i2sWriteCount);
}
else
{
i2sWritePos = 0;
i2sWriteCount = 0;
}
/* i2sNonZeroCount was added in STATE_VERSION_DAC_I2S_NONZEROCOUNT.
* Older states do not carry it, so consume nothing and start from the
* value DACPrepareFrame would establish; reading it would desync every
@@ -378,8 +408,36 @@ size_t DACStateLoad(const uint8_t *buf, uint32_t stateVersion)
STATE_LOAD_VAR(buf, i2sNonZeroCount);
else
i2sNonZeroCount = 0;
STATE_LOAD_VAR(buf, i2sPhase);
STATE_LOAD_VAR(buf, i2sRateRatio);
if (stateVersion >= STATE_VERSION_DAC_I2S_RESAMPLER)
{
STATE_LOAD_VAR(buf, i2sPhase);
STATE_LOAD_VAR(buf, i2sRateRatio);
}
else
{
i2sPhase = 0.0;
i2sRateRatio = 1.0;
}
/* The I2S hardware registers (see DACStateSave). Layouts older than
* STATE_VERSION_DAC_REGISTERS carry no slot for them; consume nothing
* and leave them as they are, which is the behaviour those states were
* written against. Reading fields the layout does not carry would
* desync every module that follows. */
if (stateVersion >= STATE_VERSION_DAC_REGISTERS)
{
STATE_LOAD_VAR(buf, *ltxd);
STATE_LOAD_VAR(buf, *rtxd);
STATE_LOAD_VAR(buf, *sclk);
STATE_LOAD_VAR(buf, *smode);
STATE_LOAD_VAR(buf, lrxd);
STATE_LOAD_VAR(buf, rrxd);
STATE_LOAD_VAR(buf, sstat);
/* The resample ratio is derived from SCLK/SMODE, so re-derive it
* now that they hold the restored values rather than the ones the
* previous run left behind. */
DACUpdateSCLKRate();
}
return (size_t)(buf - start);
}
+22
View File
@@ -161,6 +161,7 @@
#include "dsp.h"
#include "eeprom.h"
#include "event.h"
#include "jaggd.h"
#include "jaguar.h"
#include "perf_counters.h"
@@ -495,6 +496,12 @@ uint8_t JERRYReadByte(uint32_t offset, uint32_t who/*=UNKNOWN*/)
// This is wrong, should only have the lowest bit from $F14001
return value | EepromReadByte(offset);
}
/* JagGD SPI mailbox (GPIO2). Must sit above the EEPROM catch-all,
* which otherwise swallows these addresses and returns $0000. When
* the GameDrive is not active we deliberately fall through: a $0000
* status read reproduces the stock-console "GD absent" hang. */
else if (jgdActive && offset >= JGD_REG_FIRST && offset <= JGD_REG_LAST)
return JGDControlReadByte(offset);
else if (offset >= 0xF14000 && offset <= 0xF1A0FF)
return EepromReadByte(offset);
@@ -550,6 +557,9 @@ uint16_t JERRYReadWord(uint32_t offset, uint32_t who/*=UNKNOWN*/)
return (JoystickReadWord(offset) & 0xFFFE) | EepromReadWord(offset);
else if ((offset >= 0xF14002) && (offset < 0xF14003))
return JoystickReadWord(offset);
/* JagGD SPI mailbox (GPIO2) -- see JERRYReadByte. */
else if (jgdActive && offset >= JGD_REG_FIRST && offset <= JGD_REG_LAST)
return JGDControlReadWord(offset);
else if ((offset >= 0xF14000) && (offset <= 0xF1A0FF))
return EepromReadWord(offset);
@@ -630,6 +640,12 @@ void JERRYWriteByte(uint32_t offset, uint8_t data, uint32_t who/*=UNKNOWN*/)
EepromWriteByte(offset, data);
return;
}
/* JagGD SPI mailbox (GPIO2) -- see JERRYReadByte. */
else if (jgdActive && offset >= JGD_REG_FIRST && offset <= JGD_REG_LAST)
{
JGDControlWriteByte(offset, data);
return;
}
else if ((offset >= 0xF14000) && (offset <= 0xF1A0FF))
{
EepromWriteByte(offset, data);
@@ -725,6 +741,12 @@ void JERRYWriteWord(uint32_t offset, uint16_t data, uint32_t who/*=UNKNOWN*/)
EepromWriteWord(offset, data);
return;
}
/* JagGD SPI mailbox (GPIO2) -- see JERRYReadByte. */
else if (jgdActive && offset >= JGD_REG_FIRST && offset <= JGD_REG_LAST)
{
JGDControlWriteWord(offset, data);
return;
}
else if (offset >= 0xF14000 && offset <= 0xF1A0FF)
{
EepromWriteWord(offset, data);
+20 -11
View File
@@ -1259,7 +1259,7 @@ void ADDARRAY(uint16_t *addq, uint8_t daddasel, uint8_t daddbsel,
sat = daddmode & 0x03;
hicinh = ((daddmode & 0x03) == 0x03);
blitter_simd_ops.add16sat_x4(addq, co, adda, addb, cin, sat, eightbit, hicinh);
blitter_simd_add16sat_x4(addq, co, adda, addb, cin, sat, eightbit, hicinh);
}
static BLITTER_ALWAYS_INLINE
@@ -1376,7 +1376,7 @@ static BLITTER_ALWAYS_INLINE
void DATA(uint64_t *wdata, uint8_t *dcomp, uint8_t *zcomp, bool *nowrite,
bool big_pix, bool cmpdst, uint8_t daddasel, uint8_t daddbsel, uint8_t daddmode, bool daddq_sel, uint8_t data_sel,
uint8_t dbinh, uint8_t dend, uint8_t dstart, uint64_t dstd, uint32_t iinc, uint8_t lfu_func, uint64_t *patd, bool patdadd,
bool phrase_mode, uint64_t srcd, bool srcdread, bool srczread, bool srcz2add, uint8_t zmode,
bool phrase_mode, uint64_t srcd, uint64_t srcd_cmp, bool srcdread, bool srczread, bool srcz2add, uint8_t zmode,
bool bcompen, bool bkgwren, bool dcompen, uint8_t icount, uint8_t pixsize,
uint64_t *srcz, uint64_t dstz, uint32_t zinc)
{
@@ -1424,7 +1424,7 @@ Patdhi := JOIN (patdhi, patd[32..63]);*/
/*Lfu := LFU (lfu[0..1], srcdlo, srcdhi, dstdlo, dstdhi, lfu_func[0..3]);*/
////////////////////////////////////// C++ CODE //////////////////////////////////////
uint64_t lfu = blitter_simd_ops.lfu(srcd, dstd, lfu_func);
uint64_t lfu = blitter_simd_lfu(srcd, dstd, lfu_func);
bool mir_bit, mir_byte;
uint16_t masku;
uint8_t e_coarse, e_fine;
@@ -1457,7 +1457,10 @@ Zstep := JOIN (zstep, zstep[0..31]);*/
/*Datacomp := DATACOMP (dcomp[0..7], cmpdst, dstdlo, dstdhi, patdlo, patdhi, srcdlo, srcdhi);*/
////////////////////////////////////// C++ CODE //////////////////////////////////////
*dcomp = blitter_simd_ops.dcomp(*patd, srcd, dstd, cmpdst);
/* srcd_cmp is the source data as read from memory; srcd may carry the
* SRCSHADE intensity offset. The transparency comparison keys on the
* unshaded pixel -- see the SRCSHADE block in BlitterMidsummer2. */
*dcomp = blitter_simd_dcomp(*patd, srcd_cmp, dstd, cmpdst);
/* Source-pixel transparency for DCOMPEN+!CMPDST.
*
@@ -1500,7 +1503,7 @@ Zstep := JOIN (zstep, zstep[0..31]);*/
* borrow -- e.g. s=0x0001_0000 spuriously flags byte 2 as zero
* because the borrow chain propagates the wrong way. The loop
* is honest and compiles to predictable code on every target.) */
uint64_t s = srcd;
uint64_t s = srcd_cmp;
uint8_t zero_mask = 0;
unsigned i;
for (i = 0; i < 8; i++)
@@ -1523,7 +1526,7 @@ Zstep := JOIN (zstep, zstep[0..31]);*/
with srcshift bits 4 & 5 selecting the start position
*/
//So... basically what we have here is:
*zcomp = blitter_simd_ops.zcomp(*srcz, dstz, zmode);
*zcomp = blitter_simd_zcomp(*srcz, dstz, zmode);
//TEMP, TO TEST IF ZCOMP IS THE CULPRIT...
//Nope, this is NOT the problem...
@@ -1778,8 +1781,8 @@ Dat[40-47] := MX4 (dat[40-47], dstdhi{8-15}, ddathi{8-15}, dstzhi{8-15}, srcz
Dat[48-55] := MX4 (dat[48-55], dstdhi{16-23}, ddathi{16-23}, dstzhi{16-23}, srczhi{16-23}, mask[13], zed_selb[1]);
Dat[56-63] := MX4 (dat[56-63], dstdhi{24-31}, ddathi{24-31}, dstzhi{24-31}, srczhi{24-31}, mask[14], zed_selb[1]);*/
////////////////////////////////////// C++ CODE //////////////////////////////////////
*wdata = blitter_simd_ops.byte_merge(ddat, dstd, mask);
*srcz = blitter_simd_ops.byte_merge(*srcz, dstz, mask);
*wdata = blitter_simd_byte_merge(ddat, dstd, mask);
*srcz = blitter_simd_byte_merge(*srcz, dstz, mask);
//////////////////////////////////////////////////////////////////////////////////////
/*Data_enab[0-1] := BUF8 (data_enab[0-1], data_ena);
@@ -2266,7 +2269,7 @@ void BlitterMidsummer2(void)
false/*daddq_sel*/,
0/*data_sel*/, 0/*dbinh*/, pf_dend, pf_dstart, pf_dstd_local,
iinc, lfufunc, &patd, false/*patdadd*/,
phrase_mode, 0/*srcd*/, false/*srcdread*/, false/*srczread*/,
phrase_mode, 0/*srcd*/, 0/*srcd_cmp*/, false/*srcdread*/, false/*srczread*/,
false/*srcz2add*/, zmode,
bcompen, bkgwren, dcompen, icount & 0x07, pixsize,
&srcz, dstz, zinc);
@@ -2585,7 +2588,7 @@ void BlitterMidsummer2(void)
false/*daddq_sel*/, 1/*data_sel=LFU*/, 0/*dbinh*/,
fc_dend, fc_dstart, dstd, iinc, lfufunc, &patd,
false/*patdadd*/,
phrase_mode, fc_srcd, false/*srcdread*/, false/*srczread*/,
phrase_mode, fc_srcd, fc_srcd/*srcd_cmp*/, false/*srcdread*/, false/*srczread*/,
false/*srcz2add*/, zmode,
false/*bcompen*/, bkgwren, false/*dcompen*/,
icount & 0x07, pixsize,
@@ -3034,6 +3037,7 @@ A2ptrldi := NAN2 (a2ptrldi, a2update\, a2pldt);*/
uint16_t oldicount;
uint8_t dstart = 0;
uint8_t ppp;
uint64_t srcd_cmp;
#ifdef BENCH_PROFILE
blitter_did_io = 1;
#endif
@@ -3176,6 +3180,11 @@ A2ptrldi := NAN2 (a2ptrldi, a2update\, a2pldt);*/
//after the add?
//Dest write address/pix address: 0014E83E/0 [dstart=0 dend=10 pwidth=8 srcshift=0][daas=4 dabs=5 dam=7 ds=1 daq=F] [0000000000006505] (icount=003F, inc=1)
//Let's try this:
/* The intensity offset belongs to the write data only. DCOMPEN
* transparency keys on the source pixel as read from memory, so
* keep an unshaded copy for the comparator: shading a
* transparent (PATD-matching) pixel must not make it opaque. */
srcd_cmp = srcd;
if (srcshade)
{
uint16_t addq[4];
@@ -3223,7 +3232,7 @@ A2ptrldi := NAN2 (a2ptrldi, a2update\, a2pldt);*/
DATA(&wdata, &dcomp, &zcomp, &winhibit,
true, cmpdst, daddasel, daddbsel, daddmode, daddq_sel, data_sel, 0/*dbinh*/,
dend, dstart, dstd, iinc, lfufunc, &patd, patdadd,
phrase_mode, srcd, false/*srcdread*/, false/*srczread*/, srcz2add, zmode,
phrase_mode, srcd, srcd_cmp, false/*srcdread*/, false/*srczread*/, srcz2add, zmode,
bcompen, bkgwren, dcompen, icount & 0x07, pixsize,
&srcz, dstz, zinc);
+43 -4
View File
@@ -2,11 +2,24 @@
* SIMD-accelerated blitter operations for Virtual Jaguar
*
* Provides architecture-specific implementations of the blitter's
* hottest data-path operations. Only one implementation file is
* compiled per build (selected in Makefile.common).
* hottest data-path operations. Only one implementation is active per
* build; the arch is selected in Makefile.common, which compiles the
* matching blitter_simd_<arch>.c *and* passes -DBLITTER_SIMD_<ARCH>.
*
* Each arch file defines:
* const blitter_simd_ops_t blitter_simd_ops = { ... };
* The implementations live in blitter_simd_<arch>.h as static inline
* functions so BlitterMidsummer2 can inline them. That matters: these
* are a handful of SIMD instructions each, called up to six times per
* inner-loop iteration, and Tempest 2000 runs ~99k inner iterations per
* frame. Reaching them through the blitter_simd_ops function-pointer
* table (a different translation unit, and no LTO on any desktop
* target) cost ~21% of accurate-blitter runtime in indirect-call and
* argument-marshalling overhead, and it also defeated the per-call-site
* specialisation that ADDARRAY's BLITTER_ALWAYS_INLINE exists to enable
* -- sat/eightbit/hicinh are compile-time constants at those sites.
*
* blitter_simd_ops still exists, and each arch .c file builds it out of
* thin wrappers around the very same inline functions, so
* test/test_blitter_simd.c validates exactly the code the core runs.
*/
#ifndef BLITTER_SIMD_H
@@ -15,6 +28,16 @@
#include <stdint.h>
#include <boolean.h>
/* Portable always-inline, spelled to include the inline keyword itself
* (MSVC's __forceinline IS the inline keyword for that compiler). */
#if defined(_MSC_VER)
# define BLITTER_SIMD_INLINE __forceinline
#elif defined(__GNUC__) || defined(__clang__)
# define BLITTER_SIMD_INLINE inline __attribute__((always_inline))
#else
# define BLITTER_SIMD_INLINE inline
#endif
typedef struct
{
/* Logic Function Unit: 64-bit truth table over srcd/dstd.
@@ -59,4 +82,20 @@ typedef struct
extern const blitter_simd_ops_t blitter_simd_ops;
/* Pull in the selected implementation as static inline functions:
* blitter_simd_lfu / _dcomp / _zcomp / _byte_merge / _add16sat_x4
*
* Makefile.common defines exactly one of these to match the .c file it
* added to SOURCES_C. Builds that don't go through it (ndk-build, and
* every MSVC target) get scalar, which is what those already selected.
* A mismatch between the -D and the compiled .c is a hard compile error
* rather than a silent slow path -- see blitter_simd_<arch>.c. */
#if defined(BLITTER_SIMD_NEON)
# include "blitter_simd_neon.h"
#elif defined(BLITTER_SIMD_SSE2)
# include "blitter_simd_sse2.h"
#else
# include "blitter_simd_scalar.h"
#endif
#endif /* BLITTER_SIMD_H */
+34 -275
View File
@@ -1,298 +1,57 @@
/*
* Blitter SIMD ops — ARM NEON implementation
* Blitter SIMD ops -- out-of-line neon vtable.
*
* NEON is mandatory on AArch64 and available on most ARMv7-A with
* VFPv3. The Makefile selects this file when targeting ARM64 or
* when HAVE_NEON=1.
* The implementations themselves live in blitter_simd_neon.h so that
* blitter.c can inline them (see blitter_simd.h). This file only
* provides the function-pointer table, which test/test_blitter_simd.c
* uses to exercise the very same code the core runs.
*/
/* Select our own arch before blitter_simd.h picks one. This file IS
* the neon implementation, so it must get the neon inline set even when
* compiled standalone without the Makefile's -DBLITTER_SIMD_NEON --
* as .github/workflows/c-cpp.yml does when it builds
* test_blitter_simd by hand. Without this the header chain pulled in
* the scalar set as well and the two collided. */
#ifndef BLITTER_SIMD_NEON
#define BLITTER_SIMD_NEON 1
#endif
#include "blitter_simd.h"
#include <arm_neon.h>
#include "blitter_simd_neon.h"
/* Logic Function Unit — NEON
*
* Same truth-table approach as scalar, but using 64-bit NEON
* integer operations (uint64x1_t).
*/
static uint64_t neon_lfu(uint64_t srcd, uint64_t dstd, uint8_t lfu_func)
static uint64_t ops_lfu(uint64_t srcd, uint64_t dstd, uint8_t lfu_func)
{
uint64x1_t vs = vcreate_u64(srcd);
uint64x1_t vd = vcreate_u64(dstd);
/* NEON vmvn only works up to 32-bit, so we use EOR with all-ones */
uint64x1_t ones = vcreate_u64(0xFFFFFFFFFFFFFFFFULL);
uint64x1_t vns = veor_u64(vs, ones); /* ~srcd */
uint64x1_t vnd = veor_u64(vd, ones); /* ~dstd */
uint64x1_t vf0 = vcreate_u64((lfu_func & 0x01) ? 0xFFFFFFFFFFFFFFFFULL : 0);
uint64x1_t vf1 = vcreate_u64((lfu_func & 0x02) ? 0xFFFFFFFFFFFFFFFFULL : 0);
uint64x1_t vf2 = vcreate_u64((lfu_func & 0x04) ? 0xFFFFFFFFFFFFFFFFULL : 0);
uint64x1_t vf3 = vcreate_u64((lfu_func & 0x08) ? 0xFFFFFFFFFFFFFFFFULL : 0);
/* (~s & ~d & f0) | (~s & d & f1) | (s & ~d & f2) | (s & d & f3) */
uint64x1_t t0 = vand_u64(vand_u64(vns, vnd), vf0);
uint64x1_t t1 = vand_u64(vand_u64(vns, vd), vf1);
uint64x1_t t2 = vand_u64(vand_u64(vs, vnd), vf2);
uint64x1_t t3 = vand_u64(vand_u64(vs, vd), vf3);
uint64x1_t result = vorr_u64(vorr_u64(t0, t1), vorr_u64(t2, t3));
return vget_lane_u64(result, 0);
return blitter_simd_lfu(srcd, dstd, lfu_func);
}
/* Data Comparator — NEON
*
* XOR + per-byte zero check. NEON vceq gives 0xFF per matching byte,
* then we extract one bit per byte.
*/
static uint8_t neon_dcomp(uint64_t patd, uint64_t srcd, uint64_t dstd, bool cmpdst)
static uint8_t ops_dcomp(uint64_t patd, uint64_t srcd, uint64_t dstd, bool cmpdst)
{
uint64_t other = cmpdst ? dstd : srcd;
uint8x8_t vp = vreinterpret_u8_u64(vcreate_u64(patd));
uint8x8_t vo = vreinterpret_u8_u64(vcreate_u64(other));
/* XOR then compare each byte to zero */
uint8x8_t vxor = veor_u8(vp, vo);
uint8x8_t vzero = vdup_n_u8(0);
uint8x8_t vcmp = vceq_u8(vxor, vzero); /* 0xFF where equal, 0 otherwise */
/* Convert compare lanes from 0xFF/0x00 to 1/0, then pack into
* the result byte via lane extraction — avoids weights load
* and vpadd chain. */
uint8x8_t vbits = vshr_n_u8(vcmp, 7);
return (uint8_t)(
(vget_lane_u8(vbits, 0) << 0) |
(vget_lane_u8(vbits, 1) << 1) |
(vget_lane_u8(vbits, 2) << 2) |
(vget_lane_u8(vbits, 3) << 3) |
(vget_lane_u8(vbits, 4) << 4) |
(vget_lane_u8(vbits, 5) << 5) |
(vget_lane_u8(vbits, 6) << 6) |
(vget_lane_u8(vbits, 7) << 7));
return blitter_simd_dcomp(patd, srcd, dstd, cmpdst);
}
/* Z-buffer Comparator — NEON
*
* 4 packed unsigned 16-bit comparisons. NEON has native unsigned
* compare instructions (vclt_u16, vceq_u16, vcgt_u16).
*/
static uint8_t neon_zcomp(uint64_t srcz, uint64_t dstz, uint8_t zmode)
static uint8_t ops_zcomp(uint64_t srcz, uint64_t dstz, uint8_t zmode)
{
uint16x4_t vs = vreinterpret_u16_u64(vcreate_u64(srcz));
uint16x4_t vd = vreinterpret_u16_u64(vcreate_u64(dstz));
uint16x4_t vresult = vdup_n_u16(0);
if (zmode & 0x01) /* LT */
vresult = vorr_u16(vresult, vclt_u16(vs, vd));
if (zmode & 0x02) /* EQ */
vresult = vorr_u16(vresult, vceq_u16(vs, vd));
if (zmode & 0x04) /* GT */
vresult = vorr_u16(vresult, vcgt_u16(vs, vd));
/* Extract one bit per 16-bit lane.
* vresult lanes are 0xFFFF or 0x0000. Narrow and extract. */
/* Shift each lane: lane0 >> 0, lane1 >> 1, lane2 >> 2, lane3 >> 3
* But easier: just read each lane */
uint8_t result = 0;
if (vget_lane_u16(vresult, 0)) result |= 0x01;
if (vget_lane_u16(vresult, 1)) result |= 0x02;
if (vget_lane_u16(vresult, 2)) result |= 0x04;
if (vget_lane_u16(vresult, 3)) result |= 0x08;
return result;
return blitter_simd_zcomp(srcz, dstz, zmode);
}
/* Byte Mask Merge — NEON
*
* Build a byte-level mask, then use vbsl (bitwise select) to blend.
* vbsl(mask, src, dst) = (src & mask) | (dst & ~mask)
*/
static uint64_t neon_byte_merge(uint64_t src, uint64_t dst, uint16_t mask)
static uint64_t ops_byte_merge(uint64_t src, uint64_t dst, uint16_t mask)
{
/* Build 8-byte selection mask entirely in registers.
* Byte 0 uses the low 8 bits directly; bytes 1-7 are all-ones or
* all-zero based on mask bits 8-14. */
uint64_t sel =
((uint64_t)(mask & 0x00FF)) |
((uint64_t)((mask & 0x0100) ? 0xFF : 0x00) << 8) |
((uint64_t)((mask & 0x0200) ? 0xFF : 0x00) << 16) |
((uint64_t)((mask & 0x0400) ? 0xFF : 0x00) << 24) |
((uint64_t)((mask & 0x0800) ? 0xFF : 0x00) << 32) |
((uint64_t)((mask & 0x1000) ? 0xFF : 0x00) << 40) |
((uint64_t)((mask & 0x2000) ? 0xFF : 0x00) << 48) |
((uint64_t)((mask & 0x4000) ? 0xFF : 0x00) << 56);
uint8x8_t vmask = vreinterpret_u8_u64(vcreate_u64(sel));
uint8x8_t vsrc = vreinterpret_u8_u64(vcreate_u64(src));
uint8x8_t vdst = vreinterpret_u8_u64(vcreate_u64(dst));
/* vbsl: for each bit, selects src where mask=1, dst where mask=0 */
uint8x8_t result = vbsl_u8(vmask, vsrc, vdst);
return vget_lane_u64(vreinterpret_u64_u8(result), 0);
return blitter_simd_byte_merge(src, dst, mask);
}
/* ADD16SAT x4 — NEON
*
* Processes all four 16-bit lanes in parallel using NEON 16x4 vectors.
* The Jaguar's segmented carry chain (low byte / mid nibble / high nibble)
* is replicated across all 4 lanes simultaneously.
*
* Stage 1: low byte (bits 7:0) — add with per-lane cin
* Stage 2: mid nibble (bits 11:8) — add with carry from stage 1 (if !eightbit)
* Stage 3: high nibble (bits 15:12)— add with carry from stage 2 (if !hicinh)
* Then: saturation logic based on overflow detection.
*/
static void neon_add16sat_x4(uint16_t *addq, uint8_t *co,
const uint16_t *adda, const uint16_t *addb,
const uint8_t *cin,
bool sat, bool eightbit, bool hicinh)
static void ops_add16sat_x4(uint16_t *addq, uint8_t *co,
const uint16_t *adda, const uint16_t *addb,
const uint8_t *cin,
bool sat, bool eightbit, bool hicinh)
{
/* Load operands into NEON 16x4 vectors */
uint16x4_t va = vld1_u16(adda);
uint16x4_t vb = vld1_u16(addb);
/* Masks for isolating bit fields */
uint16x4_t mask_lo = vdup_n_u16(0x00FF);
uint16x4_t mask_mid = vdup_n_u16(0x0F00);
uint16x4_t mask_hi = vdup_n_u16(0xF000);
uint16x4_t vzero = vdup_n_u16(0);
uint16x4_t vone = vdup_n_u16(1);
/* Expand cin[0..3] from uint8_t to uint16x4_t */
uint16_t cin16[4];
uint16x4_t vcin;
/* Stage 1 results */
uint16x4_t va_lo, vb_lo, sum1, q_lo, carry0, carry1;
/* Stage 2 results */
uint16x4_t va_mid, vb_mid, carry_shifted1, sum2, q_mid, carry2, carry3;
/* Stage 3 results */
uint16x4_t va_hi, vb_hi, carry_shifted3, sum3, q_hi;
/* Combine result */
uint16x4_t q;
/* Saturation */
uint16x4_t btop, ctop, do_saturate;
uint16x4_t sat_lo_fill, sat_hi_fill;
uint16x4_t sat_lo_zero, sat_hi_zero;
cin16[0] = cin[0]; cin16[1] = cin[1]; cin16[2] = cin[2]; cin16[3] = cin[3];
vcin = vld1_u16(cin16);
/* Stage 1: low byte add */
va_lo = vand_u16(va, mask_lo);
vb_lo = vand_u16(vb, mask_lo);
sum1 = vadd_u16(vadd_u16(va_lo, vb_lo), vcin);
q_lo = vand_u16(sum1, mask_lo);
/* carry0 = (sum1 & 0x100) ? 1 : 0 */
carry0 = vshr_n_u16(vand_u16(sum1, vdup_n_u16(0x0100)), 8);
/* carry1 = carry0 if !eightbit, else 0 */
carry1 = eightbit ? vzero : carry0;
/* Stage 2: mid nibble add */
va_mid = vand_u16(va, mask_mid);
vb_mid = vand_u16(vb, mask_mid);
carry_shifted1 = vshl_n_u16(carry1, 8);
sum2 = vadd_u16(vadd_u16(va_mid, vb_mid), carry_shifted1);
q_mid = vand_u16(sum2, mask_mid);
/* carry2 = (sum2 & 0x1000) ? 1 : 0 */
carry2 = vshr_n_u16(vand_u16(sum2, vdup_n_u16(0x1000)), 12);
/* carry3 = carry2 if !hicinh, else 0 */
carry3 = hicinh ? vzero : carry2;
/* Stage 3: high nibble add */
va_hi = vand_u16(va, mask_hi);
vb_hi = vand_u16(vb, mask_hi);
carry_shifted3 = vshl_n_u16(carry3, 12);
sum3 = vadd_u16(vadd_u16(va_hi, vb_hi), carry_shifted3);
q_hi = vand_u16(sum3, mask_hi);
/* co[i] = (sum3 & 0x10000) ? 1 : 0 — detect carry out of bit 15.
* Since our lanes are only 16-bit, the carry is lost. Widen to
* 32-bit for the overflow detection. */
{
/* Use 32-bit arithmetic to capture the carry */
uint32_t s0 = (uint32_t)(adda[0] & 0xF000) + (addb[0] & 0xF000) + ((uint32_t)vget_lane_u16(carry3, 0) << 12);
uint32_t s1 = (uint32_t)(adda[1] & 0xF000) + (addb[1] & 0xF000) + ((uint32_t)vget_lane_u16(carry3, 1) << 12);
uint32_t s2 = (uint32_t)(adda[2] & 0xF000) + (addb[2] & 0xF000) + ((uint32_t)vget_lane_u16(carry3, 2) << 12);
uint32_t s3 = (uint32_t)(adda[3] & 0xF000) + (addb[3] & 0xF000) + ((uint32_t)vget_lane_u16(carry3, 3) << 12);
co[0] = (s0 & 0x10000) ? 1 : 0;
co[1] = (s1 & 0x10000) ? 1 : 0;
co[2] = (s2 & 0x10000) ? 1 : 0;
co[3] = (s3 & 0x10000) ? 1 : 0;
}
/* Combine low + mid + high */
q = vorr_u16(vorr_u16(q_lo, q_mid), q_hi);
/* Saturation logic */
if (sat)
{
uint16_t co16[4];
uint16x4_t vco;
uint16x4_t do_hi_saturate;
uint16x4_t result_lo, result_hi;
co16[0] = co[0]; co16[1] = co[1]; co16[2] = co[2]; co16[3] = co[3];
vco = vld1_u16(co16);
if (eightbit)
{
btop = vshr_n_u16(vand_u16(vb, vdup_n_u16(0x0080)), 7);
ctop = carry0;
}
else
{
btop = vshr_n_u16(vb, 15);
ctop = vco;
}
/* saturate = sat && (btop ^ ctop) — sat is already true here */
do_saturate = veor_u16(btop, ctop); /* non-zero where we saturate */
/* do_hi_saturate = do_saturate && !eightbit */
do_hi_saturate = eightbit ? vzero : do_saturate;
/* When saturating:
* low byte = ctop ? 0xFF : 0x00
* high byte = ctop ? 0xFF00 : 0x0000 (only if !eightbit)
* When not saturating, keep q as-is. */
/* Expand ctop/do_saturate from {0,1} to full 16-bit masks {0x0000,0xFFFF} */
{
uint16x4_t ctop_mask = vceq_u16(ctop, vone); /* 0xFFFF where ctop=1 */
uint16x4_t sat_mask = vceq_u16(do_saturate, vone); /* 0xFFFF where saturating */
uint16x4_t hi_sat_mask = vceq_u16(do_hi_saturate, vone);
/* Build saturated fill values: ctop_mask ? 0x00FF/0xFF00 : 0x0000 */
sat_lo_fill = vand_u16(ctop_mask, vdup_n_u16(0x00FF));
sat_hi_fill = vand_u16(ctop_mask, vdup_n_u16(0xFF00));
/* Build non-saturated values */
sat_lo_zero = vand_u16(q, mask_lo);
sat_hi_zero = vand_u16(q, vdup_n_u16(0xFF00));
result_lo = vbsl_u16(sat_mask, sat_lo_fill, sat_lo_zero);
result_hi = vbsl_u16(hi_sat_mask, sat_hi_fill, sat_hi_zero);
}
q = vorr_u16(result_lo, result_hi);
}
vst1_u16(addq, q);
blitter_simd_add16sat_x4(addq, co, adda, addb, cin, sat, eightbit, hicinh);
}
const blitter_simd_ops_t blitter_simd_ops = {
neon_lfu,
neon_dcomp,
neon_zcomp,
neon_byte_merge,
neon_add16sat_x4
ops_lfu,
ops_dcomp,
ops_zcomp,
ops_byte_merge,
ops_add16sat_x4
};
+309
View File
@@ -0,0 +1,309 @@
#ifndef BLITTER_SIMD_NEON_H
#define BLITTER_SIMD_NEON_H
/* Included by blitter_simd.h once the arch has been selected.
* Do not include directly -- BLITTER_SIMD_INLINE comes from there. */
#ifndef BLITTER_SIMD_INLINE
#error "include blitter_simd.h, not blitter_simd_neon.h"
#endif
#include <stdint.h>
#include <boolean.h>
#include <arm_neon.h>
/*
* Blitter SIMD ops — ARM NEON implementation
*
* NEON is mandatory on AArch64 and available on most ARMv7-A with
* VFPv3. The Makefile selects this file when targeting ARM64 or
* when HAVE_NEON=1.
*/
/* Logic Function Unit — NEON
*
* Same truth-table approach as scalar, but using 64-bit NEON
* integer operations (uint64x1_t).
*/
static BLITTER_SIMD_INLINE
uint64_t blitter_simd_lfu(uint64_t srcd, uint64_t dstd, uint8_t lfu_func)
{
uint64x1_t vs = vcreate_u64(srcd);
uint64x1_t vd = vcreate_u64(dstd);
/* NEON vmvn only works up to 32-bit, so we use EOR with all-ones */
uint64x1_t ones = vcreate_u64(0xFFFFFFFFFFFFFFFFULL);
uint64x1_t vns = veor_u64(vs, ones); /* ~srcd */
uint64x1_t vnd = veor_u64(vd, ones); /* ~dstd */
uint64x1_t vf0 = vcreate_u64((lfu_func & 0x01) ? 0xFFFFFFFFFFFFFFFFULL : 0);
uint64x1_t vf1 = vcreate_u64((lfu_func & 0x02) ? 0xFFFFFFFFFFFFFFFFULL : 0);
uint64x1_t vf2 = vcreate_u64((lfu_func & 0x04) ? 0xFFFFFFFFFFFFFFFFULL : 0);
uint64x1_t vf3 = vcreate_u64((lfu_func & 0x08) ? 0xFFFFFFFFFFFFFFFFULL : 0);
/* (~s & ~d & f0) | (~s & d & f1) | (s & ~d & f2) | (s & d & f3) */
uint64x1_t t0 = vand_u64(vand_u64(vns, vnd), vf0);
uint64x1_t t1 = vand_u64(vand_u64(vns, vd), vf1);
uint64x1_t t2 = vand_u64(vand_u64(vs, vnd), vf2);
uint64x1_t t3 = vand_u64(vand_u64(vs, vd), vf3);
uint64x1_t result = vorr_u64(vorr_u64(t0, t1), vorr_u64(t2, t3));
return vget_lane_u64(result, 0);
}
/* Data Comparator — NEON
*
* XOR + per-byte zero check. NEON vceq gives 0xFF per matching byte,
* then we extract one bit per byte.
*/
static BLITTER_SIMD_INLINE
uint8_t blitter_simd_dcomp(uint64_t patd, uint64_t srcd, uint64_t dstd, bool cmpdst)
{
uint64_t other = cmpdst ? dstd : srcd;
uint8x8_t vp = vreinterpret_u8_u64(vcreate_u64(patd));
uint8x8_t vo = vreinterpret_u8_u64(vcreate_u64(other));
/* XOR then compare each byte to zero */
uint8x8_t vxor = veor_u8(vp, vo);
uint8x8_t vzero = vdup_n_u8(0);
uint8x8_t vcmp = vceq_u8(vxor, vzero); /* 0xFF where equal, 0 otherwise */
/* Convert compare lanes from 0xFF/0x00 to 1/0, then pack into
* the result byte via lane extraction — avoids weights load
* and vpadd chain. */
uint8x8_t vbits = vshr_n_u8(vcmp, 7);
return (uint8_t)(
(vget_lane_u8(vbits, 0) << 0) |
(vget_lane_u8(vbits, 1) << 1) |
(vget_lane_u8(vbits, 2) << 2) |
(vget_lane_u8(vbits, 3) << 3) |
(vget_lane_u8(vbits, 4) << 4) |
(vget_lane_u8(vbits, 5) << 5) |
(vget_lane_u8(vbits, 6) << 6) |
(vget_lane_u8(vbits, 7) << 7));
}
/* Z-buffer Comparator — NEON
*
* 4 packed unsigned 16-bit comparisons. NEON has native unsigned
* compare instructions (vclt_u16, vceq_u16, vcgt_u16).
*/
static BLITTER_SIMD_INLINE
uint8_t blitter_simd_zcomp(uint64_t srcz, uint64_t dstz, uint8_t zmode)
{
uint16x4_t vs = vreinterpret_u16_u64(vcreate_u64(srcz));
uint16x4_t vd = vreinterpret_u16_u64(vcreate_u64(dstz));
uint16x4_t vresult = vdup_n_u16(0);
uint8_t result = 0;
if (zmode & 0x01) /* LT */
vresult = vorr_u16(vresult, vclt_u16(vs, vd));
if (zmode & 0x02) /* EQ */
vresult = vorr_u16(vresult, vceq_u16(vs, vd));
if (zmode & 0x04) /* GT */
vresult = vorr_u16(vresult, vcgt_u16(vs, vd));
/* Extract one bit per 16-bit lane.
* vresult lanes are 0xFFFF or 0x0000. Narrow and extract. */
/* Shift each lane: lane0 >> 0, lane1 >> 1, lane2 >> 2, lane3 >> 3
* But easier: just read each lane */
if (vget_lane_u16(vresult, 0)) result |= 0x01;
if (vget_lane_u16(vresult, 1)) result |= 0x02;
if (vget_lane_u16(vresult, 2)) result |= 0x04;
if (vget_lane_u16(vresult, 3)) result |= 0x08;
return result;
}
/* Byte Mask Merge — NEON
*
* Build a byte-level mask, then use vbsl (bitwise select) to blend.
* vbsl(mask, src, dst) = (src & mask) | (dst & ~mask)
*/
static BLITTER_SIMD_INLINE
uint64_t blitter_simd_byte_merge(uint64_t src, uint64_t dst, uint16_t mask)
{
/* Build 8-byte selection mask entirely in registers.
* Byte 0 uses the low 8 bits directly; bytes 1-7 are all-ones or
* all-zero based on mask bits 8-14. */
uint64_t sel =
((uint64_t)(mask & 0x00FF)) |
((uint64_t)((mask & 0x0100) ? 0xFF : 0x00) << 8) |
((uint64_t)((mask & 0x0200) ? 0xFF : 0x00) << 16) |
((uint64_t)((mask & 0x0400) ? 0xFF : 0x00) << 24) |
((uint64_t)((mask & 0x0800) ? 0xFF : 0x00) << 32) |
((uint64_t)((mask & 0x1000) ? 0xFF : 0x00) << 40) |
((uint64_t)((mask & 0x2000) ? 0xFF : 0x00) << 48) |
((uint64_t)((mask & 0x4000) ? 0xFF : 0x00) << 56);
uint8x8_t vmask = vreinterpret_u8_u64(vcreate_u64(sel));
uint8x8_t vsrc = vreinterpret_u8_u64(vcreate_u64(src));
uint8x8_t vdst = vreinterpret_u8_u64(vcreate_u64(dst));
/* vbsl: for each bit, selects src where mask=1, dst where mask=0 */
uint8x8_t result = vbsl_u8(vmask, vsrc, vdst);
return vget_lane_u64(vreinterpret_u64_u8(result), 0);
}
/* ADD16SAT x4 — NEON
*
* Processes all four 16-bit lanes in parallel using NEON 16x4 vectors.
* The Jaguar's segmented carry chain (low byte / mid nibble / high nibble)
* is replicated across all 4 lanes simultaneously.
*
* Stage 1: low byte (bits 7:0) — add with per-lane cin
* Stage 2: mid nibble (bits 11:8) — add with carry from stage 1 (if !eightbit)
* Stage 3: high nibble (bits 15:12)— add with carry from stage 2 (if !hicinh)
* Then: saturation logic based on overflow detection.
*/
static BLITTER_SIMD_INLINE
void blitter_simd_add16sat_x4(uint16_t *addq, uint8_t *co,
const uint16_t *adda, const uint16_t *addb,
const uint8_t *cin,
bool sat, bool eightbit, bool hicinh)
{
/* Load operands into NEON 16x4 vectors */
uint16x4_t va = vld1_u16(adda);
uint16x4_t vb = vld1_u16(addb);
/* Masks for isolating bit fields */
uint16x4_t mask_lo = vdup_n_u16(0x00FF);
uint16x4_t mask_mid = vdup_n_u16(0x0F00);
uint16x4_t mask_hi = vdup_n_u16(0xF000);
uint16x4_t vzero = vdup_n_u16(0);
uint16x4_t vone = vdup_n_u16(1);
/* Expand cin[0..3] from uint8_t to uint16x4_t */
uint16_t cin16[4];
uint16x4_t vcin;
/* Stage 1 results */
uint16x4_t va_lo, vb_lo, sum1, q_lo, carry0, carry1;
/* Stage 2 results */
uint16x4_t va_mid, vb_mid, carry_shifted1, sum2, q_mid, carry2, carry3;
/* Stage 3 results */
uint16x4_t va_hi, vb_hi, carry_shifted3, sum3, q_hi;
/* Combine result */
uint16x4_t q;
/* Saturation */
uint16x4_t btop, ctop, do_saturate;
uint16x4_t sat_lo_fill, sat_hi_fill;
uint16x4_t sat_lo_zero, sat_hi_zero;
cin16[0] = cin[0]; cin16[1] = cin[1]; cin16[2] = cin[2]; cin16[3] = cin[3];
vcin = vld1_u16(cin16);
/* Stage 1: low byte add */
va_lo = vand_u16(va, mask_lo);
vb_lo = vand_u16(vb, mask_lo);
sum1 = vadd_u16(vadd_u16(va_lo, vb_lo), vcin);
q_lo = vand_u16(sum1, mask_lo);
/* carry0 = (sum1 & 0x100) ? 1 : 0 */
carry0 = vshr_n_u16(vand_u16(sum1, vdup_n_u16(0x0100)), 8);
/* carry1 = carry0 if !eightbit, else 0 */
carry1 = eightbit ? vzero : carry0;
/* Stage 2: mid nibble add */
va_mid = vand_u16(va, mask_mid);
vb_mid = vand_u16(vb, mask_mid);
carry_shifted1 = vshl_n_u16(carry1, 8);
sum2 = vadd_u16(vadd_u16(va_mid, vb_mid), carry_shifted1);
q_mid = vand_u16(sum2, mask_mid);
/* carry2 = (sum2 & 0x1000) ? 1 : 0 */
carry2 = vshr_n_u16(vand_u16(sum2, vdup_n_u16(0x1000)), 12);
/* carry3 = carry2 if !hicinh, else 0 */
carry3 = hicinh ? vzero : carry2;
/* Stage 3: high nibble add */
va_hi = vand_u16(va, mask_hi);
vb_hi = vand_u16(vb, mask_hi);
carry_shifted3 = vshl_n_u16(carry3, 12);
sum3 = vadd_u16(vadd_u16(va_hi, vb_hi), carry_shifted3);
q_hi = vand_u16(sum3, mask_hi);
/* co[i] = (sum3 & 0x10000) ? 1 : 0 — detect carry out of bit 15.
* Since our lanes are only 16-bit, the carry is lost. Widen to
* 32-bit for the overflow detection. */
{
/* Use 32-bit arithmetic to capture the carry */
uint32_t s0 = (uint32_t)(adda[0] & 0xF000) + (addb[0] & 0xF000) + ((uint32_t)vget_lane_u16(carry3, 0) << 12);
uint32_t s1 = (uint32_t)(adda[1] & 0xF000) + (addb[1] & 0xF000) + ((uint32_t)vget_lane_u16(carry3, 1) << 12);
uint32_t s2 = (uint32_t)(adda[2] & 0xF000) + (addb[2] & 0xF000) + ((uint32_t)vget_lane_u16(carry3, 2) << 12);
uint32_t s3 = (uint32_t)(adda[3] & 0xF000) + (addb[3] & 0xF000) + ((uint32_t)vget_lane_u16(carry3, 3) << 12);
co[0] = (s0 & 0x10000) ? 1 : 0;
co[1] = (s1 & 0x10000) ? 1 : 0;
co[2] = (s2 & 0x10000) ? 1 : 0;
co[3] = (s3 & 0x10000) ? 1 : 0;
}
/* Combine low + mid + high */
q = vorr_u16(vorr_u16(q_lo, q_mid), q_hi);
/* Saturation logic */
if (sat)
{
uint16_t co16[4];
uint16x4_t vco;
uint16x4_t do_hi_saturate;
uint16x4_t result_lo, result_hi;
co16[0] = co[0]; co16[1] = co[1]; co16[2] = co[2]; co16[3] = co[3];
vco = vld1_u16(co16);
if (eightbit)
{
btop = vshr_n_u16(vand_u16(vb, vdup_n_u16(0x0080)), 7);
ctop = carry0;
}
else
{
btop = vshr_n_u16(vb, 15);
ctop = vco;
}
/* saturate = sat && (btop ^ ctop) — sat is already true here */
do_saturate = veor_u16(btop, ctop); /* non-zero where we saturate */
/* do_hi_saturate = do_saturate && !eightbit */
do_hi_saturate = eightbit ? vzero : do_saturate;
/* When saturating:
* low byte = ctop ? 0xFF : 0x00
* high byte = ctop ? 0xFF00 : 0x0000 (only if !eightbit)
* When not saturating, keep q as-is. */
/* Expand ctop/do_saturate from {0,1} to full 16-bit masks {0x0000,0xFFFF} */
{
uint16x4_t ctop_mask = vceq_u16(ctop, vone); /* 0xFFFF where ctop=1 */
uint16x4_t sat_mask = vceq_u16(do_saturate, vone); /* 0xFFFF where saturating */
uint16x4_t hi_sat_mask = vceq_u16(do_hi_saturate, vone);
/* Build saturated fill values: ctop_mask ? 0x00FF/0xFF00 : 0x0000 */
sat_lo_fill = vand_u16(ctop_mask, vdup_n_u16(0x00FF));
sat_hi_fill = vand_u16(ctop_mask, vdup_n_u16(0xFF00));
/* Build non-saturated values */
sat_lo_zero = vand_u16(q, mask_lo);
sat_hi_zero = vand_u16(q, vdup_n_u16(0xFF00));
result_lo = vbsl_u16(sat_mask, sat_lo_fill, sat_lo_zero);
result_hi = vbsl_u16(hi_sat_mask, sat_hi_fill, sat_hi_zero);
}
q = vorr_u16(result_lo, result_hi);
}
vst1_u16(addq, q);
}
#endif /* BLITTER_SIMD_NEON_H */
+34 -145
View File
@@ -1,168 +1,57 @@
/*
* Blitter SIMD ops — portable scalar reference implementation
* Blitter SIMD ops -- out-of-line scalar vtable.
*
* These are extracted verbatim from blitter.c DATA() and serve as:
* 1. The fallback for platforms without SIMD
* 2. The reference for bit-exactness testing of SIMD variants
* The implementations themselves live in blitter_simd_scalar.h so that
* blitter.c can inline them (see blitter_simd.h). This file only
* provides the function-pointer table, which test/test_blitter_simd.c
* uses to exercise the very same code the core runs.
*/
/* Select our own arch before blitter_simd.h picks one. This file IS
* the scalar implementation, so it must get the scalar inline set even when
* compiled standalone without the Makefile's -DBLITTER_SIMD_SCALAR --
* as .github/workflows/c-cpp.yml does when it builds
* test_blitter_simd by hand. Without this the header chain pulled in
* the scalar set as well and the two collided. */
#ifndef BLITTER_SIMD_SCALAR
#define BLITTER_SIMD_SCALAR 1
#endif
#include "blitter_simd.h"
#include "blitter_simd_scalar.h"
/* Logic Function Unit
*
* Implements a 4-input truth table over each bit position of two
* 64-bit words (srcd, dstd). lfu_func selects one of 16 boolean
* functions (AND, OR, XOR, etc.).
*/
static uint64_t scalar_lfu(uint64_t srcd, uint64_t dstd, uint8_t lfu_func)
static uint64_t ops_lfu(uint64_t srcd, uint64_t dstd, uint8_t lfu_func)
{
uint64_t funcmask[2] = { 0, 0xFFFFFFFFFFFFFFFFULL };
uint64_t func0 = funcmask[lfu_func & 0x01];
uint64_t func1 = funcmask[(lfu_func >> 1) & 0x01];
uint64_t func2 = funcmask[(lfu_func >> 2) & 0x01];
uint64_t func3 = funcmask[(lfu_func >> 3) & 0x01];
return (~srcd & ~dstd & func0)
| (~srcd & dstd & func1)
| ( srcd & ~dstd & func2)
| ( srcd & dstd & func3);
return blitter_simd_lfu(srcd, dstd, lfu_func);
}
/* Data Comparator
*
* XORs patd with either dstd or srcd (selected by cmpdst), then
* checks each of the 8 bytes for equality (zero). Returns an 8-bit
* mask with bit N set when byte N matches.
*/
static uint8_t scalar_dcomp(uint64_t patd, uint64_t srcd, uint64_t dstd, bool cmpdst)
static uint8_t ops_dcomp(uint64_t patd, uint64_t srcd, uint64_t dstd, bool cmpdst)
{
uint8_t result = 0;
uint64_t cmpd = patd ^ (cmpdst ? dstd : srcd);
if ((cmpd & 0x00000000000000FFULL) == 0) result |= 0x01;
if ((cmpd & 0x000000000000FF00ULL) == 0) result |= 0x02;
if ((cmpd & 0x0000000000FF0000ULL) == 0) result |= 0x04;
if ((cmpd & 0x00000000FF000000ULL) == 0) result |= 0x08;
if ((cmpd & 0x000000FF00000000ULL) == 0) result |= 0x10;
if ((cmpd & 0x0000FF0000000000ULL) == 0) result |= 0x20;
if ((cmpd & 0x00FF000000000000ULL) == 0) result |= 0x40;
if ((cmpd & 0xFF00000000000000ULL) == 0) result |= 0x80;
return result;
return blitter_simd_dcomp(patd, srcd, dstd, cmpdst);
}
/* Z-buffer Comparator
*
* Compares 4 packed 16-bit Z values. For each of the 4 lanes,
* sets the output bit if the comparison selected by zmode is true:
* bit 0 (0x01): less-than
* bit 1 (0x02): equal
* bit 2 (0x04): greater-than
*/
static uint8_t scalar_zcomp(uint64_t srcz, uint64_t dstz, uint8_t zmode)
static uint8_t ops_zcomp(uint64_t srcz, uint64_t dstz, uint8_t zmode)
{
uint8_t result = 0;
int i;
for (i = 0; i < 4; i++)
{
uint16_t s = (uint16_t)(srcz >> (i * 16));
uint16_t d = (uint16_t)(dstz >> (i * 16));
if (((s < d) && (zmode & 0x01))
|| ((s == d) && (zmode & 0x02))
|| ((s > d) && (zmode & 0x04)))
result |= (1u << i);
}
return result;
return blitter_simd_zcomp(srcz, dstz, zmode);
}
/* Byte Mask Merge
*
* Selects bytes from src or dst based on a 16-bit mask:
* - Byte 0: per-bit blend (src where mask bit is 1, dst where 0)
* - Bytes 1-7: whole-byte select via mask bits 8-14
*
* Used for both pixel data (ddat/dstd -> wdata) and Z-buffer data
* (srcz/dstz -> zwdata).
*/
static uint64_t scalar_byte_merge(uint64_t src, uint64_t dst, uint16_t mask)
static uint64_t ops_byte_merge(uint64_t src, uint64_t dst, uint16_t mask)
{
uint64_t result;
/* Byte 0: per-bit mask (low 8 bits of mask) */
result = ((src & mask) | (dst & ~(uint64_t)mask)) & 0x00000000000000FFULL;
/* Bytes 1-7: whole-byte select via mask bits 8-14 */
result |= ((mask & 0x0100) ? src : dst) & 0x000000000000FF00ULL;
result |= ((mask & 0x0200) ? src : dst) & 0x0000000000FF0000ULL;
result |= ((mask & 0x0400) ? src : dst) & 0x00000000FF000000ULL;
result |= ((mask & 0x0800) ? src : dst) & 0x000000FF00000000ULL;
result |= ((mask & 0x1000) ? src : dst) & 0x0000FF0000000000ULL;
result |= ((mask & 0x2000) ? src : dst) & 0x00FF000000000000ULL;
result |= ((mask & 0x4000) ? src : dst) & 0xFF00000000000000ULL;
return result;
return blitter_simd_byte_merge(src, dst, mask);
}
/* ADD16SAT x4 — scalar reference
*
* Implements 4 parallel 16-bit saturating adds using the Jaguar's
* segmented carry chain: low byte, mid nibble (bits 11:8), high
* nibble (bits 15:12), with conditional carry propagation.
*/
static void scalar_add16sat_x4(uint16_t *addq, uint8_t *co,
const uint16_t *adda, const uint16_t *addb,
const uint8_t *cin,
bool sat, bool eightbit, bool hicinh)
static void ops_add16sat_x4(uint16_t *addq, uint8_t *co,
const uint16_t *adda, const uint16_t *addb,
const uint8_t *cin,
bool sat, bool eightbit, bool hicinh)
{
int i;
for (i = 0; i < 4; i++)
{
uint16_t a = adda[i];
uint16_t b = addb[i];
uint8_t carry0, carry1, carry2, carry3;
uint8_t btop, ctop;
bool saturate, hisaturate;
uint32_t qt;
uint16_t q;
qt = (a & 0xFF) + (b & 0xFF) + cin[i];
q = qt & 0x00FF;
carry0 = ((qt & 0x0100) ? 1 : 0);
carry1 = (carry0 && !eightbit ? carry0 : 0);
qt = (a & 0x0F00) + (b & 0x0F00) + (carry1 << 8);
carry2 = ((qt & 0x1000) ? 1 : 0);
q |= qt & 0x0F00;
carry3 = (carry2 && !hicinh ? carry2 : 0);
qt = (a & 0xF000) + (b & 0xF000) + (carry3 << 12);
co[i] = ((qt & 0x10000) ? 1 : 0);
q |= qt & 0xF000;
if (eightbit)
{
btop = (b & 0x0080) >> 7;
ctop = carry0;
}
else
{
btop = (b & 0x8000) >> 15;
ctop = co[i];
}
saturate = sat && (btop ^ ctop);
hisaturate = saturate && !eightbit;
addq[i] = (saturate ? (ctop ? 0x00FF : 0x0000) : q & 0x00FF);
addq[i] |= (hisaturate ? (ctop ? 0xFF00 : 0x0000) : q & 0xFF00);
}
blitter_simd_add16sat_x4(addq, co, adda, addb, cin, sat, eightbit, hicinh);
}
const blitter_simd_ops_t blitter_simd_ops = {
scalar_lfu,
scalar_dcomp,
scalar_zcomp,
scalar_byte_merge,
scalar_add16sat_x4
ops_lfu,
ops_dcomp,
ops_zcomp,
ops_byte_merge,
ops_add16sat_x4
};
+178
View File
@@ -0,0 +1,178 @@
#ifndef BLITTER_SIMD_SCALAR_H
#define BLITTER_SIMD_SCALAR_H
/* Included by blitter_simd.h once the arch has been selected.
* Do not include directly -- BLITTER_SIMD_INLINE comes from there. */
#ifndef BLITTER_SIMD_INLINE
#error "include blitter_simd.h, not blitter_simd_scalar.h"
#endif
#include <stdint.h>
#include <boolean.h>
/*
* Blitter SIMD ops — portable scalar reference implementation
*
* These are extracted verbatim from blitter.c DATA() and serve as:
* 1. The fallback for platforms without SIMD
* 2. The reference for bit-exactness testing of SIMD variants
*/
/* Logic Function Unit
*
* Implements a 4-input truth table over each bit position of two
* 64-bit words (srcd, dstd). lfu_func selects one of 16 boolean
* functions (AND, OR, XOR, etc.).
*/
static BLITTER_SIMD_INLINE
uint64_t blitter_simd_lfu(uint64_t srcd, uint64_t dstd, uint8_t lfu_func)
{
uint64_t funcmask[2] = { 0, 0xFFFFFFFFFFFFFFFFULL };
uint64_t func0 = funcmask[lfu_func & 0x01];
uint64_t func1 = funcmask[(lfu_func >> 1) & 0x01];
uint64_t func2 = funcmask[(lfu_func >> 2) & 0x01];
uint64_t func3 = funcmask[(lfu_func >> 3) & 0x01];
return (~srcd & ~dstd & func0)
| (~srcd & dstd & func1)
| ( srcd & ~dstd & func2)
| ( srcd & dstd & func3);
}
/* Data Comparator
*
* XORs patd with either dstd or srcd (selected by cmpdst), then
* checks each of the 8 bytes for equality (zero). Returns an 8-bit
* mask with bit N set when byte N matches.
*/
static BLITTER_SIMD_INLINE
uint8_t blitter_simd_dcomp(uint64_t patd, uint64_t srcd, uint64_t dstd, bool cmpdst)
{
uint8_t result = 0;
uint64_t cmpd = patd ^ (cmpdst ? dstd : srcd);
if ((cmpd & 0x00000000000000FFULL) == 0) result |= 0x01;
if ((cmpd & 0x000000000000FF00ULL) == 0) result |= 0x02;
if ((cmpd & 0x0000000000FF0000ULL) == 0) result |= 0x04;
if ((cmpd & 0x00000000FF000000ULL) == 0) result |= 0x08;
if ((cmpd & 0x000000FF00000000ULL) == 0) result |= 0x10;
if ((cmpd & 0x0000FF0000000000ULL) == 0) result |= 0x20;
if ((cmpd & 0x00FF000000000000ULL) == 0) result |= 0x40;
if ((cmpd & 0xFF00000000000000ULL) == 0) result |= 0x80;
return result;
}
/* Z-buffer Comparator
*
* Compares 4 packed 16-bit Z values. For each of the 4 lanes,
* sets the output bit if the comparison selected by zmode is true:
* bit 0 (0x01): less-than
* bit 1 (0x02): equal
* bit 2 (0x04): greater-than
*/
static BLITTER_SIMD_INLINE
uint8_t blitter_simd_zcomp(uint64_t srcz, uint64_t dstz, uint8_t zmode)
{
uint8_t result = 0;
int i;
for (i = 0; i < 4; i++)
{
uint16_t s = (uint16_t)(srcz >> (i * 16));
uint16_t d = (uint16_t)(dstz >> (i * 16));
if (((s < d) && (zmode & 0x01))
|| ((s == d) && (zmode & 0x02))
|| ((s > d) && (zmode & 0x04)))
result |= (1u << i);
}
return result;
}
/* Byte Mask Merge
*
* Selects bytes from src or dst based on a 16-bit mask:
* - Byte 0: per-bit blend (src where mask bit is 1, dst where 0)
* - Bytes 1-7: whole-byte select via mask bits 8-14
*
* Used for both pixel data (ddat/dstd -> wdata) and Z-buffer data
* (srcz/dstz -> zwdata).
*/
static BLITTER_SIMD_INLINE
uint64_t blitter_simd_byte_merge(uint64_t src, uint64_t dst, uint16_t mask)
{
uint64_t result;
/* Byte 0: per-bit mask (low 8 bits of mask) */
result = ((src & mask) | (dst & ~(uint64_t)mask)) & 0x00000000000000FFULL;
/* Bytes 1-7: whole-byte select via mask bits 8-14 */
result |= ((mask & 0x0100) ? src : dst) & 0x000000000000FF00ULL;
result |= ((mask & 0x0200) ? src : dst) & 0x0000000000FF0000ULL;
result |= ((mask & 0x0400) ? src : dst) & 0x00000000FF000000ULL;
result |= ((mask & 0x0800) ? src : dst) & 0x000000FF00000000ULL;
result |= ((mask & 0x1000) ? src : dst) & 0x0000FF0000000000ULL;
result |= ((mask & 0x2000) ? src : dst) & 0x00FF000000000000ULL;
result |= ((mask & 0x4000) ? src : dst) & 0xFF00000000000000ULL;
return result;
}
/* ADD16SAT x4 — scalar reference
*
* Implements 4 parallel 16-bit saturating adds using the Jaguar's
* segmented carry chain: low byte, mid nibble (bits 11:8), high
* nibble (bits 15:12), with conditional carry propagation.
*/
static BLITTER_SIMD_INLINE
void blitter_simd_add16sat_x4(uint16_t *addq, uint8_t *co,
const uint16_t *adda, const uint16_t *addb,
const uint8_t *cin,
bool sat, bool eightbit, bool hicinh)
{
int i;
for (i = 0; i < 4; i++)
{
uint16_t a = adda[i];
uint16_t b = addb[i];
uint8_t carry0, carry1, carry2, carry3;
uint8_t btop, ctop;
bool saturate, hisaturate;
uint32_t qt;
uint16_t q;
qt = (a & 0xFF) + (b & 0xFF) + cin[i];
q = qt & 0x00FF;
carry0 = ((qt & 0x0100) ? 1 : 0);
carry1 = (carry0 && !eightbit ? carry0 : 0);
qt = (a & 0x0F00) + (b & 0x0F00) + (carry1 << 8);
carry2 = ((qt & 0x1000) ? 1 : 0);
q |= qt & 0x0F00;
carry3 = (carry2 && !hicinh ? carry2 : 0);
qt = (a & 0xF000) + (b & 0xF000) + (carry3 << 12);
co[i] = ((qt & 0x10000) ? 1 : 0);
q |= qt & 0xF000;
if (eightbit)
{
btop = (b & 0x0080) >> 7;
ctop = carry0;
}
else
{
btop = (b & 0x8000) >> 15;
ctop = co[i];
}
saturate = sat && (btop ^ ctop);
hisaturate = saturate && !eightbit;
addq[i] = (saturate ? (ctop ? 0x00FF : 0x0000) : q & 0x00FF);
addq[i] |= (hisaturate ? (ctop ? 0xFF00 : 0x0000) : q & 0xFF00);
}
}
#endif /* BLITTER_SIMD_SCALAR_H */
+34 -304
View File
@@ -1,327 +1,57 @@
/*
* Blitter SIMD ops — x86/x64 SSE2 implementation
* Blitter SIMD ops -- out-of-line sse2 vtable.
*
* SSE2 is baseline for all x86_64 processors and available on x86
* since Pentium 4 (2001). No runtime feature detection needed.
*
* Operations work on 64-bit values loaded into 128-bit SSE registers
* (upper 64 bits unused or zeroed).
* The implementations themselves live in blitter_simd_sse2.h so that
* blitter.c can inline them (see blitter_simd.h). This file only
* provides the function-pointer table, which test/test_blitter_simd.c
* uses to exercise the very same code the core runs.
*/
#include "blitter_simd.h"
#include <emmintrin.h> /* SSE2 */
#include <string.h> /* memcpy for type-punning extract */
/* _mm_set_epi64x doesn't exist in MSVC 2010 and earlier.
* Build from two 32-bit halves instead (pure SSE2). */
#if defined(_MSC_VER) && _MSC_VER < 1700
#define SSE2_SET64(hi, lo) \
_mm_set_epi32((int)((uint64_t)(hi) >> 32), (int)(hi), \
(int)((uint64_t)(lo) >> 32), (int)(lo))
#else
#define SSE2_SET64(hi, lo) _mm_set_epi64x((int64_t)(hi), (int64_t)(lo))
/* Select our own arch before blitter_simd.h picks one. This file IS
* the sse2 implementation, so it must get the sse2 inline set even when
* compiled standalone without the Makefile's -DBLITTER_SIMD_SSE2 --
* as .github/workflows/c-cpp.yml does when it builds
* test_blitter_simd by hand. Without this the header chain pulled in
* the scalar set as well and the two collided. */
#ifndef BLITTER_SIMD_SSE2
#define BLITTER_SIMD_SSE2 1
#endif
static uint64_t sse2_extract_u64(__m128i v)
#include "blitter_simd.h"
#include "blitter_simd_sse2.h"
static uint64_t ops_lfu(uint64_t srcd, uint64_t dstd, uint8_t lfu_func)
{
uint64_t r;
memcpy(&r, &v, sizeof(r));
return r;
return blitter_simd_lfu(srcd, dstd, lfu_func);
}
/* Logic Function Unit — SSE2
*
* The truth table has 4 terms, each gated by one bit of lfu_func.
* We broadcast each gate to all 64 bits, then AND/OR in registers.
*/
static uint64_t sse2_lfu(uint64_t srcd, uint64_t dstd, uint8_t lfu_func)
static uint8_t ops_dcomp(uint64_t patd, uint64_t srcd, uint64_t dstd, bool cmpdst)
{
/* Build 64-bit masks from each lfu_func bit */
uint64_t func0 = (lfu_func & 0x01) ? 0xFFFFFFFFFFFFFFFFULL : 0;
uint64_t func1 = (lfu_func & 0x02) ? 0xFFFFFFFFFFFFFFFFULL : 0;
uint64_t func2 = (lfu_func & 0x04) ? 0xFFFFFFFFFFFFFFFFULL : 0;
uint64_t func3 = (lfu_func & 0x08) ? 0xFFFFFFFFFFFFFFFFULL : 0;
__m128i vs = SSE2_SET64(0, srcd);
__m128i vd = SSE2_SET64(0, dstd);
__m128i vns = _mm_andnot_si128(vs, _mm_set1_epi32(-1)); /* ~srcd */
__m128i vnd = _mm_andnot_si128(vd, _mm_set1_epi32(-1)); /* ~dstd */
__m128i vf0 = SSE2_SET64(0, func0);
__m128i vf1 = SSE2_SET64(0, func1);
__m128i vf2 = SSE2_SET64(0, func2);
__m128i vf3 = SSE2_SET64(0, func3);
/* (~s & ~d & f0) | (~s & d & f1) | (s & ~d & f2) | (s & d & f3) */
__m128i t0 = _mm_and_si128(_mm_and_si128(vns, vnd), vf0);
__m128i t1 = _mm_and_si128(_mm_and_si128(vns, vd), vf1);
__m128i t2 = _mm_and_si128(_mm_and_si128(vs, vnd), vf2);
__m128i t3 = _mm_and_si128(_mm_and_si128(vs, vd), vf3);
__m128i result = _mm_or_si128(_mm_or_si128(t0, t1), _mm_or_si128(t2, t3));
return sse2_extract_u64(result);
return blitter_simd_dcomp(patd, srcd, dstd, cmpdst);
}
/* Data Comparator — SSE2
*
* XOR the two 64-bit values, then check each byte for zero.
* _mm_cmpeq_epi8 + _mm_movemask_epi8 gives us all 8 byte-equality
* bits in one shot.
*/
static uint8_t sse2_dcomp(uint64_t patd, uint64_t srcd, uint64_t dstd, bool cmpdst)
static uint8_t ops_zcomp(uint64_t srcz, uint64_t dstz, uint8_t zmode)
{
uint64_t other = cmpdst ? dstd : srcd;
__m128i vp = SSE2_SET64(0, patd);
__m128i vo = SSE2_SET64(0, other);
__m128i vxor = _mm_xor_si128(vp, vo);
/* Compare each byte against zero */
__m128i vzero = _mm_setzero_si128();
__m128i vcmp = _mm_cmpeq_epi8(vxor, vzero);
/* movemask gives us 16 bits (one per byte of 128-bit reg).
* We only care about the low 8 bytes. */
return (uint8_t)(_mm_movemask_epi8(vcmp) & 0xFF);
return blitter_simd_zcomp(srcz, dstz, zmode);
}
/* Z-buffer Comparator — SSE2
*
* 4 packed 16-bit comparisons. SSE2 has _mm_cmpeq_epi16 and
* _mm_cmpgt_epi16 (signed), but Z values are unsigned. We handle
* this by biasing both operands by -32768 before signed compare.
*/
static uint8_t sse2_zcomp(uint64_t srcz, uint64_t dstz, uint8_t zmode)
static uint64_t ops_byte_merge(uint64_t src, uint64_t dst, uint16_t mask)
{
uint8_t result = 0;
uint8_t packed = 0;
__m128i vs = SSE2_SET64(0, srcz);
__m128i vd = SSE2_SET64(0, dstz);
/* Bias for unsigned comparison via signed instructions */
__m128i bias = _mm_set1_epi16((short)0x8000);
__m128i vs_biased = _mm_sub_epi16(vs, bias);
__m128i vd_biased = _mm_sub_epi16(vd, bias);
/* EQ: src == dst */
if (zmode & 0x02)
{
__m128i veq = _mm_cmpeq_epi16(vs, vd);
result |= (uint8_t)(_mm_movemask_epi8(veq) & 0xFF);
}
/* LT: src < dst (signed compare after bias = unsigned compare) */
if (zmode & 0x01)
{
__m128i vlt = _mm_cmplt_epi16(vs_biased, vd_biased);
result |= (uint8_t)(_mm_movemask_epi8(vlt) & 0xFF);
}
/* GT: src > dst */
if (zmode & 0x04)
{
__m128i vgt = _mm_cmpgt_epi16(vs_biased, vd_biased);
result |= (uint8_t)(_mm_movemask_epi8(vgt) & 0xFF);
}
/* movemask gives 2 bits per 16-bit lane (one per byte).
* Convert to 1 bit per lane: lanes at positions 0,2,4,6 */
if (result & 0x03) packed |= 0x01; /* lane 0: bytes 0-1 */
if (result & 0x0C) packed |= 0x02; /* lane 1: bytes 2-3 */
if (result & 0x30) packed |= 0x04; /* lane 2: bytes 4-5 */
if (result & 0xC0) packed |= 0x08; /* lane 3: bytes 6-7 */
return packed;
return blitter_simd_byte_merge(src, dst, mask);
}
/* Byte Mask Merge — SSE2
*
* Byte 0 is per-bit blended, bytes 1-7 are whole-byte selected.
* We build a byte-level mask from the 16-bit input, then use
* AND/ANDNOT/OR to blend.
*/
static uint64_t sse2_byte_merge(uint64_t src, uint64_t dst, uint16_t mask)
static void ops_add16sat_x4(uint16_t *addq, uint8_t *co,
const uint16_t *adda, const uint16_t *addb,
const uint8_t *cin,
bool sat, bool eightbit, bool hicinh)
{
/* Build an 8-byte selection mask directly via bit arithmetic.
* Byte 0 = low 8 bits of mask (per-bit blend).
* Bytes 1-7 = 0xFF or 0x00 from mask bits 8-14 (whole-byte select).
* We expand each bit to a full 0xFF byte using sign-extension. */
uint64_t sel64 = (uint64_t)(mask & 0xFF); /* byte 0: per-bit */
__m128i vmask, vsrc, vdst, r;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 8) & 1))) << 8;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 9) & 1))) << 16;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 10) & 1))) << 24;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 11) & 1))) << 32;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 12) & 1))) << 40;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 13) & 1))) << 48;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 14) & 1))) << 56;
vmask = SSE2_SET64(0, sel64);
vsrc = SSE2_SET64(0, src);
vdst = SSE2_SET64(0, dst);
/* result = (src & mask) | (dst & ~mask) */
r = _mm_or_si128(
_mm_and_si128(vsrc, vmask),
_mm_andnot_si128(vmask, vdst)
);
return sse2_extract_u64(r);
}
/* ADD16SAT x4 — SSE2
*
* Processes all four 16-bit lanes in parallel using SSE2 vectors.
* The Jaguar's segmented carry chain (low byte / mid nibble / high nibble)
* is replicated across all 4 lanes simultaneously.
*
* Uses the low 64 bits of 128-bit SSE registers (4 x 16-bit lanes).
*/
static void sse2_add16sat_x4(uint16_t *addq, uint8_t *co,
const uint16_t *adda, const uint16_t *addb,
const uint8_t *cin,
bool sat, bool eightbit, bool hicinh)
{
/* Load operands — only low 4 lanes used (64 bits) */
__m128i va = _mm_loadl_epi64((const __m128i *)adda);
__m128i vb = _mm_loadl_epi64((const __m128i *)addb);
/* Expand cin[0..3] from uint8_t to 16-bit lanes */
__m128i vcin;
__m128i mask_lo, mask_mid, mask_hi, mask_0100, mask_1000;
__m128i va_lo, vb_lo, sum1, q_lo, carry0, carry1;
__m128i va_mid, vb_mid, sum2, q_mid, carry2, carry3;
__m128i va_hi, vb_hi, sum3, q_hi;
__m128i q;
uint32_t s0, s1, s2, s3;
uint16_t c3_0, c3_1, c3_2, c3_3;
uint16_t carry3_buf[8];
{
uint16_t cin16[8] = { 0 };
cin16[0] = cin[0]; cin16[1] = cin[1]; cin16[2] = cin[2]; cin16[3] = cin[3];
vcin = _mm_loadu_si128((const __m128i *)cin16);
}
mask_lo = _mm_set1_epi16(0x00FF);
mask_mid = _mm_set1_epi16(0x0F00);
mask_hi = _mm_set1_epi16((short)0xF000);
mask_0100 = _mm_set1_epi16(0x0100);
mask_1000 = _mm_set1_epi16(0x1000);
/* Stage 1: low byte add */
va_lo = _mm_and_si128(va, mask_lo);
vb_lo = _mm_and_si128(vb, mask_lo);
sum1 = _mm_add_epi16(_mm_add_epi16(va_lo, vb_lo), vcin);
q_lo = _mm_and_si128(sum1, mask_lo);
/* carry0 = (sum1 >> 8) & 1 */
carry0 = _mm_srli_epi16(_mm_and_si128(sum1, mask_0100), 8);
/* carry1 = eightbit ? 0 : carry0 */
carry1 = eightbit ? _mm_setzero_si128() : carry0;
/* Stage 2: mid nibble add */
va_mid = _mm_and_si128(va, mask_mid);
vb_mid = _mm_and_si128(vb, mask_mid);
sum2 = _mm_add_epi16(_mm_add_epi16(va_mid, vb_mid), _mm_slli_epi16(carry1, 8));
q_mid = _mm_and_si128(sum2, mask_mid);
/* carry2 = (sum2 >> 12) & 1 */
carry2 = _mm_srli_epi16(_mm_and_si128(sum2, mask_1000), 12);
/* carry3 = hicinh ? 0 : carry2 */
carry3 = hicinh ? _mm_setzero_si128() : carry2;
/* Stage 3: high nibble add */
va_hi = _mm_and_si128(va, mask_hi);
vb_hi = _mm_and_si128(vb, mask_hi);
sum3 = _mm_add_epi16(_mm_add_epi16(va_hi, vb_hi), _mm_slli_epi16(carry3, 12));
q_hi = _mm_and_si128(sum3, mask_hi);
/* co[i]: detect carry out of bit 15. 16-bit lanes lose bit 16,
* so widen to 32-bit to capture the overflow. */
_mm_storeu_si128((__m128i *)carry3_buf, carry3);
c3_0 = carry3_buf[0]; c3_1 = carry3_buf[1];
c3_2 = carry3_buf[2]; c3_3 = carry3_buf[3];
s0 = (uint32_t)(adda[0] & 0xF000) + (addb[0] & 0xF000) + ((uint32_t)c3_0 << 12);
s1 = (uint32_t)(adda[1] & 0xF000) + (addb[1] & 0xF000) + ((uint32_t)c3_1 << 12);
s2 = (uint32_t)(adda[2] & 0xF000) + (addb[2] & 0xF000) + ((uint32_t)c3_2 << 12);
s3 = (uint32_t)(adda[3] & 0xF000) + (addb[3] & 0xF000) + ((uint32_t)c3_3 << 12);
co[0] = (s0 & 0x10000) ? 1 : 0;
co[1] = (s1 & 0x10000) ? 1 : 0;
co[2] = (s2 & 0x10000) ? 1 : 0;
co[3] = (s3 & 0x10000) ? 1 : 0;
/* Combine low + mid + high */
q = _mm_or_si128(_mm_or_si128(q_lo, q_mid), q_hi);
/* Saturation logic */
if (sat)
{
__m128i btop, ctop, do_saturate, do_hi_saturate;
__m128i sat_lo_fill, sat_hi_fill;
__m128i sat_mask, hi_sat_mask;
__m128i result_lo, result_hi;
uint16_t co16[8] = { 0 };
__m128i vco;
co16[0] = co[0]; co16[1] = co[1]; co16[2] = co[2]; co16[3] = co[3];
vco = _mm_loadu_si128((const __m128i *)co16);
if (eightbit)
{
btop = _mm_srli_epi16(_mm_and_si128(vb, _mm_set1_epi16(0x0080)), 7);
ctop = carry0;
}
else
{
btop = _mm_srli_epi16(vb, 15);
ctop = vco;
}
/* do_saturate = btop ^ ctop (non-zero lanes saturate) */
do_saturate = _mm_xor_si128(btop, ctop);
/* do_hi_saturate = eightbit ? 0 : do_saturate */
do_hi_saturate = eightbit ? _mm_setzero_si128() : do_saturate;
/* Build saturated fill: ctop ? 0x00FF/0xFF00 : 0x0000 */
/* ctop is 0 or 1 in each lane. Expand to mask: cmpeq with 1 gives 0xFFFF where ctop=1 */
{
__m128i vone = _mm_set1_epi16(1);
__m128i ctop_mask = _mm_cmpeq_epi16(ctop, vone);
sat_lo_fill = _mm_and_si128(ctop_mask, mask_lo); /* 0x00FF where ctop=1 */
sat_hi_fill = _mm_and_si128(ctop_mask, _mm_set1_epi16((short)0xFF00));
/* Expand do_saturate/do_hi_saturate to masks */
sat_mask = _mm_cmpeq_epi16(do_saturate, vone);
hi_sat_mask = _mm_cmpeq_epi16(do_hi_saturate, vone);
}
/* Select: if saturating use fill, else use q */
result_lo = _mm_or_si128(
_mm_and_si128(sat_mask, sat_lo_fill),
_mm_andnot_si128(sat_mask, _mm_and_si128(q, mask_lo))
);
result_hi = _mm_or_si128(
_mm_and_si128(hi_sat_mask, sat_hi_fill),
_mm_andnot_si128(hi_sat_mask, _mm_and_si128(q, _mm_set1_epi16((short)0xFF00)))
);
q = _mm_or_si128(result_lo, result_hi);
}
/* Store result — only low 4 lanes (64 bits) */
_mm_storel_epi64((__m128i *)addq, q);
blitter_simd_add16sat_x4(addq, co, adda, addb, cin, sat, eightbit, hicinh);
}
const blitter_simd_ops_t blitter_simd_ops = {
sse2_lfu,
sse2_dcomp,
sse2_zcomp,
sse2_byte_merge,
sse2_add16sat_x4
ops_lfu,
ops_dcomp,
ops_zcomp,
ops_byte_merge,
ops_add16sat_x4
};
+342
View File
@@ -0,0 +1,342 @@
#ifndef BLITTER_SIMD_SSE2_H
#define BLITTER_SIMD_SSE2_H
/* Included by blitter_simd.h once the arch has been selected.
* Do not include directly -- BLITTER_SIMD_INLINE comes from there. */
#ifndef BLITTER_SIMD_INLINE
#error "include blitter_simd.h, not blitter_simd_sse2.h"
#endif
#include <stdint.h>
#include <boolean.h>
#include <emmintrin.h> /* SSE2 */
#include <string.h> /* memcpy for type-punning extract */
/*
* Blitter SIMD ops — x86/x64 SSE2 implementation
*
* SSE2 is baseline for all x86_64 processors and available on x86
* since Pentium 4 (2001). No runtime feature detection needed.
*
* Operations work on 64-bit values loaded into 128-bit SSE registers
* (upper 64 bits unused or zeroed).
*/
/* _mm_set_epi64x doesn't exist in MSVC 2010 and earlier, and its
* availability on 32-bit MSVC targets varies by version. Build from
* two 32-bit halves instead (pure SSE2, and what cl.exe lowers the
* 64-bit form to on x86 anyway) whenever either applies. */
#if defined(_MSC_VER) && (_MSC_VER < 1700 || !defined(_M_X64))
#define SSE2_SET64(hi, lo) \
_mm_set_epi32((int)((uint64_t)(hi) >> 32), (int)(hi), \
(int)((uint64_t)(lo) >> 32), (int)(lo))
#else
#define SSE2_SET64(hi, lo) _mm_set_epi64x((int64_t)(hi), (int64_t)(lo))
#endif
static BLITTER_SIMD_INLINE
uint64_t blitter_simd_sse2_extract_u64(__m128i v)
{
uint64_t r;
memcpy(&r, &v, sizeof(r));
return r;
}
/* Logic Function Unit — SSE2
*
* The truth table has 4 terms, each gated by one bit of lfu_func.
* We broadcast each gate to all 64 bits, then AND/OR in registers.
*/
static BLITTER_SIMD_INLINE
uint64_t blitter_simd_lfu(uint64_t srcd, uint64_t dstd, uint8_t lfu_func)
{
/* Build 64-bit masks from each lfu_func bit */
uint64_t func0 = (lfu_func & 0x01) ? 0xFFFFFFFFFFFFFFFFULL : 0;
uint64_t func1 = (lfu_func & 0x02) ? 0xFFFFFFFFFFFFFFFFULL : 0;
uint64_t func2 = (lfu_func & 0x04) ? 0xFFFFFFFFFFFFFFFFULL : 0;
uint64_t func3 = (lfu_func & 0x08) ? 0xFFFFFFFFFFFFFFFFULL : 0;
__m128i vs = SSE2_SET64(0, srcd);
__m128i vd = SSE2_SET64(0, dstd);
__m128i vns = _mm_andnot_si128(vs, _mm_set1_epi32(-1)); /* ~srcd */
__m128i vnd = _mm_andnot_si128(vd, _mm_set1_epi32(-1)); /* ~dstd */
__m128i vf0 = SSE2_SET64(0, func0);
__m128i vf1 = SSE2_SET64(0, func1);
__m128i vf2 = SSE2_SET64(0, func2);
__m128i vf3 = SSE2_SET64(0, func3);
/* (~s & ~d & f0) | (~s & d & f1) | (s & ~d & f2) | (s & d & f3) */
__m128i t0 = _mm_and_si128(_mm_and_si128(vns, vnd), vf0);
__m128i t1 = _mm_and_si128(_mm_and_si128(vns, vd), vf1);
__m128i t2 = _mm_and_si128(_mm_and_si128(vs, vnd), vf2);
__m128i t3 = _mm_and_si128(_mm_and_si128(vs, vd), vf3);
__m128i result = _mm_or_si128(_mm_or_si128(t0, t1), _mm_or_si128(t2, t3));
return blitter_simd_sse2_extract_u64(result);
}
/* Data Comparator — SSE2
*
* XOR the two 64-bit values, then check each byte for zero.
* _mm_cmpeq_epi8 + _mm_movemask_epi8 gives us all 8 byte-equality
* bits in one shot.
*/
static BLITTER_SIMD_INLINE
uint8_t blitter_simd_dcomp(uint64_t patd, uint64_t srcd, uint64_t dstd, bool cmpdst)
{
uint64_t other = cmpdst ? dstd : srcd;
__m128i vp = SSE2_SET64(0, patd);
__m128i vo = SSE2_SET64(0, other);
__m128i vxor = _mm_xor_si128(vp, vo);
/* Compare each byte against zero */
__m128i vzero = _mm_setzero_si128();
__m128i vcmp = _mm_cmpeq_epi8(vxor, vzero);
/* movemask gives us 16 bits (one per byte of 128-bit reg).
* We only care about the low 8 bytes. */
return (uint8_t)(_mm_movemask_epi8(vcmp) & 0xFF);
}
/* Z-buffer Comparator — SSE2
*
* 4 packed 16-bit comparisons. SSE2 has _mm_cmpeq_epi16 and
* _mm_cmpgt_epi16 (signed), but Z values are unsigned. We handle
* this by biasing both operands by -32768 before signed compare.
*/
static BLITTER_SIMD_INLINE
uint8_t blitter_simd_zcomp(uint64_t srcz, uint64_t dstz, uint8_t zmode)
{
uint8_t result = 0;
uint8_t packed = 0;
__m128i vs = SSE2_SET64(0, srcz);
__m128i vd = SSE2_SET64(0, dstz);
/* Bias for unsigned comparison via signed instructions */
__m128i bias = _mm_set1_epi16((short)0x8000);
__m128i vs_biased = _mm_sub_epi16(vs, bias);
__m128i vd_biased = _mm_sub_epi16(vd, bias);
/* EQ: src == dst */
if (zmode & 0x02)
{
__m128i veq = _mm_cmpeq_epi16(vs, vd);
result |= (uint8_t)(_mm_movemask_epi8(veq) & 0xFF);
}
/* LT: src < dst (signed compare after bias = unsigned compare) */
if (zmode & 0x01)
{
__m128i vlt = _mm_cmplt_epi16(vs_biased, vd_biased);
result |= (uint8_t)(_mm_movemask_epi8(vlt) & 0xFF);
}
/* GT: src > dst */
if (zmode & 0x04)
{
__m128i vgt = _mm_cmpgt_epi16(vs_biased, vd_biased);
result |= (uint8_t)(_mm_movemask_epi8(vgt) & 0xFF);
}
/* movemask gives 2 bits per 16-bit lane (one per byte).
* Convert to 1 bit per lane: lanes at positions 0,2,4,6 */
if (result & 0x03) packed |= 0x01; /* lane 0: bytes 0-1 */
if (result & 0x0C) packed |= 0x02; /* lane 1: bytes 2-3 */
if (result & 0x30) packed |= 0x04; /* lane 2: bytes 4-5 */
if (result & 0xC0) packed |= 0x08; /* lane 3: bytes 6-7 */
return packed;
}
/* Byte Mask Merge — SSE2
*
* Byte 0 is per-bit blended, bytes 1-7 are whole-byte selected.
* We build a byte-level mask from the 16-bit input, then use
* AND/ANDNOT/OR to blend.
*/
static BLITTER_SIMD_INLINE
uint64_t blitter_simd_byte_merge(uint64_t src, uint64_t dst, uint16_t mask)
{
/* Build an 8-byte selection mask directly via bit arithmetic.
* Byte 0 = low 8 bits of mask (per-bit blend).
* Bytes 1-7 = 0xFF or 0x00 from mask bits 8-14 (whole-byte select).
* We expand each bit to a full 0xFF byte using sign-extension. */
uint64_t sel64 = (uint64_t)(mask & 0xFF); /* byte 0: per-bit */
__m128i vmask, vsrc, vdst, r;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 8) & 1))) << 8;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 9) & 1))) << 16;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 10) & 1))) << 24;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 11) & 1))) << 32;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 12) & 1))) << 40;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 13) & 1))) << 48;
sel64 |= (uint64_t)((uint8_t)(-(int8_t)((mask >> 14) & 1))) << 56;
vmask = SSE2_SET64(0, sel64);
vsrc = SSE2_SET64(0, src);
vdst = SSE2_SET64(0, dst);
/* result = (src & mask) | (dst & ~mask) */
r = _mm_or_si128(
_mm_and_si128(vsrc, vmask),
_mm_andnot_si128(vmask, vdst)
);
return blitter_simd_sse2_extract_u64(r);
}
/* ADD16SAT x4 — SSE2
*
* Processes all four 16-bit lanes in parallel using SSE2 vectors.
* The Jaguar's segmented carry chain (low byte / mid nibble / high nibble)
* is replicated across all 4 lanes simultaneously.
*
* Uses the low 64 bits of 128-bit SSE registers (4 x 16-bit lanes).
*/
static BLITTER_SIMD_INLINE
void blitter_simd_add16sat_x4(uint16_t *addq, uint8_t *co,
const uint16_t *adda, const uint16_t *addb,
const uint8_t *cin,
bool sat, bool eightbit, bool hicinh)
{
/* Load operands — only low 4 lanes used (64 bits) */
__m128i va = _mm_loadl_epi64((const __m128i *)adda);
__m128i vb = _mm_loadl_epi64((const __m128i *)addb);
/* Expand cin[0..3] from uint8_t to 16-bit lanes */
__m128i vcin;
__m128i mask_lo, mask_mid, mask_hi, mask_0100, mask_1000;
__m128i va_lo, vb_lo, sum1, q_lo, carry0, carry1;
__m128i va_mid, vb_mid, sum2, q_mid, carry2, carry3;
__m128i va_hi, vb_hi, sum3, q_hi;
__m128i q;
uint32_t s0, s1, s2, s3;
uint16_t c3_0, c3_1, c3_2, c3_3;
uint16_t carry3_buf[8];
{
uint16_t cin16[8] = { 0 };
cin16[0] = cin[0]; cin16[1] = cin[1]; cin16[2] = cin[2]; cin16[3] = cin[3];
vcin = _mm_loadu_si128((const __m128i *)cin16);
}
mask_lo = _mm_set1_epi16(0x00FF);
mask_mid = _mm_set1_epi16(0x0F00);
mask_hi = _mm_set1_epi16((short)0xF000);
mask_0100 = _mm_set1_epi16(0x0100);
mask_1000 = _mm_set1_epi16(0x1000);
/* Stage 1: low byte add */
va_lo = _mm_and_si128(va, mask_lo);
vb_lo = _mm_and_si128(vb, mask_lo);
sum1 = _mm_add_epi16(_mm_add_epi16(va_lo, vb_lo), vcin);
q_lo = _mm_and_si128(sum1, mask_lo);
/* carry0 = (sum1 >> 8) & 1 */
carry0 = _mm_srli_epi16(_mm_and_si128(sum1, mask_0100), 8);
/* carry1 = eightbit ? 0 : carry0 */
carry1 = eightbit ? _mm_setzero_si128() : carry0;
/* Stage 2: mid nibble add */
va_mid = _mm_and_si128(va, mask_mid);
vb_mid = _mm_and_si128(vb, mask_mid);
sum2 = _mm_add_epi16(_mm_add_epi16(va_mid, vb_mid), _mm_slli_epi16(carry1, 8));
q_mid = _mm_and_si128(sum2, mask_mid);
/* carry2 = (sum2 >> 12) & 1 */
carry2 = _mm_srli_epi16(_mm_and_si128(sum2, mask_1000), 12);
/* carry3 = hicinh ? 0 : carry2 */
carry3 = hicinh ? _mm_setzero_si128() : carry2;
/* Stage 3: high nibble add */
va_hi = _mm_and_si128(va, mask_hi);
vb_hi = _mm_and_si128(vb, mask_hi);
sum3 = _mm_add_epi16(_mm_add_epi16(va_hi, vb_hi), _mm_slli_epi16(carry3, 12));
q_hi = _mm_and_si128(sum3, mask_hi);
/* co[i]: detect carry out of bit 15. 16-bit lanes lose bit 16,
* so widen to 32-bit to capture the overflow. */
_mm_storeu_si128((__m128i *)carry3_buf, carry3);
c3_0 = carry3_buf[0]; c3_1 = carry3_buf[1];
c3_2 = carry3_buf[2]; c3_3 = carry3_buf[3];
s0 = (uint32_t)(adda[0] & 0xF000) + (addb[0] & 0xF000) + ((uint32_t)c3_0 << 12);
s1 = (uint32_t)(adda[1] & 0xF000) + (addb[1] & 0xF000) + ((uint32_t)c3_1 << 12);
s2 = (uint32_t)(adda[2] & 0xF000) + (addb[2] & 0xF000) + ((uint32_t)c3_2 << 12);
s3 = (uint32_t)(adda[3] & 0xF000) + (addb[3] & 0xF000) + ((uint32_t)c3_3 << 12);
co[0] = (s0 & 0x10000) ? 1 : 0;
co[1] = (s1 & 0x10000) ? 1 : 0;
co[2] = (s2 & 0x10000) ? 1 : 0;
co[3] = (s3 & 0x10000) ? 1 : 0;
/* Combine low + mid + high */
q = _mm_or_si128(_mm_or_si128(q_lo, q_mid), q_hi);
/* Saturation logic */
if (sat)
{
__m128i btop, ctop, do_saturate, do_hi_saturate;
__m128i sat_lo_fill, sat_hi_fill;
__m128i sat_mask, hi_sat_mask;
__m128i result_lo, result_hi;
uint16_t co16[8] = { 0 };
__m128i vco;
co16[0] = co[0]; co16[1] = co[1]; co16[2] = co[2]; co16[3] = co[3];
vco = _mm_loadu_si128((const __m128i *)co16);
if (eightbit)
{
btop = _mm_srli_epi16(_mm_and_si128(vb, _mm_set1_epi16(0x0080)), 7);
ctop = carry0;
}
else
{
btop = _mm_srli_epi16(vb, 15);
ctop = vco;
}
/* do_saturate = btop ^ ctop (non-zero lanes saturate) */
do_saturate = _mm_xor_si128(btop, ctop);
/* do_hi_saturate = eightbit ? 0 : do_saturate */
do_hi_saturate = eightbit ? _mm_setzero_si128() : do_saturate;
/* Build saturated fill: ctop ? 0x00FF/0xFF00 : 0x0000 */
/* ctop is 0 or 1 in each lane. Expand to mask: cmpeq with 1 gives 0xFFFF where ctop=1 */
{
__m128i vone = _mm_set1_epi16(1);
__m128i ctop_mask = _mm_cmpeq_epi16(ctop, vone);
sat_lo_fill = _mm_and_si128(ctop_mask, mask_lo); /* 0x00FF where ctop=1 */
sat_hi_fill = _mm_and_si128(ctop_mask, _mm_set1_epi16((short)0xFF00));
/* Expand do_saturate/do_hi_saturate to masks */
sat_mask = _mm_cmpeq_epi16(do_saturate, vone);
hi_sat_mask = _mm_cmpeq_epi16(do_hi_saturate, vone);
}
/* Select: if saturating use fill, else use q */
result_lo = _mm_or_si128(
_mm_and_si128(sat_mask, sat_lo_fill),
_mm_andnot_si128(sat_mask, _mm_and_si128(q, mask_lo))
);
result_hi = _mm_or_si128(
_mm_and_si128(hi_sat_mask, sat_hi_fill),
_mm_andnot_si128(hi_sat_mask, _mm_and_si128(q, _mm_set1_epi16((short)0xFF00)))
);
q = _mm_or_si128(result_lo, result_hi);
}
/* Store result — only low 4 lanes (64 bits) */
_mm_storel_epi64((__m128i *)addq, q);
}
#endif /* BLITTER_SIMD_SSE2_H */
+8 -2
View File
@@ -967,8 +967,14 @@ void GPUSyncToM68K(void)
if (!GPU_RUNNING || (gpu_control & 0x08) || gpu_in_exec)
return;
/* GPU is clocked at twice the 68000 (JTRM clock hierarchy). */
target = (int32_t)m68k_cycles_run() * 2;
/* GPU is clocked at twice the 68000 (JTRM clock hierarchy). With
* clock scales (issue #314) both sides run in their own scaled cycle
* domain, so map through wall time: 68K cycles run are divided by the
* M68K scale to get wall position, then multiplied by 2 and the RISC
* scale to get the GPU's scaled position. At 100/100 this is
* (run * 200) / 100 == run * 2, an exact identity. */
target = (int32_t)(((int64_t)m68k_cycles_run() * 2 * riscClockScalePct)
/ m68kClockScalePct);
if (target > gpuSliceBudget)
target = gpuSliceBudget;
+61
View File
@@ -0,0 +1,61 @@
# Scripted-input fixtures
Plain-text `--press` scripts for the shared harness (`test/harness/`).
## `avp_reach_gameplay.press`
Drives **Alien vs Predator (1994)** from boot into sustained Alien
first-person gameplay. Unlocks #266 / #267 follow-on work; does **not**
reach weapon-select / shotgun.
```bash
bash test/tools/run_avp_fixture.sh ./virtualjaguar_libretro.dylib
```
Requires `test/roms/private` → private ROM tree and a `TEST_EXPORTS=1` core
build. Gate is mechanical (late-window motion + non-black), not visual.
## `avp_reach_marine_shotgun.press`
Reaches **Colonial Marine** gameplay and picks up the shotgun, so the #267
artefact (red backdrop behind the shotgun HUD icon on the accurate blitter)
can be reproduced headlessly. The Alien fixture above cannot reach it - the
shotgun is a Marine weapon.
~~~bash
PRESS_FILE=test/fixtures/avp_reach_marine_shotgun.press FRAMES=12200 \
bash test/tools/run_avp_fixture.sh ./virtualjaguar_libretro.dylib
~~~
Needs **~12200 frames**: pickup lands around frame 10520 and the artefact shows
from ~10560. The route after the briefing is a seeded wander, not a solved
path, so it depends on the core staying deterministic - re-verify the pickup
frame if emulation timing changes.
A savestate captured mid-route does **not** carry the artefact, so drive
the fixture from boot rather than via `--load-state`.
## `dragons_lair_death_branch.press` / `dragons_lair_death_branch_bios.press`
Drives **Dragon's Lair (USA)** (Jaguar CD) out of the attract loop into
gameplay and kills Dirk, forcing a **scene branch** — a near-full-stroke CD
read (LBA 16026 → 154061, +138 035 sectors) to the death clip and back. Used
to measure branch-transition timing for #297; see
[`docs/fmv-drift-notes.md`](../../docs/fmv-drift-notes.md) §10.
~~~bash
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
bash test/tools/run_dl_branch_fixture.sh ./virtualjaguar_libretro.dylib hle
VJ_EXPECT_BUILD=$(./scripts/build-id.sh) \
bash test/tools/run_dl_branch_fixture.sh ./virtualjaguar_libretro.dylib bios
~~~
Two files because BIOS boot reaches the same state **+439 fields** later, and
`--bios` does **not** switch CD boot mode — the runner passes
`--option virtualjaguar_cd_boot_mode=bios` and then verifies the mode from the
core's own `[BOOT] CD game, mode=…` log line.
Needs `test/tools/fmv_seek_probe` built (build line in the runner's error
message). The gate is the CD trace ring — a read whose LBA delta exceeds
100 000 sectors — **not** the `seeks`/`seekstarts` CSV columns, which are
structurally 0 in HLE mode because HLE never touches the BUTCH `$12xx` path.
A no-press control run fails the gate, so it is falsifiable.
+29
View File
@@ -0,0 +1,29 @@
# Alien vs Predator (1994).jag — reach sustained Alien first-person gameplay.
# BIOS off (core default). Frames are absolute from boot.
#
# Timeline (measured on develop @ 4eab779):
# ~240-480 title art animates
# ~720 main menu (SELECT CHARACTER)
# ~900-1100 character select / confirm
# ~1140-1500 briefing ("LOADING SIMULATION... PRESS FIRE")
# ~1500+ in-game; hold d-pad for sustained motion
780:b:10
900:b:10
1020:b:10
1140:b:10
1260:b:15
1380:b:15
1500:b:15
1620:b:15
1740:b:10
1860:up:120
1980:right:120
2100:down:120
2220:left:120
2340:up:120
2460:right:120
2580:up:120
2700:left:120
2820:right:120
2940:up:60
+138
View File
@@ -0,0 +1,138 @@
# Alien vs Predator (1994).jag -- reach Colonial Marine gameplay with the
# shotgun equipped, for GitHub issue #267 (red background behind the shotgun
# HUD icon on the accurate blitter).
#
# BIOS off (core default). Frames are absolute from boot.
#
# Timeline (measured on investigate/267-shotgun @ 888dfb6):
# ~780 main menu, "SELECT CHARACTER" already highlighted
# 840 FIRE -> character-select carousel (starts on ALIEN)
# 1140/1260 RIGHT twice -> ALIEN -> PREDATOR -> COLONIAL MARINE
# 1400 FIRE -> confirm Marine
# 1520-2000 FIRE through the briefing / "LOADING SIMULATION..."
# ~2100 in-game, unarmed
# 2200+ seeded wander (gen_wander.py, seed 7) through the Security Brig
# ~10520 shotgun picked up; HUD slot 1 icon fades in
# ~10560+ accurate blitter shows a red backdrop behind that icon
# at x[249..298] y[62..79]; fast blitter does not
#
# The wander is a fixed script, not a solved route: it depends on the core
# staying deterministic. Re-verify the pickup frame if emulation timing
# changes. A save state taken after the pickup does NOT carry the artefact,
# so drive it from boot with this script.
840:b:10
1140:right:6
1260:right:6
1400:b:10
1520:b:10
1640:b:10
1760:b:10
1880:b:10
2000:b:10
2200:up:120
2360:left:30
2410:up:90
2570:left:20
2610:up:150
2770:left:10
2800:a:8
2830:up:120
2990:left:15
3025:a:8
3055:up:120
3215:left:45
3280:a:8
3310:up:90
3470:left:45
3535:up:90
3695:left:10
3725:up:90
3885:right:30
3935:a:8
3965:up:90
4125:right:45
4190:up:90
4350:left:45
4415:up:90
4575:right:10
4605:up:90
4765:left:45
4830:a:8
4860:up:150
5020:right:20
5060:up:120
5220:right:20
5260:a:8
5290:up:90
5450:left:10
5480:up:150
5640:right:20
5680:up:120
5840:left:10
5870:up:90
6030:right:15
6065:up:120
6225:left:10
6255:up:150
6415:right:20
6455:up:150
6615:right:45
6680:up:90
6840:left:20
6880:up:150
7040:left:10
7070:up:120
7230:right:20
7270:up:150
7430:right:10
7460:up:120
7620:left:45
7685:a:8
7715:up:90
7875:left:20
7915:a:8
7945:up:90
8105:right:30
8155:up:120
8315:left:15
8350:up:150
8510:right:15
8545:up:150
8705:right:30
8755:up:150
8915:right:15
8950:a:8
8980:up:90
9140:left:15
9175:up:90
9335:right:45
9400:a:8
9430:up:120
9590:left:15
9625:up:120
9785:right:15
9820:up:150
9980:left:30
10030:up:150
10190:right:30
10240:up:90
10400:right:30
10450:a:8
10480:up:90
10640:left:30
10690:a:8
10720:up:120
10880:left:10
10910:a:8
10940:up:90
11100:left:20
11140:up:90
11300:left:45
11365:up:150
11525:right:20
11565:up:120
11725:left:10
11755:up:120
11915:right:30
11965:up:90
12125:left:20
+34
View File
@@ -0,0 +1,34 @@
# Dragon's Lair (USA).cue -- drive the attract movie into gameplay and die,
# forcing a scene BRANCH (a near-full-stroke seek to the death clip and back).
#
# HLE boot mode (core default: virtualjaguar_cd_boot_mode=hle).
# For the real-BIOS path use dragons_lair_death_branch_bios.press -- BIOS boot
# is +439 fields, and `--bios` alone does NOT switch CD boot mode; that needs
# --option virtualjaguar_cd_boot_mode=bios.
#
# Verified on libretro/develop @ 674f600 -- see docs/fmv-drift-notes.md §10.
#
# Timeline (fields, absolute from boot, HLE):
# ~500/560 pause x2 leaves the attract loop and starts a game
# ~700 `a` is the first QTE input; the branch fires the same field
# ~700 BRANCH: CD read jumps LBA 16026 -> 154061 (+138035 sectors)
# ~828 BRANCH: LBA 154061 -> 20491 (retry scene)
# thereafter the die/retry loop self-repeats every ~1171 fields with no
# further input: branches at ~1871, ~2000, ~3043, ~3172, ...
#
# Success criterion (objective, not visual): a CD read whose LBA delta from
# the previous read exceeds 100000 sectors. Steady-state streaming reads step
# 339-428 sectors and the attract-loop restart steps 5171, so the separation is
# ~2.5 orders of magnitude. A no-press control run produces NO such jump.
#
# bash test/tools/run_dl_branch_fixture.sh ./virtualjaguar_libretro.dylib
#
# NOTE for HLE runs: the `seeks`/`seekstarts`/`seekdones` columns of
# fmv_seek_probe's CSV are incremented only by the BUTCH $12xx register path in
# src/cd/cdrom.c, which HLE bypasses entirely. They are structurally 0 in HLE
# whether or not a branch happened -- do not use them as the HLE gate. Use the
# HLE_READ trace event's LBA instead (VJ_CD_TRACE=1 VJ_CD_TRACE_LIVE=1).
500:pause
560:pause
700:a
+28
View File
@@ -0,0 +1,28 @@
# Dragon's Lair (USA).cue -- real-BIOS variant of dragons_lair_death_branch.press.
#
# REQUIRES --option virtualjaguar_cd_boot_mode=bios. `--bios` alone does NOT
# switch CD boot mode; a run labelled "BIOS" without the option is byte-identical
# HLE. Confirm from the run's own log: "[BOOT] CD game, mode=BIOS".
#
# BIOS boot reaches the same state +439 fields later than HLE, so every frame
# number here is the HLE number + 439.
#
# Verified on libretro/develop @ 674f600 -- see docs/fmv-drift-notes.md §10.
#
# Timeline (fields, absolute from boot, BIOS):
# ~939/999 pause x2 leaves the attract loop and starts a game
# ~1139 `a` is the first QTE input; the branch fires the same field
# ~1139 BRANCH: SEEK_START LBA 16026 -> 154061 (+138035 sectors)
# ~1268 BRANCH: SEEK_START LBA 154061 -> 20491 (retry scene)
# thereafter self-repeating every ~1171 fields: ~2310, ~2439, ~3482, ~3611
#
# Success criterion: fmv_seek_probe's `seekstarts` CSV column increments at
# those fields AND the SEEK_START trace event's LBA delta exceeds 100000
# sectors. A no-press control run on the same build produces only 339-428
# sector streaming seeks plus one -5171 attract restart -- no branch.
#
# bash test/tools/run_dl_branch_fixture.sh ./virtualjaguar_libretro.dylib bios
939:pause
999:pause
1139:a
+38 -1
View File
@@ -120,6 +120,12 @@ static int16_t input_state(unsigned p, unsigned d, unsigned i, unsigned id)
{ (void)p; (void)d; (void)i; (void)id; return 0; }
static int use_bios = 0;
/* --option K=V pairs served through RETRO_ENVIRONMENT_GET_VARIABLE. */
#define MAX_OPTS 8
static const char *opt_keys[MAX_OPTS];
static const char *opt_vals[MAX_OPTS];
static int opt_count = 0;
static int log_quiet = 0;
static void log_printf(enum retro_log_level level, const char *fmt, ...)
@@ -158,11 +164,22 @@ static bool environment(unsigned cmd, void *data)
case RETRO_ENVIRONMENT_GET_VARIABLE:
{
struct retro_variable *var = (struct retro_variable *)data;
int oi;
if (var->key && strcmp(var->key, "virtualjaguar_bios") == 0)
{
var->value = use_bios ? "enabled" : "disabled";
return true;
}
/* Generic --option K=V passthrough (added for the clock-scale
* suite rows, issue #314; same contract as the shared harness). */
for (oi = 0; oi < opt_count; oi++)
{
if (var->key && strcmp(var->key, opt_keys[oi]) == 0)
{
var->value = opt_vals[oi];
return true;
}
}
var->value = NULL;
return false;
}
@@ -271,6 +288,20 @@ int main(int argc, char **argv)
else if (!strcmp(a, "--rms-ceiling") && i + 1 < argc) rms_ceiling = atof(argv[++i]);
else if (!strcmp(a, "--max-zero-run-pct") && i + 1 < argc) max_zero_run_pct = atof(argv[++i]);
else if (!strcmp(a, "--label") && i + 1 < argc) label = argv[++i];
else if (!strcmp(a, "--option") && i + 1 < argc)
{
char *kv = argv[++i];
char *eq = strchr(kv, '=');
if (!eq || opt_count >= MAX_OPTS)
{
fprintf(stderr, "Bad or excess --option (want K=V): %s\n", kv);
return 2;
}
*eq = '\0';
opt_keys[opt_count] = kv;
opt_vals[opt_count] = eq + 1;
opt_count++;
}
else
{
fprintf(stderr, "Unknown arg: %s\n", a);
@@ -353,9 +384,15 @@ int main(int argc, char **argv)
zero_run_pct = (unsigned)(frames_in_run / (double)active_window_frames * 100.0);
}
/* The banner is printed even under --quiet, matching test_audio_clipping.
* --quiet is meant to suppress the core's log spam, not to hide which
* check just ran: with it suppressed, `make test` emitted a bare
* "PASS: audio is present..." line that named no title, so a reader could
* not tell which ROM (or whether any ROM) had been exercised. */
printf("\n=== Presence check: %s ===\n", label ? label : rom_path);
if (!log_quiet)
{
printf("\n=== Presence check: %s ===\n", label ? label : rom_path);
printf(" ROM: %s\n", rom_path);
printf(" Frames: %u, Window: [%u, %u)\n",
total_frames, WINDOW_START_FRAME, total_frames);
+45 -12
View File
@@ -95,10 +95,40 @@ static bool tf_current_failed = false;
/* BIOS file paths */
/* ------------------------------------------------------------------ */
#define BIOS_DIR "test/roms/private"
#define JAGUAR_BIOS_PATH BIOS_DIR "/[BIOS] Atari Jaguar (World).j64"
#define JAGUAR_CD_BIOS_PATH BIOS_DIR "/[BIOS] Atari Jaguar CD (World).j64"
#define JAGUAR_CD_BIOS_ROM BIOS_DIR "/Jaguar CD BIOS.rom"
/* These were compile-time literals rooted directly at "test/roms/private".
* The corpus keeps its BIOS dumps in a ROMS/ subdirectory, so the paths
* never resolved and all eight real-BIOS assertions below reported SKIP --
* while the suite still exited 0. That is the same silent-skip class as
* the Skyhammer clipping sentinel: a check that cannot find its input
* reads exactly like a check that passed.
*
* `make test` now locates the directory with scripts/find-rom.sh and passes
* it in via VJ_BIOS_DIR. The old literal stays as the fallback so a bare
* invocation of this binary behaves as it always did. */
#define BIOS_DIR_DEFAULT "test/roms/private"
static char bios_dir[512];
static char jaguar_bios_path[640];
static char jaguar_cd_bios_path[640];
static char jaguar_cd_bios_rom[640];
#define JAGUAR_BIOS_PATH jaguar_bios_path
#define JAGUAR_CD_BIOS_PATH jaguar_cd_bios_path
#define JAGUAR_CD_BIOS_ROM jaguar_cd_bios_rom
static void init_bios_paths(void)
{
const char *env = getenv("VJ_BIOS_DIR");
snprintf(bios_dir, sizeof(bios_dir), "%s",
(env && *env) ? env : BIOS_DIR_DEFAULT);
snprintf(jaguar_bios_path, sizeof(jaguar_bios_path),
"%s/[BIOS] Atari Jaguar (World).j64", bios_dir);
snprintf(jaguar_cd_bios_path, sizeof(jaguar_cd_bios_path),
"%s/[BIOS] Atari Jaguar CD (World).j64", bios_dir);
snprintf(jaguar_cd_bios_rom, sizeof(jaguar_cd_bios_rom),
"%s/Jaguar CD BIOS.rom", bios_dir);
}
static bool file_exists(const char *path)
{
@@ -401,7 +431,7 @@ TEST(real_bios_init_succeeds)
{
current_bios_mode = BIOS_MODE_REAL;
current_cd_mode = CD_MODE_DISABLED;
current_system_dir = BIOS_DIR;
current_system_dir = bios_dir;
bc_init(&core);
ASSERT_TRUE(core.GetRamPtr != NULL);
core.retro_deinit();
@@ -415,7 +445,7 @@ TEST(real_bios_boot_rom_space_accessible)
* Actual BIOS loading requires retro_load_game (not just retro_init). */
current_bios_mode = BIOS_MODE_REAL;
current_cd_mode = CD_MODE_DISABLED;
current_system_dir = BIOS_DIR;
current_system_dir = bios_dir;
bc_init(&core);
val = core.JaguarReadWord(0xE00000, CALLER_M68K);
@@ -430,7 +460,7 @@ TEST(real_bios_ram_accessible)
uint16_t val;
current_bios_mode = BIOS_MODE_REAL;
current_cd_mode = CD_MODE_DISABLED;
current_system_dir = BIOS_DIR;
current_system_dir = bios_dir;
bc_init(&core);
core.JaguarWriteWord(0x6000, 0xBEEF, CALLER_M68K);
@@ -445,7 +475,7 @@ TEST(real_bios_gpu_init_ok)
uint16_t val;
current_bios_mode = BIOS_MODE_REAL;
current_cd_mode = CD_MODE_DISABLED;
current_system_dir = BIOS_DIR;
current_system_dir = bios_dir;
bc_init(&core);
/* GPU RAM should be accessible */
@@ -464,7 +494,7 @@ TEST(real_cd_bios_init_succeeds)
{
current_bios_mode = BIOS_MODE_REAL;
current_cd_mode = CD_MODE_REAL;
current_system_dir = BIOS_DIR;
current_system_dir = bios_dir;
bc_init(&core);
ASSERT_TRUE(core.GetRamPtr != NULL);
core.retro_deinit();
@@ -480,7 +510,7 @@ TEST(real_cd_bios_cart_space_accessible)
* Verify address decode works without crash. */
current_bios_mode = BIOS_MODE_REAL;
current_cd_mode = CD_MODE_REAL;
current_system_dir = BIOS_DIR;
current_system_dir = bios_dir;
bc_init(&core);
w0 = core.JaguarReadWord(0x800000, CALLER_M68K);
@@ -496,7 +526,7 @@ TEST(real_cd_bios_butch_accessible)
uint16_t val;
current_bios_mode = BIOS_MODE_REAL;
current_cd_mode = CD_MODE_REAL;
current_system_dir = BIOS_DIR;
current_system_dir = bios_dir;
bc_init(&core);
val = core.JaguarReadWord(0xDFFF00, CALLER_M68K);
@@ -511,7 +541,7 @@ TEST(real_cd_bios_jerry_accessible)
uint16_t val;
current_bios_mode = BIOS_MODE_REAL;
current_cd_mode = CD_MODE_REAL;
current_system_dir = BIOS_DIR;
current_system_dir = bios_dir;
bc_init(&core);
/* JERRY registers should be accessible with CD BIOS loaded */
@@ -531,6 +561,9 @@ int main(int argc, char *argv[])
(void)argc; (void)argv;
TEST_INIT("BIOS Configuration");
init_bios_paths();
fprintf(stderr, " [INFO] BIOS dir: %s\n", bios_dir);
/* Check which BIOS files are available */
have_jaguar_bios = file_exists(JAGUAR_BIOS_PATH);
have_cd_bios = file_exists(JAGUAR_CD_BIOS_PATH) || file_exists(JAGUAR_CD_BIOS_ROM);
+32 -6
View File
@@ -265,7 +265,30 @@ TEST(strategy_names)
static const char *env_cd_boot_mode = "auto";
static const char *env_bios_enabled = "enabled";
static const char *env_system_dir = "test/roms/private";
/* BIOS directory, resolved at run time. This used to be the literal
* "test/roms/private", but the corpus keeps BIOS dumps in a ROMS/
* subdirectory, so CD_BIOS_PATH never resolved and the two
* "_with_bios" integration tests always reported SKIP -- with the suite
* still exiting 0. Same silent-skip class as the Skyhammer clipping
* sentinel. `make test` passes the real directory in via VJ_BIOS_DIR;
* the old literal remains the fallback. */
#define BIOS_DIR_DEFAULT "test/roms/private"
static char bios_dir[512];
static char cd_bios_path[640];
static void init_bios_paths(void)
{
const char *env = getenv("VJ_BIOS_DIR");
snprintf(bios_dir, sizeof(bios_dir), "%s",
(env && *env) ? env : BIOS_DIR_DEFAULT);
snprintf(cd_bios_path, sizeof(cd_bios_path),
"%s/[BIOS] Atari Jaguar CD (World).j64", bios_dir);
}
static const char *env_system_dir = BIOS_DIR_DEFAULT;
static void stub_video(const void *d, unsigned w, unsigned h, size_t p)
{ (void)d; (void)w; (void)h; (void)p; }
@@ -376,14 +399,14 @@ static void find_first_cue(const char *dir)
closedir(dp);
}
#define CD_BIOS_PATH "test/roms/private/[BIOS] Atari Jaguar CD (World).j64"
#define CD_BIOS_PATH cd_bios_path
TEST(integration_hle_mode)
{
bool loaded;
env_cd_boot_mode = "hle";
env_bios_enabled = "enabled";
env_system_dir = "test/roms/private";
env_system_dir = bios_dir;
core_init();
loaded = core_load_disc(g_test_cue);
@@ -405,7 +428,7 @@ TEST(integration_bios_mode_with_bios)
bool loaded;
env_cd_boot_mode = "bios";
env_bios_enabled = "enabled";
env_system_dir = "test/roms/private";
env_system_dir = bios_dir;
core_init();
loaded = core_load_disc(g_test_cue);
@@ -428,7 +451,7 @@ TEST(integration_auto_mode_with_bios)
bool loaded;
env_cd_boot_mode = "auto";
env_bios_enabled = "enabled";
env_system_dir = "test/roms/private";
env_system_dir = bios_dir;
core_init();
loaded = core_load_disc(g_test_cue);
@@ -499,7 +522,7 @@ TEST(integration_hle_bios_setting_off)
bool loaded;
env_cd_boot_mode = "hle";
env_bios_enabled = "disabled";
env_system_dir = "test/roms/private";
env_system_dir = bios_dir;
core_init();
loaded = core_load_disc(g_test_cue);
@@ -527,6 +550,9 @@ int main(int argc, char *argv[])
(void)argc; (void)argv;
total_fail = 0;
init_bios_paths();
fprintf(stderr, " [INFO] BIOS dir: %s\n", bios_dir);
if (!load_core()) return 1;
/* ---- Part 1: Unit tests (no disc needed) ---- */
File diff suppressed because it is too large. Load diff
+781
View File
@@ -0,0 +1,781 @@
/*
* test_cd_synth_cdda.c -- CD-DA (Red Book audio) delivery over the SSI,
* pinned against a SYNTHETIC multi-track audio disc.
*
* test/test_cd_ssi_stream.c already covers this contract, but it SKIPs
* unless VJ_SSI_DISC points at a commercial image under
* test/roms/private -- gitignored, absent in CI and on a fresh clone -- so
* in practice nothing guards the CD-audio path. This test builds its own
* disc in a scratch dir and therefore always runs.
*
* THE DISC IS THE ORACLE
* ----------------------
* The PCM is generated from a closed-form integer phase accumulator, so
* the decoded AUDIO ITSELF says what is playing and where -- the audio
* analogue of test_cd_synth_butch.c's disc_pat() byte pattern:
*
* - Base frequency encodes the TRACK. Track t starts at 882*t Hz, and
* a zero-crossing count over any window identifies t on its own.
* - A linear chirp inside each track (+441 Hz over its 2 s) encodes the
* POSITION within the track: instantaneous frequency rises
* monotonically, so a rewound or re-framed head shows up as a
* frequency that goes backwards -- in the audio domain, without
* needing to know any LBA.
* - LEFT and RIGHT differ: right is the second harmonic at a different
* amplitude. A channel swap is invisible on a mono disc and glaring
* here. (This repo has real SSI channel/alignment history.)
* - Amplitude is a known constant (square wave, never zero), so
* "silence where there should be tone" is unambiguous.
* - The only silence on the disc is the 150-sector INDEX 00 pregap in
* front of tracks 2..4. Silence anywhere else is a failure.
*
* The disc is a MULTI-FILE CUE (one BIN per track) with real pregaps --
* the exact shape every CUE in the Jaguar CD corpus uses. That makes
* dataLBA != startLBA, so the loader's pregap handling is under test
* rather than bypassed (a single-file no-pregap disc has them equal and
* hides that whole class).
*
* INVARIANTS PINNED (each with a documented negative control)
* ----------------------------------------------------------
* 1. Channel order and sample framing -- LRXD carries the LEFT channel
* (little-endian bytes [4i+0..1]) and RRXD the RIGHT ([4i+2..3]),
* sample-aligned from byte 0 of the seek target sector, crossing
* sector boundaries linearly.
* NEGATIVE CONTROL: swap the two reads in
* SetSSIWordsXmittedFromButch() (src/cd/cdrom.c) -> test 2 fails.
* 2. Track identity from the audio -- a seek to track t's INDEX 01
* delivers audio whose measured frequency is in track t's band and
* in no other track's.
* NEGATIVE CONTROL: make CDIntfGetTrackInfo() use startLBA instead
* of dataLBA (src/cd/cdintf.c), i.e. drop the pregap from the TOC ->
* the seek lands 150 sectors early, in silence -> test 3 fails.
* 3. Stream continuity across a redundant seek -- re-issuing the seek
* the drive is already serving must not rewind the SSI head. The
* chirp makes this an audio-domain assertion: instantaneous
* frequency never goes backwards. (Regression guard for #306/#307,
* where a redundant seek replayed up to ~13 ms of CD-DA.)
* NEGATIVE CONTROL: restore `ssiBufPtr = 0;` on the redundant-seek
* branch of the $12xx handler (src/cd/cdrom.c) -> test 4 fails.
* 4. Pregap is silence and the tone starts exactly at INDEX 01 --
* seeking two sectors before track t's data start yields exactly
* 1176 zero samples and then phase 0 of track t.
* NEGATIVE CONTROL: same dataLBA/startLBA sabotage as 2 -> test 5
* fails.
*
* No timing constant is asserted; every timing claim is relational.
*
* Build: make test/test_cd_synth_cdda (needs TEST_EXPORTS=1)
* Run: DYLD_LIBRARY_PATH=. test/test_cd_synth_cdda
*/
#include "test_framework.h"
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>
static struct vj_core C;
/* ------------------------------------------------------------------ */
/* Core internals resolved by dlsym */
/* ------------------------------------------------------------------ */
static bool (*p_open_image)(const char *);
static void (*p_close_image)(void);
static uint32_t (*p_num_tracks)(void);
static uint8_t (*p_track_info)(uint32_t, uint32_t);
static void (*p_butch_exec)(uint32_t);
static void (*p_seek_state)(uint32_t *, uint32_t *, uint32_t *);
static void (*p_ssi_xmit)(void);
static uint16_t *p_lrxd;
static uint16_t *p_rrxd;
/* ------------------------------------------------------------------ */
/* BUTCH register offsets (CDROMReadWord/CDROMWriteWord mask to & $FF) */
/* ------------------------------------------------------------------ */
#define R_BUTCH_LO 0x02u
#define R_DS_DATA 0x0Au
#define R_I2CNTRL_LO 0x12u
#define ST_RX_FULL 0x2000u
#define I2S_DATA_ENABLE 0x0001u
#define CALLER 0u /* `who` argument -- UNKNOWN; the enum in
* vjag_memory.h is { UNKNOWN, JAGUAR, DSP,
* GPU, TOM, JERRY, M68K, ... }, so 0 is
* UNKNOWN and M68K would be 6. */
/* ------------------------------------------------------------------ */
/* Synthetic disc geometry */
/* ------------------------------------------------------------------ */
#define SECTOR_BYTES 2352u
#define SAMPLES_PER_SECT (SECTOR_BYTES / 4u) /* 588 stereo frames */
#define NUM_TRACKS 4u
#define AUDIO_SECTORS 150u /* 2 s per track */
#define PREGAP_SECTORS 150u /* 2 s, Red Book gap */
#define TRACK_SAMPLES (AUDIO_SECTORS * SAMPLES_PER_SECT) /* 88200 */
/* Bounds: generous enough that a legitimate constant change cannot trip
* them, tight enough that a hang is reported instead of spinning. */
#define MAX_SEEK_TICKS 200000u
#define MAX_DSA_TICKS 4096u
/* ------------------------------------------------------------------ */
/* Deterministic PCM: a per-track chirp */
/* ------------------------------------------------------------------ */
/* Phase increment per sample for 882.0 Hz at 44100 Hz, in 32-bit phase
* units: 882/44100 * 2^32 = 0.02 * 2^32. Track t starts at 882*t Hz. */
#define BASE_INC 85899346u
/* Chirp rate: +441 Hz spread over one track's 88200 samples. Track t
* therefore sweeps 882*t -> 882*t + 441 Hz, leaving a 441 Hz gap between
* adjacent tracks' bands -- wide enough that a windowed frequency
* measurement identifies the track with no ambiguity. */
#define CHIRP_DINC 487u
#define AMP_LEFT 20000
#define AMP_RIGHT 12000
/* Phase after `n` samples of track `track` (1-based).
*
* phase(n) = n*inc0 + DINC * (n*(n-1)/2) (mod 2^32)
*
* Closed form, so any sample is reproducible without generating the ones
* before it. All arithmetic is uint32 and wrapping is intended. */
static uint32_t pcm_phase(uint32_t track, uint32_t n)
{
uint32_t inc0 = track * BASE_INC;
uint32_t tri;
/* n*(n-1)/2 without overflowing before the (intended) mod-2^32 wrap:
* exactly one of n, n-1 is even, so halve that one first. */
if (n & 1u)
tri = ((n - 1u) / 2u) * n;
else
tri = (n / 2u) * (n - 1u);
return n * inc0 + CHIRP_DINC * tri;
}
/* Sample `n` of track `track`. Square waves: the amplitude is an exact
* known constant and the value is never zero, so a zero sample always
* means "no disc audio here". */
static void pcm_sample(uint32_t track, uint32_t n, int16_t *l, int16_t *r)
{
uint32_t ph = pcm_phase(track, n);
*l = (ph & 0x80000000u) ? (int16_t)(-AMP_LEFT) : (int16_t)AMP_LEFT;
/* Right channel is the second harmonic (phase doubled) at a different
* amplitude -- L and R never agree in both sign pattern and level. */
*r = (ph & 0x40000000u) ? (int16_t)(-AMP_RIGHT) : (int16_t)AMP_RIGHT;
}
/* ------------------------------------------------------------------ */
/* Synthetic disc construction */
/* ------------------------------------------------------------------ */
static void track_bin_name(char *out, size_t outLen, const char *dir,
uint32_t track)
{
snprintf(out, outLen, "%s/track%02u.bin", dir, (unsigned)track);
}
static bool write_track_bin(const char *dir, uint32_t track)
{
char path[1024];
uint8_t *bin = NULL;
uint32_t pregap = (track == 1u) ? 0u : PREGAP_SECTORS;
size_t binLen = (size_t)(pregap + AUDIO_SECTORS) * SECTOR_BYTES;
size_t gapLen = (size_t)pregap * SECTOR_BYTES;
uint32_t n;
size_t written;
FILE *f;
bool ok = false;
bin = (uint8_t *)calloc(1, binLen);
if (!bin)
return false;
/* [0, gapLen) stays zero: the INDEX 00 pregap. Audio follows. */
for (n = 0; n < TRACK_SAMPLES; n++)
{
size_t o = gapLen + (size_t)n * 4u;
int16_t l, r;
pcm_sample(track, n, &l, &r);
bin[o + 0] = (uint8_t)((uint16_t)l & 0xFFu);
bin[o + 1] = (uint8_t)(((uint16_t)l >> 8) & 0xFFu);
bin[o + 2] = (uint8_t)((uint16_t)r & 0xFFu);
bin[o + 3] = (uint8_t)(((uint16_t)r >> 8) & 0xFFu);
}
track_bin_name(path, sizeof(path), dir, track);
f = fopen(path, "wb");
if (!f)
goto done;
written = fwrite(bin, 1, binLen, f);
fclose(f);
ok = (written == binLen);
done:
free(bin);
return ok;
}
static bool make_disc(const char *dir, char *cueOut, size_t cueOutLen)
{
uint32_t t;
FILE *f;
for (t = 1u; t <= NUM_TRACKS; t++)
if (!write_track_bin(dir, t))
return false;
snprintf(cueOut, cueOutLen, "%s/musiccd.cue", dir);
f = fopen(cueOut, "wb");
if (!f)
return false;
/* Multi-file CUE, file-relative INDEX MSF -- the corpus shape. Tracks
* 2..4 carry a real 150-sector (00:02:00) INDEX 00 pregap. */
fprintf(f, "REM SESSION 01\n");
for (t = 1u; t <= NUM_TRACKS; t++)
{
fprintf(f, "FILE \"track%02u.bin\" BINARY\n", (unsigned)t);
fprintf(f, " TRACK %02u AUDIO\n", (unsigned)t);
if (t == 1u)
fprintf(f, " INDEX 01 00:00:00\n");
else
fprintf(f, " INDEX 00 00:00:00\n"
" INDEX 01 00:02:00\n");
}
fclose(f);
return true;
}
static void scrub_scratch(const char *base)
{
char path[1024];
uint32_t t;
for (t = 1u; t <= NUM_TRACKS; t++)
{
track_bin_name(path, sizeof(path), base, t);
remove(path);
}
snprintf(path, sizeof(path), "%s/musiccd.cue", base);
remove(path);
rmdir(base);
}
/* ------------------------------------------------------------------ */
/* BUTCH driving helpers -- these are the CD BIOS's moves */
/* ------------------------------------------------------------------ */
static uint16_t butch_status(void)
{
return C.CDROMReadWord(R_BUTCH_LO, CALLER);
}
static void dsa_send(uint16_t cmd)
{
C.CDROMWriteWord(R_DS_DATA, cmd, CALLER);
}
static uint16_t dsa_recv(void)
{
return C.CDROMReadWord(R_DS_DATA, CALLER);
}
static uint32_t wait_rx_full(uint32_t max)
{
uint32_t n = 0;
while (n <= max)
{
if (butch_status() & ST_RX_FULL)
return n;
p_butch_exec(0);
n++;
}
return 0xFFFFFFFFu;
}
static void dsa_seek(uint32_t lba)
{
uint32_t tf = lba + 150u;
dsa_send((uint16_t)(0x1000u | (tf / 4500u)));
dsa_send((uint16_t)(0x1100u | ((tf / 75u) % 60u)));
dsa_send((uint16_t)(0x1200u | (tf % 75u)));
}
static uint32_t seek_dones(void)
{
uint32_t dones = 0;
p_seek_state(NULL, &dones, NULL);
return dones;
}
static uint32_t wait_seek_done(uint32_t before, uint32_t max)
{
uint32_t n = 0;
while (n <= max)
{
if (seek_dones() != before)
return n;
p_butch_exec(0);
n++;
}
return 0xFFFFFFFFu;
}
/* Reset + I2S enable + seek to `lba` + consume the Found response, leaving
* the drive playing with the SSI head parked at byte 0 of `lba`. */
static bool prime_stream(uint32_t lba)
{
uint32_t before;
uint16_t r;
C.CDROMReset();
C.CDROMWriteWord(R_I2CNTRL_LO, I2S_DATA_ENABLE, CALLER);
before = seek_dones();
dsa_seek(lba);
if (wait_seek_done(before, MAX_SEEK_TICKS) == 0xFFFFFFFFu)
{
fprintf(stderr, " (seek to LBA %u never completed)\n", lba);
return false;
}
if (wait_rx_full(MAX_DSA_TICKS) == 0xFFFFFFFFu)
{
fprintf(stderr, " (seek response never became visible)\n");
return false;
}
r = dsa_recv();
if (r != 0x0100)
{
fprintf(stderr, " (seek response was $%04X, expected $0100)\n", r);
return false;
}
return true;
}
/* One SSI sample pair, exactly as JERRY's slave-mode I2S callback takes
* it: SetSSIWordsXmittedFromButch() advances the head and latches
* LRXD/RRXD, which the DSP then reads. */
static void ssi_next(int16_t *l, int16_t *r)
{
p_ssi_xmit();
*l = (int16_t)*p_lrxd;
*r = (int16_t)*p_rrxd;
}
/* ------------------------------------------------------------------ */
/* Frequency measurement -- the audio-domain oracle */
/* ------------------------------------------------------------------ */
/* Sign changes over `n` samples. For a square wave that is exactly two
* per cycle, so freq_hz = crossings * 44100 / (2 * n). */
static uint32_t count_crossings(const int16_t *buf, uint32_t n)
{
uint32_t i, c = 0;
for (i = 1; i < n; i++)
if ((buf[i] < 0) != (buf[i - 1] < 0))
c++;
return c;
}
static uint32_t crossings_to_hz(uint32_t crossings, uint32_t n)
{
if (n < 2u)
return 0;
return (crossings * 44100u) / (2u * n);
}
/* Track t occupies [882*t, 882*t + 442] Hz; the next band starts 441 Hz
* above the top of this one. */
static uint32_t track_lo_hz(uint32_t t) { return 882u * t; }
static uint32_t track_hi_hz(uint32_t t) { return 882u * t + 442u; }
/* Disc LBA of track `t`'s INDEX 01, read back through the same TOC path
* the CD BIOS uses (absolute MSF, 150-frame lead-in included). */
static uint32_t track_data_lba(uint32_t t)
{
uint32_t m = p_track_info(t, 0);
uint32_t s = p_track_info(t, 1);
uint32_t f = p_track_info(t, 2);
uint32_t abs = ((m * 60u) + s) * 75u + f;
return (abs >= 150u) ? (abs - 150u) : 0u;
}
/* ------------------------------------------------------------------ */
/* 1. Preflight: the disc loaded, and its geometry is what we wrote */
/* ------------------------------------------------------------------ */
/* Everything below silently no-ops if haveCDGoodness is false, so a setup
* failure would look like a behaviour failure. This test fails first and
* unambiguously instead. It also pins the pregap arithmetic the seek
* targets depend on: track 1 at LBA 0, every later track 150 sectors past
* the end of the previous one. */
TEST(preflight_disc_geometry_and_pregaps)
{
uint32_t t;
uint32_t expect = 0;
if (p_num_tracks() != NUM_TRACKS)
FAIL("disc reports %u tracks, expected %u",
p_num_tracks(), NUM_TRACKS);
for (t = 1u; t <= NUM_TRACKS; t++)
{
uint32_t lba;
if (t > 1u)
expect += PREGAP_SECTORS;
lba = track_data_lba(t);
if (lba != expect)
FAIL("track %u INDEX 01 at LBA %u, expected %u",
t, lba, expect);
expect += AUDIO_SECTORS;
}
}
/* ------------------------------------------------------------------ */
/* 2. Channel order and sample framing */
/* ------------------------------------------------------------------ */
/* The SSI head is sample-aligned from byte 0 of the seek target sector
* (unlike the BUTCH FIFO data path, which starts one word in), LRXD is
* the LEFT channel and RRXD the RIGHT, and the stream crosses sector
* boundaries linearly. Runs past 2 sectors' worth of samples so the
* refill path is covered, on a track whose left and right channels differ
* in both sign pattern and amplitude. */
TEST(ssi_delivers_left_in_lrxd_right_in_rrxd)
{
const uint32_t COUNT = SAMPLES_PER_SECT * 2u + 17u;
uint32_t track = 3u;
uint32_t n;
if (!prime_stream(track_data_lba(track)))
FAIL("could not prime the stream at track %u", track);
for (n = 0; n < COUNT; n++)
{
int16_t gotL, gotR, wantL, wantR;
ssi_next(&gotL, &gotR);
pcm_sample(track, n, &wantL, &wantR);
if (gotL != wantL || gotR != wantR)
FAIL("sample %u: got L=%d R=%d, expected L=%d R=%d "
"(swapped channels would read L=%d R=%d)",
n, gotL, gotR, wantL, wantR, wantR, wantL);
}
/* The disc must actually distinguish the channels, or the check above
* cannot fail on a swap. The amplitudes differ by construction, so
* this only guards against someone equalising them later. */
{
int16_t l, r;
pcm_sample(track, 0u, &l, &r);
if (l == r)
FAIL("disc is mono at sample 0 -- channel assertions are vacuous");
}
}
/* ------------------------------------------------------------------ */
/* 3. Track identity, measured from the audio alone */
/* ------------------------------------------------------------------ */
/* Seeking to track t's INDEX 01 delivers audio in track t's frequency
* band and in no other track's. Nothing here looks at an LBA or a disc
* byte: the decoded audio is the whole oracle, which is what makes it
* able to catch a seek that lands on the wrong track or inside a pregap.
* The right channel is checked to be the second harmonic, so a channel
* swap is caught in the frequency domain too. */
TEST(audio_frequency_identifies_the_seeked_track)
{
static int16_t bufL[4096];
static int16_t bufR[4096];
uint32_t track;
for (track = 1u; track <= NUM_TRACKS; track++)
{
uint32_t n, hzL, hzR, other;
if (!prime_stream(track_data_lba(track)))
FAIL("could not prime the stream at track %u", track);
for (n = 0; n < 4096u; n++)
ssi_next(&bufL[n], &bufR[n]);
hzL = crossings_to_hz(count_crossings(bufL, 4096u), 4096u);
hzR = crossings_to_hz(count_crossings(bufR, 4096u), 4096u);
if (hzL < track_lo_hz(track) || hzL > track_hi_hz(track))
FAIL("track %u: measured %u Hz, outside its band %u..%u Hz",
track, hzL, track_lo_hz(track), track_hi_hz(track));
for (other = 1u; other <= NUM_TRACKS; other++)
if (other != track &&
hzL >= track_lo_hz(other) && hzL <= track_hi_hz(other))
FAIL("track %u: measured %u Hz, which is also track %u's band",
track, hzL, other);
/* Right channel is the second harmonic: twice the crossings.
* The window quantises to 44100/(2*4096) = 5.4 Hz, and that error
* is doubled by the comparison, so allow 16 Hz. */
if (hzR < 2u * hzL - 16u || hzR > 2u * hzL + 16u)
FAIL("track %u: right channel %u Hz, expected ~%u Hz "
"(2x left) -- channels swapped or mis-framed",
track, hzR, 2u * hzL);
}
}
/* ------------------------------------------------------------------ */
/* 4. Continuity across a redundant seek (#306 / #307) */
/* ------------------------------------------------------------------ */
/* Re-issuing the seek the drive is already serving must not rewind the
* SSI head. Before the #307 fix a redundant $12xx reloaded the sector
* buffer and reset ssiBufPtr to 0, replaying up to a sector (~13 ms) of
* CD-DA.
*
* Two independent assertions:
* (a) audio-domain -- the chirp's instantaneous frequency never goes
* backwards across the seek, which is true of a continuous stream
* and false of any rewind, without reference to an LBA;
* (b) exact -- the samples after the seek are the samples that were
* next, not ones already delivered. */
TEST(redundant_seek_does_not_rewind_the_audio_head)
{
/* 8 windows of 8192 samples = 65536 samples, 1.49 s -- inside a
* 2 s track. The window is large because the frequency measurement
* quantises to 44100/(2*8192) = 2.7 Hz; over one window the chirp
* climbs 8192 * 441/88200 = 41 Hz, comfortably above it. */
#define RW_WINDOWS 8u
#define RW_SAMPLES 8192u
static int16_t win[RW_WINDOWS][RW_SAMPLES];
const uint32_t track = 2u;
uint32_t delivered = 0;
uint32_t w, n;
uint32_t target;
uint32_t firstHz, lastHz, rise;
target = track_data_lba(track);
if (!prime_stream(target))
FAIL("could not prime the stream at track %u", track);
for (w = 0; w < RW_WINDOWS; w++)
{
/* Halfway through, re-issue the identical seek while the stream
* is running -- the redundant-seek path. */
if (w == RW_WINDOWS / 2u)
{
dsa_seek(target);
/* The drive still answers a no-op Goto with Found; drain it so
* the DSA queue does not stay armed. */
if (wait_rx_full(MAX_DSA_TICKS) != 0xFFFFFFFFu)
(void)dsa_recv();
}
for (n = 0; n < RW_SAMPLES; n++)
{
int16_t gotL, gotR, wantL, wantR;
ssi_next(&gotL, &gotR);
pcm_sample(track, delivered, &wantL, &wantR);
/* The sharp assertion: any rewind, of any size down to a
* single sample, lands here immediately. */
if (gotL != wantL || gotR != wantR)
FAIL("window %u sample %u (stream position %u): got L=%d R=%d, "
"expected L=%d R=%d -- the head moved",
w, n, delivered, gotL, gotR, wantL, wantR);
win[w][n] = gotL;
delivered++;
}
}
/* The audio-domain assertion, independent of any LBA or disc byte:
* the chirp must still be climbing at its known rate after the
* redundant seek. This is coarser than the sample check above -- it
* cannot resolve a rewind smaller than about a window -- but it holds
* even if the sample values themselves were to change, and it is what
* a listener would hear. */
firstHz = crossings_to_hz(count_crossings(win[0], RW_SAMPLES), RW_SAMPLES);
lastHz = crossings_to_hz(count_crossings(win[RW_WINDOWS - 1u], RW_SAMPLES),
RW_SAMPLES);
if (lastHz <= firstHz)
FAIL("chirp did not advance: %u Hz at the start, %u Hz after %u "
"samples -- the audio head stalled or rewound",
firstHz, lastHz, delivered);
/* Expected climb across (RW_WINDOWS-1) windows at 441 Hz per 88200
* samples; allow a generous band around it so a legitimate change to
* the chirp constants does not have to move a magic number. */
rise = lastHz - firstHz;
{
uint32_t want = ((RW_WINDOWS - 1u) * RW_SAMPLES * 441u) / TRACK_SAMPLES;
if (rise < want / 2u || rise > want * 2u)
FAIL("chirp climbed %u Hz over %u samples, expected about %u Hz "
"-- the stream is not advancing at the drive rate",
rise, (RW_WINDOWS - 1u) * RW_SAMPLES, want);
}
#undef RW_WINDOWS
#undef RW_SAMPLES
}
/* ------------------------------------------------------------------ */
/* 5. The pregap is silence, and the tone starts exactly at INDEX 01 */
/* ------------------------------------------------------------------ */
/* Seeking two sectors before track t's data start must deliver exactly
* 2*588 zero samples -- the only silence anywhere on this disc -- and
* then phase 0 of track t. A one-sector or one-sample framing error in
* the pregap arithmetic moves that boundary and fails here. */
TEST(pregap_is_silent_and_tone_starts_at_index01)
{
const uint32_t track = 4u;
const uint32_t lead = 2u; /* sectors of pregap to read */
const uint32_t silent = lead * SAMPLES_PER_SECT;
uint32_t n;
if (!prime_stream(track_data_lba(track) - lead))
FAIL("could not prime the stream in track %u's pregap", track);
for (n = 0; n < silent; n++)
{
int16_t l, r;
ssi_next(&l, &r);
if (l != 0 || r != 0)
FAIL("pregap sample %u is L=%d R=%d, expected silence "
"(head landed inside the audio -- pregap arithmetic)",
n, l, r);
}
for (n = 0; n < 512u; n++)
{
int16_t gotL, gotR, wantL, wantR;
ssi_next(&gotL, &gotR);
pcm_sample(track, n, &wantL, &wantR);
if (gotL != wantL || gotR != wantR)
FAIL("sample %u after the pregap: got L=%d R=%d, expected "
"L=%d R=%d -- the tone did not start at INDEX 01",
n, gotL, gotR, wantL, wantR);
}
}
/* ------------------------------------------------------------------ */
/* Symbol resolution */
/* ------------------------------------------------------------------ */
static bool resolve_symbols(void)
{
p_open_image = (bool (*)(const char *)) dlsym(C.handle, "CDIntfOpenImage");
p_close_image = (void (*)(void)) dlsym(C.handle, "CDIntfCloseImage");
p_num_tracks = (uint32_t (*)(void)) dlsym(C.handle, "CDIntfGetNumTracks");
p_track_info = (uint8_t (*)(uint32_t, uint32_t))
dlsym(C.handle, "CDIntfGetTrackInfo");
p_butch_exec = (void (*)(uint32_t)) dlsym(C.handle, "BUTCHExec");
p_seek_state = (void (*)(uint32_t *, uint32_t *, uint32_t *))
dlsym(C.handle, "CDROMDiagGetSeekWedgeState");
p_ssi_xmit = (void (*)(void)) dlsym(C.handle, "SetSSIWordsXmittedFromButch");
p_lrxd = (uint16_t *) dlsym(C.handle, "lrxd");
p_rrxd = (uint16_t *) dlsym(C.handle, "rrxd");
return p_open_image && p_close_image && p_num_tracks && p_track_info &&
p_butch_exec && p_seek_state && p_ssi_xmit && p_lrxd && p_rrxd &&
C.CDROMInit && C.CDROMReset && C.CDROMReadWord && C.CDROMWriteWord;
}
int main(int argc, char *argv[])
{
char base[512];
char cue[1024];
int rc = 1;
bool opened = false;
(void)argc; (void)argv;
TEST_INIT("CD-DA delivery over the SSI (synthetic multi-track disc)");
if (!vj_core_load(&C))
{
fprintf(stderr, "FATAL: failed to load core\n");
return 1;
}
vj_core_init(&C);
if (!resolve_symbols())
{
fprintf(stderr, " FAIL missing test exports "
"(build with `make TEST_EXPORTS=1`)\n");
vj_core_unload(&C);
return 1;
}
/* Deterministic per-process scratch dir. Everything below is
* synthetic -- this test never touches test/roms/private.
*
* Deliberately mkdir()+getpid() rather than mkdtemp(): mkdtemp is not
* declared under -std=c99 on glibc without a feature-test macro, and
* building it that way fails on Linux/Clang and the ASan job with
* "call to undeclared function 'mkdtemp'". A predictable name is fine
* here -- this is a scratch dir for a synthetic disc, not a security
* boundary, and the pid already makes it per-process unique. */
snprintf(base, sizeof(base), "/tmp/vj_cd_cdda_%ld", (long)getpid());
if (mkdir(base, 0755) != 0 && errno != EEXIST)
{
fprintf(stderr, " SKIP cannot create scratch dir %s\n", base);
vj_core_unload(&C);
return 0;
}
if (!make_disc(base, cue, sizeof(cue)))
{
fprintf(stderr, " SKIP cannot write synthetic disc under %s\n", base);
goto out;
}
if (!p_open_image(cue))
{
fprintf(stderr, " FAIL synthetic disc did not load\n");
goto out;
}
opened = true;
/* CDROMInit latches haveCDGoodness from the now-open image; without it
* every path under test silently no-ops. */
C.CDROMInit();
C.CDROMReset();
fprintf(stderr, "synthetic audio disc: %u tracks, %u pregap + %u audio "
"sectors each, track t chirps %u..%u Hz\n",
NUM_TRACKS, PREGAP_SECTORS, AUDIO_SECTORS,
track_lo_hz(1u), track_hi_hz(NUM_TRACKS));
RUN_TEST(preflight_disc_geometry_and_pregaps);
RUN_TEST(ssi_delivers_left_in_lrxd_right_in_rrxd);
RUN_TEST(audio_frequency_identifies_the_seeked_track);
RUN_TEST(redundant_seek_does_not_rewind_the_audio_head);
RUN_TEST(pregap_is_silent_and_tone_starts_at_index01);
rc = TEST_REPORT();
out:
if (opened)
p_close_image();
vj_core_unload(&C);
scrub_scratch(base);
return rc;
}
+855
View File
@@ -0,0 +1,855 @@
/*
* test_cd_synth_read.c -- HLE CD_read delivery semantics, pinned against a
* SYNTHETIC disc image and a SYNTHETIC 68K boot stub.
*
* Every other CD test in this tree needs test/roms/private (gitignored,
* absent in CI and on a fresh clone), so the HLE CD read path -- the code
* that actually moves disc bytes into Jaguar RAM -- had zero committed
* regression coverage. This test builds its own disc and its own boot ROM
* at runtime, so it runs anywhere.
*
* The disc is a two-session CUE/BIN pair (same shape test_cd_pregap.c
* synthesises, which the boot-stub extractor is known to accept):
*
* track01 4 sectors, session 1, filler
* track02 session 2:
* sector 0..7 Atari universal boot header + 68K boot stub
* sector 8..39 PAYLOAD: a deterministic, position-derived byte
* pattern in 1..251 (never $00, never $FC, never
* $FF) so "delivered", "left untouched", "zeroed"
* and "overwritten by the completion pad" are four
* distinguishable outcomes.
*
* The boot stub is real 68K code:
* movea.l #dest,a0 / movea.l #end,a1 / move.l #msf,d0 / move.l #1,d1
* jsr $303C ; CD_read, through the HLE jump table
* bra.s *
*
* D1 = 1 puts CD_read on its "D1 is a counter/ID" path: no sentinel scan,
* raw stream from the requested LBA at offset 0. That makes the source
* position exactly determined by D0, which is what lets every byte be
* predicted. ($3072 bit 7 is left at the $FF the HLE boot writes, i.e.
* match-ISR mode, so the non-match raw-alignment scan is not involved.)
*
* Invariants pinned (each has a documented negative control -- see the
* per-test comments):
*
* 1. LBA addressing -- a read at LBA L returns the bytes
* written at L in the image.
* 2. Long-rounded tail -- total = (count+3)&~3 while the game
* asked for `count`; the tail of the last
* longword must hold the NEXT REAL DISC
* BYTES, not zeros and not the $FF pad.
* (Iron Soldier 2 checksums through it.)
* 3. Streamed, not instant -- CD_read itself copies nothing; bytes
* arrive over many ticks; the far end of
* the buffer is still untouched after one
* tick. (Instant delivery stomped
* in-flight poll code: Hover Strike.)
* 4. Arm over in-flight -- the previous transfer's tail is dropped
* and its completion side effects (pad +
* ATRI block) never fire.
* 5. End-to-end through 68K -- the same read driven by the synthetic
* boot stub via retro_run, not by poking
* the hook directly.
*
* Build: make test/test_cd_synth_read (needs TEST_EXPORTS=1)
* Run: DYLD_LIBRARY_PATH=. test/test_cd_synth_read
*/
#include "cd_assertions.h"
#include "../libretro-common/include/libretro.h"
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>
static struct vj_core C;
/* ------------------------------------------------------------------ */
/* Core internals resolved by dlsym */
/* ------------------------------------------------------------------ */
static bool (*p_hle_hook)(uint32_t);
static void (*p_hle_tick)(void);
static bool (*p_hle_active)(void);
static uint32_t (*p_hle_dest)(void);
static uint32_t (*p_hle_bytes)(void);
static uint32_t (*p_hle_arms)(void);
static bool (*p_cd_read_block)(uint32_t, uint8_t *);
static uint32_t (*p_cd_s2_first)(void);
static uint32_t (*p_cd_total)(void);
static bool (*p_load_game)(const struct retro_game_info *);
static void (*p_unload_game)(void);
static void (*p_run)(void);
static uint8_t *ram;
/* ------------------------------------------------------------------ */
/* Synthetic disc geometry */
/* ------------------------------------------------------------------ */
#define SECTOR_BYTES 2352u
#define S1_SECTORS 4u
#define PAYLOAD_SECTOR 8u /* payload starts this many sectors into track 02 */
#define PAYLOAD_SECTORS 32u
#define T2_SECTORS (PAYLOAD_SECTOR + PAYLOAD_SECTORS)
#define PAYLOAD_BASE_OFF (PAYLOAD_SECTOR * SECTOR_BYTES)
/* Atari universal boot header (offsets as used by CDIntfExtractBootStub). */
#define HDR_MAGIC_OFF 0x42u
#define HDR_LOAD_OFF 0x62u
#define HDR_LEN_OFF 0x66u
#define HDR_PAYLOAD 0x6Au
#define STUB_LOAD_ADDR 0x004000u
#define STUB_LENGTH 0x100u
static const char BOOT_MAGIC[32] = "ATARI APPROVED DATA HEADER ATRI ";
/* Destination buffers in main RAM. Clear of the 68K vector table, the
* $400 RTE stub, the TOC at $2C00, the jump table at $3000, the injected
* boot stub at $4000, the CD-ready flag at $3727C and SP at $200000. */
#define DEST_A 0x100000u
#define DEST_B 0x140000u
/* Filler written into the destination before every read. Outside the
* payload pattern's 1..251 range, and distinct from both $00 (a zeroing
* bug) and $FF (the completion pad). */
#define FILLER 0xFCu
/* The stub's own read, for the end-to-end test: deliberately not a
* multiple of 4 so it doubles as a tail check. */
#define STUB_READ_BYTES 0x1005u
/* ------------------------------------------------------------------ */
/* Deterministic payload pattern */
/* ------------------------------------------------------------------ */
/* Raw byte stored at track-02 file offset `off`. Always in 1..251. */
static uint8_t disc_pat(uint32_t off)
{
return (uint8_t)(1u + ((off * 37u + (off >> 8) * 11u) % 251u));
}
/* Byte the streamer must land at dest + i for a read that starts at the
* payload LBA, offset 0.
*
* The streamer un-does the I2S word swap: within each 2352-byte sector it
* exchanges byte pairs (0,1), (2,3), ... PAYLOAD_BASE_OFF and SECTOR_BYTES
* are both even, so sector-local parity is global parity and the swap is
* just `^1` on the file offset. */
static uint8_t expect_ram(uint32_t i)
{
return disc_pat(PAYLOAD_BASE_OFF + (i ^ 1u));
}
/* ------------------------------------------------------------------ */
/* Byte helpers */
/* ------------------------------------------------------------------ */
static void put16(uint8_t *p, uint16_t v)
{
p[0] = (uint8_t)(v >> 8);
p[1] = (uint8_t)v;
}
static void put32(uint8_t *p, uint32_t v)
{
p[0] = (uint8_t)(v >> 24);
p[1] = (uint8_t)(v >> 16);
p[2] = (uint8_t)(v >> 8);
p[3] = (uint8_t)v;
}
/* LBA -> the packed MSF CD_read expects in D0: (min<<16)|(sec<<8)|frm,
* with the 150-sector lead-in added back (CD_read subtracts it). */
static uint32_t lba_to_msf(uint32_t lba)
{
uint32_t tf = lba + 150u;
uint32_t min = tf / 4500u;
uint32_t sec = (tf / 75u) % 60u;
uint32_t frm = tf % 75u;
return (min << 16) | (sec << 8) | frm;
}
/* ------------------------------------------------------------------ */
/* Synthetic ROM: the 68K boot stub */
/* ------------------------------------------------------------------ */
/* Emits, at `out` (STUB_LENGTH bytes, logical/unswapped order):
* 207C dddddddd movea.l #dest,a0
* 227C eeeeeeee movea.l #dest+size,a1
* 203C mmmmmmmm move.l #msf,d0
* 223C 00000001 move.l #1,d1
* 4EB9 0000303C jsr $0000303C ; CD_read
* 60FE bra.s * ; park
* with the remainder filled with `bra.s *` so any stray entry parks too. */
static void build_stub(uint8_t *out, uint32_t msf, uint32_t dest, uint32_t size)
{
uint32_t o = 0;
put16(out + o, 0x207C); put32(out + o + 2, dest); o += 6;
put16(out + o, 0x227C); put32(out + o + 2, dest + size); o += 6;
put16(out + o, 0x203C); put32(out + o + 2, msf); o += 6;
put16(out + o, 0x223C); put32(out + o + 2, 0x00000001u); o += 6;
put16(out + o, 0x4EB9); put32(out + o + 2, 0x0000303Cu); o += 6;
while (o + 1u < STUB_LENGTH)
{
put16(out + o, 0x60FE);
o += 2;
}
}
/* ------------------------------------------------------------------ */
/* Synthetic disc construction */
/* ------------------------------------------------------------------ */
static bool write_file(const char *path, const uint8_t *data, size_t len)
{
FILE *f = fopen(path, "wb");
size_t n;
if (!f)
return false;
n = len ? fwrite(data, 1, len, f) : 0;
fclose(f);
return n == len;
}
/* Writes <dir>/disc.cue + track01.bin + track02.bin.
*
* `stubMsf` is baked into the boot stub's `move.l #msf,d0`. The payload
* LBA is only known after the image loader has told us where session 2
* starts, so the disc is built twice: once with a placeholder, then again
* with the real value. Track sizes never change, so the geometry the
* loader reports is identical for both builds. */
static bool make_disc(const char *dir, uint32_t stubMsf,
char *cueOut, size_t cueOutLen)
{
char path[1024];
uint8_t *t1 = NULL;
uint8_t *t2 = NULL;
uint8_t *logical = NULL;
size_t t1Len = (size_t)S1_SECTORS * SECTOR_BYTES;
size_t t2Len = (size_t)T2_SECTORS * SECTOR_BYTES;
size_t i;
FILE *cue;
bool ok = false;
t1 = (uint8_t *)calloc(1, t1Len);
t2 = (uint8_t *)calloc(1, t2Len);
logical = (uint8_t *)calloc(1, PAYLOAD_BASE_OFF);
if (!t1 || !t2 || !logical)
goto done;
/* Header + stub, built in logical order then I2S-swapped onto the disc. */
memcpy(logical + HDR_MAGIC_OFF, BOOT_MAGIC, sizeof(BOOT_MAGIC));
put32(logical + HDR_LOAD_OFF, STUB_LOAD_ADDR);
put32(logical + HDR_LEN_OFF, STUB_LENGTH);
build_stub(logical + HDR_PAYLOAD, stubMsf, DEST_A, STUB_READ_BYTES);
for (i = 0; i + 1 < PAYLOAD_BASE_OFF; i += 2)
{
t2[i] = logical[i + 1];
t2[i + 1] = logical[i];
}
/* Payload region: raw disc bytes, straight from the pattern. */
for (i = PAYLOAD_BASE_OFF; i < t2Len; i++)
t2[i] = disc_pat((uint32_t)i);
snprintf(path, sizeof(path), "%s/track01.bin", dir);
if (!write_file(path, t1, t1Len))
goto done;
snprintf(path, sizeof(path), "%s/track02.bin", dir);
if (!write_file(path, t2, t2Len))
goto done;
snprintf(cueOut, cueOutLen, "%s/disc.cue", dir);
cue = fopen(cueOut, "wb");
if (!cue)
goto done;
fprintf(cue,
"REM SESSION 01\n"
"FILE \"track01.bin\" BINARY\n"
" TRACK 01 AUDIO\n"
" INDEX 01 00:00:00\n"
"REM SESSION 02\n"
"FILE \"track02.bin\" BINARY\n"
" TRACK 02 AUDIO\n"
" INDEX 01 00:00:00\n");
fclose(cue);
ok = true;
done:
free(t1);
free(t2);
free(logical);
return ok;
}
/* ------------------------------------------------------------------ */
/* libretro plumbing */
/* ------------------------------------------------------------------ */
static bool env_cb(unsigned cmd, void *data)
{
if (cmd == RETRO_ENVIRONMENT_GET_VARIABLE)
{
struct retro_variable *var = (struct retro_variable *)data;
if (strcmp(var->key, "virtualjaguar_cd_boot_mode") == 0)
{
var->value = "hle";
return true;
}
/* Everything else defaults -- notably virtualjaguar_cd_read_speed,
* whose default is CDSPEED_2X (paced). CDSPEED_INSTANT would make
* the streaming test vacuous. */
return false;
}
if (cmd == RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY)
{
*(const char **)data = ".";
return true;
}
if (cmd == RETRO_ENVIRONMENT_SET_PIXEL_FORMAT)
return true;
return false;
}
static void vcb(const void *d, unsigned w, unsigned h, size_t p)
{ (void)d; (void)w; (void)h; (void)p; }
static void acb(int16_t l, int16_t r) { (void)l; (void)r; }
static size_t abcb(const int16_t *d, size_t f) { (void)d; return f; }
static void ipcb(void) {}
static int16_t iscb(unsigned p, unsigned d, unsigned i, unsigned id)
{ (void)p; (void)d; (void)i; (void)id; return 0; }
/* Remove the scratch disc. Called on every exit path -- an aborted run
* must not leave ~94 KB of synthetic BIN behind. */
static void scrub_scratch(const char *base)
{
char path[1024];
snprintf(path, sizeof(path), "%s/track01.bin", base); remove(path);
snprintf(path, sizeof(path), "%s/track02.bin", base); remove(path);
snprintf(path, sizeof(path), "%s/disc.cue", base); remove(path);
rmdir(base);
}
static bool load_disc(const char *cue)
{
struct retro_game_info info;
memset(&info, 0, sizeof(info));
info.path = cue;
return p_load_game(&info);
}
/* ------------------------------------------------------------------ */
/* Test helpers */
/* ------------------------------------------------------------------ */
static uint32_t payloadLBA; /* resolved after the first load */
static uint32_t s2FirstLBA;
static uint32_t discSectors;
/* Arm a CD_read exactly as the 68K entry does: registers, then the HLE
* jump-table hook at $303C. */
static void arm_read(uint32_t lba, uint32_t dest, uint32_t bytes)
{
C.m68k_set_reg(M68K_REG_D0, lba_to_msf(lba));
C.m68k_set_reg(M68K_REG_D1, 0x00000001u);
C.m68k_set_reg(M68K_REG_A0, dest);
C.m68k_set_reg(M68K_REG_A1, dest + bytes);
p_hle_hook(0x0000303Cu);
}
static void fill_dest(uint32_t dest, uint32_t len)
{
memset(ram + dest, FILLER, len);
}
static uint32_t drive_stream(uint32_t maxTicks)
{
uint32_t n = 0;
while (p_hle_active() && n < maxTicks)
{
p_hle_tick();
n++;
}
return n;
}
/* Index of the first byte in [0,len) that differs from expect_ram(), or
* len when the whole range matches. */
static uint32_t first_bad(uint32_t dest, uint32_t len)
{
uint32_t i;
for (i = 0; i < len; i++)
if (ram[dest + i] != expect_ram(i))
return i;
return len;
}
/* HLEStreamFinish's end-of-transfer marker: 8 bytes of $FF immediately
* overwritten by 16 "ATRI" longwords, both starting at dest + total. Its
* position is the observable that says where the transfer was considered to
* end -- at the long-rounded end, never at the requested end. */
static bool is_atri(uint32_t addr)
{
return ram[addr + 0] == 0x41u && ram[addr + 1] == 0x54u &&
ram[addr + 2] == 0x52u && ram[addr + 3] == 0x49u;
}
static bool range_is(uint32_t addr, uint32_t len, uint8_t v)
{
uint32_t i;
for (i = 0; i < len; i++)
if (ram[addr + i] != v)
return false;
return true;
}
/* ------------------------------------------------------------------ */
/* 1. LBA addressing / image model */
/* ------------------------------------------------------------------ */
/* Pins that a given absolute LBA hands back exactly the bytes written at
* that position in the synthetic image, and establishes the preconditions
* every later test relies on (payload inside the session-2 range, so
* CD_read's out-of-range redirect never fires, and inside the disc, so
* CDIntfReadBlock never fails into a zero-filled buffer).
*
* Negative control: `hleStream.lba = scanLBA + 1` in HLEHandleCDRead makes
* test 2 fail at offset 0 -- this test localises whether such a failure is
* the streamer or the image loader. */
TEST(synth_disc_lba_addressing)
{
static uint8_t buf[SECTOR_BYTES];
uint32_t k;
uint32_t s;
ASSERT_TRUE(s2FirstLBA > 0);
ASSERT_TRUE(discSectors > 0);
/* Payload must sit strictly inside [s2first, discTotal): outside it,
* CD_read redirects to the session-2 game-data LBA and reads something
* else entirely. */
ASSERT_TRUE(payloadLBA >= s2FirstLBA);
ASSERT_TRUE(payloadLBA + PAYLOAD_SECTORS <= discSectors);
for (s = 0; s < 4; s++)
{
ASSERT_TRUE(p_cd_read_block(payloadLBA + s, buf));
for (k = 0; k < SECTOR_BYTES; k++)
{
if (buf[k] != disc_pat(PAYLOAD_BASE_OFF + s * SECTOR_BYTES + k))
FAIL("LBA %u byte %u: got $%02X, expected $%02X",
payloadLBA + s, k, buf[k],
disc_pat(PAYLOAD_BASE_OFF + s * SECTOR_BYTES + k));
}
}
}
/* ------------------------------------------------------------------ */
/* 2. Long-rounded tail delivery */
/* ------------------------------------------------------------------ */
/* hleStream.total = (count+3)&~3 while reqTotal = count. The bytes in
* [count, total) are NOT padding: the real GPU CD ISR stores whole
* longwords, so those positions hold the next real bytes off the disc.
* Iron Soldier 2's boot stub checksums its $14FE-byte load in ADD.L steps
* and sums 2 bytes past its own end address; delivering exact-size (and
* therefore putting the $FF pad at the odd end) made every validation fail
* and the stub re-issue the read forever.
*
* Negative control (verified): change
* hleStream.total = (byteCount + 3u) & ~3u;
* to
* hleStream.total = byteCount;
* -- the tail then reads $FF (the completion pad now starts at reqTotal)
* and this test goes red on every unaligned size. */
TEST(long_rounded_tail_holds_real_disc_bytes)
{
static const uint32_t sizes[7] = { 1u, 2u, 3u, 5u, 7u, 4093u, 4096u };
uint32_t n;
for (n = 0; n < 7; n++)
{
uint32_t size = sizes[n];
uint32_t total = (size + 3u) & ~3u;
uint32_t bad;
uint32_t i;
/* Filler across the request, its long-rounded tail, and the pad +
* ATRI region beyond, so nothing is inherited from the last pass. */
fill_dest(DEST_A, total + 128u);
arm_read(payloadLBA, DEST_A, size);
ASSERT_TRUE(p_hle_active());
drive_stream(200000u);
if (p_hle_active())
FAIL("size %u: stream never completed", size);
bad = first_bad(DEST_A, total);
if (bad != total)
FAIL("size %u: byte %u of %u: got $%02X, expected $%02X",
size, bad, total, ram[DEST_A + bad], expect_ram(bad));
/* Restate the tail explicitly -- this is the load-bearing part and
* it must not be silently covered by the loop above. */
for (i = size; i < total; i++)
{
if (ram[DEST_A + i] == 0xFF)
FAIL("size %u: tail byte %u is the $FF completion pad, "
"not disc data", size, i);
if (ram[DEST_A + i] == 0x00)
FAIL("size %u: tail byte %u is zero, not disc data", size, i);
if (ram[DEST_A + i] == FILLER)
FAIL("size %u: tail byte %u was never written", size, i);
ASSERT_EQ_U8(ram[DEST_A + i], expect_ram(i));
}
/* The wire count itself is long-rounded. */
ASSERT_EQ_U32(p_hle_bytes(), total);
/* And the end-of-transfer marker starts exactly AT the long-rounded
* end, so it can never eat a delivered byte. (Delivering exact-size
* put it at the odd end address and clobbered the checksummed tail.) */
if (!is_atri(DEST_A + total))
FAIL("size %u: no end marker at the long-rounded end $%06X "
"(got $%02X%02X%02X%02X)", size, DEST_A + total,
ram[DEST_A + total], ram[DEST_A + total + 1],
ram[DEST_A + total + 2], ram[DEST_A + total + 3]);
}
}
/* ------------------------------------------------------------------ */
/* 3. Streamed, not instant */
/* ------------------------------------------------------------------ */
/* Games issue overlay loads whose destination covers the very code that
* polls for completion (Hover Strike's LVL loads span $05D340-$1F0000 with
* its poll loop at $1B4xxx). On hardware the drive streams at 352,800 B/s,
* giving the game time to jump away. An instant CD_read stomps the running
* poll loop and the 68K double-faults.
*
* Deliberately no predicted per-tick byte count (that depends on the
* halfline period and the NTSC/PAL flag): the assertions are two-sided and
* model-free -- the front of the buffer has moved, the far end has not.
*
* Negative control (verified): force `hleStream.speedMult = CDSPEED_INSTANT`
* at arm time -- the whole transfer lands in the first tick, the far byte is
* no longer filler, and this test goes red. */
TEST(delivery_is_streamed_not_instant)
{
uint32_t size = 0x8000u;
uint32_t ticks;
uint32_t bad;
fill_dest(DEST_A, size + 128u);
arm_read(payloadLBA, DEST_A, size);
/* CD_read itself must copy nothing -- it only arms. */
ASSERT_TRUE(p_hle_active());
ASSERT_EQ_U32(p_hle_dest(), DEST_A);
if (!range_is(DEST_A, size, FILLER))
FAIL("CD_read wrote to the destination before any tick");
/* One tick: the front has advanced, the far end has not. */
p_hle_tick();
ASSERT_EQ_U8(ram[DEST_A], expect_ram(0));
ASSERT_EQ_U8(ram[DEST_A + size - 1u], FILLER);
ASSERT_TRUE(p_hle_active());
/* And it takes many more ticks to finish -- a single-tick transfer is
* exactly the instant delivery this pins against. */
ticks = 1u + drive_stream(200000u);
if (p_hle_active())
FAIL("stream never completed (%u ticks)", ticks);
if (ticks < 100u)
FAIL("%u-byte transfer completed in %u ticks -- not drive-paced",
size, ticks);
bad = first_bad(DEST_A, size);
if (bad != size)
FAIL("byte %u: got $%02X, expected $%02X",
bad, ram[DEST_A + bad], expect_ram(bad));
}
/* ------------------------------------------------------------------ */
/* 4. Arming over an in-flight stream */
/* ------------------------------------------------------------------ */
/* A CD_read armed while another is still streaming abandons the first one:
* its undelivered tail never arrives and -- the part that matters for
* anything downstream -- HLEStreamFinish never runs for it, so neither the
* $FF pad nor the ATRI sync block that overwrites it appears at its end. A regression that quietly
* "finished" the abandoned transfer would publish a completion the game
* never earned.
*
* The second read differs in A0/A1, so it does not take CD_read's
* identical-re-issue path (which deliberately keeps the in-flight transfer).
*
* Negative control (verified): call HLEStreamFinish() just before re-arming
* in HLEHandleCDRead -- the pad + ATRI block appear at dest_A + total_A and
* this test goes red. */
TEST(arming_over_inflight_drops_tail_and_completion)
{
uint32_t sizeA = 0x8000u;
uint32_t sizeB = 0x400u;
uint32_t totalA = (sizeA + 3u) & ~3u;
uint32_t totalB = (sizeB + 3u) & ~3u;
uint32_t arms0 = p_hle_arms();
uint32_t i;
uint32_t bad;
uint32_t delivered = 0;
fill_dest(DEST_A, sizeA + 128u);
fill_dest(DEST_B, sizeB + 128u);
arm_read(payloadLBA, DEST_A, sizeA);
for (i = 0; i < 5u; i++)
p_hle_tick();
ASSERT_TRUE(p_hle_active());
/* Partially delivered: some bytes in, most still filler. */
for (i = 0; i < totalA; i++)
{
if (ram[DEST_A + i] == FILLER)
break;
delivered++;
}
if (delivered == 0)
FAIL("stream A delivered nothing in 5 ticks");
if (delivered >= totalA)
FAIL("stream A already complete after 5 ticks -- cannot test abandon");
/* Arm B over it. */
arm_read(payloadLBA, DEST_B, sizeB);
ASSERT_EQ_U32(p_hle_arms() - arms0, 2u);
ASSERT_EQ_U32(p_hle_dest(), DEST_B);
ASSERT_EQ_U32(p_hle_bytes(), totalB);
/* A's tail was dropped: the last byte it would have delivered is still
* filler, and no completion side effects ran at its end. */
ASSERT_EQ_U8(ram[DEST_A + totalA - 1u], FILLER);
if (!range_is(DEST_A + totalA, 8u, FILLER))
FAIL("abandoned transfer A published completion side effects "
"($FF pad / ATRI block) at $%06X", DEST_A + totalA);
/* B completes normally and is byte-exact. */
drive_stream(200000u);
if (p_hle_active())
FAIL("stream B never completed");
bad = first_bad(DEST_B, totalB);
if (bad != totalB)
FAIL("B byte %u: got $%02X, expected $%02X",
bad, ram[DEST_B + bad], expect_ram(bad));
if (!is_atri(DEST_B + totalB))
FAIL("stream B did not publish its end marker at $%06X",
DEST_B + totalB);
/* A's abandoned region stayed abandoned while B ran. */
ASSERT_EQ_U8(ram[DEST_A + totalA - 1u], FILLER);
}
/* ------------------------------------------------------------------ */
/* 5. End-to-end through the synthetic ROM */
/* ------------------------------------------------------------------ */
/* Everything above drives JaguarCDHLEHook() directly. This one proves the
* whole chain is wired: the boot-stub extractor pulls our 68K program off
* the synthetic disc, M68KInstructionHook dispatches its `jsr $303C` into
* the HLE CD_read, and HalflineCallback's per-halfline StreamTick delivers
* the bytes across retro_run() frames.
*
* The stub's request is $1005 bytes -- not a multiple of 4 -- so the tail
* invariant is re-checked end-to-end.
*
* Negative control (verified): the same `hleStream.total = byteCount`
* sabotage as test 2 turns this red too. */
TEST(stub_driven_read_end_to_end)
{
uint32_t total = (STUB_READ_BYTES + 3u) & ~3u;
uint32_t arms0;
uint32_t f;
uint32_t bad;
uint32_t i;
/* Boot stub injected and entered at its load address. */
ASSERT_EQ_U32(C.m68k_get_reg(NULL, M68K_REG_PC), STUB_LOAD_ADDR);
/* Our program, not somebody's zero fill: movea.l #DEST_A,a0. */
ASSERT_EQ_U8(ram[STUB_LOAD_ADDR + 0], 0x20);
ASSERT_EQ_U8(ram[STUB_LOAD_ADDR + 1], 0x7C);
arms0 = p_hle_arms();
fill_dest(DEST_A, total + 128u);
for (f = 0; f < 30u; f++)
p_run();
if (p_hle_arms() - arms0 != 1u)
FAIL("stub issued %u CD_reads in 30 frames, expected 1",
p_hle_arms() - arms0);
if (p_hle_active())
FAIL("stub's read never completed in 30 frames");
bad = first_bad(DEST_A, total);
if (bad != total)
FAIL("byte %u of %u: got $%02X, expected $%02X",
bad, total, ram[DEST_A + bad], expect_ram(bad));
for (i = STUB_READ_BYTES; i < total; i++)
{
if (ram[DEST_A + i] == 0xFF || ram[DEST_A + i] == FILLER)
FAIL("tail byte %u not delivered from disc (got $%02X)",
i, ram[DEST_A + i]);
ASSERT_EQ_U8(ram[DEST_A + i], expect_ram(i));
}
}
/* ------------------------------------------------------------------ */
static bool resolve_symbols(void)
{
p_hle_hook = (bool (*)(uint32_t))dlsym(C.handle, "JaguarCDHLEHook");
p_hle_tick = (void (*)(void))dlsym(C.handle, "JaguarCDHLEStreamTick");
p_hle_active = (bool (*)(void))dlsym(C.handle, "JaguarCDHLEStreamActive");
p_hle_dest = (uint32_t (*)(void))dlsym(C.handle, "JaguarCDHLEStreamDest");
p_hle_bytes = (uint32_t (*)(void))dlsym(C.handle, "JaguarCDHLEStreamBytes");
p_hle_arms = (uint32_t (*)(void))dlsym(C.handle, "JaguarCDHLEStreamArmCount");
p_cd_read_block = (bool (*)(uint32_t, uint8_t *))dlsym(C.handle, "CDIntfReadBlock");
p_cd_s2_first = (uint32_t (*)(void))dlsym(C.handle, "CDIntfGetSession2FirstTrackLBA");
p_cd_total = (uint32_t (*)(void))dlsym(C.handle, "CDIntfGetDiscTotalSectors");
p_load_game = (bool (*)(const struct retro_game_info *))dlsym(C.handle, "retro_load_game");
p_unload_game = (void (*)(void))dlsym(C.handle, "retro_unload_game");
p_run = (void (*)(void))dlsym(C.handle, "retro_run");
return p_hle_hook && p_hle_tick && p_hle_active && p_hle_dest &&
p_hle_bytes && p_hle_arms && p_cd_read_block && p_cd_s2_first &&
p_cd_total && p_load_game && p_unload_game && p_run &&
C.m68k_set_reg && C.m68k_get_reg && C.GetRamPtr;
}
int main(int argc, char *argv[])
{
char base[512];
char cue[1024];
int rc;
(void)argc; (void)argv;
TEST_INIT("CD HLE read semantics (synthetic disc + ROM)");
if (!vj_core_load(&C))
{
fprintf(stderr, "FATAL: failed to load core\n");
return 1;
}
C.retro_set_environment(env_cb);
C.retro_set_video_refresh(vcb);
C.retro_set_audio_sample(acb);
C.retro_set_audio_sample_batch(abcb);
C.retro_set_input_poll(ipcb);
C.retro_set_input_state(iscb);
C.retro_init();
if (!resolve_symbols())
{
fprintf(stderr, " FAIL missing test exports "
"(build with `make TEST_EXPORTS=1`)\n");
dlclose(C.handle);
return 1;
}
ram = C.GetRamPtr();
if (!ram)
{
fprintf(stderr, " FAIL GetRamPtr() returned NULL\n");
dlclose(C.handle);
return 1;
}
/* Deterministic per-process scratch dir (mkdtemp sits behind different
* feature-test macros on Darwin vs glibc, and per-process is unique
* enough). Everything below is synthetic -- no test/roms/private. */
snprintf(base, sizeof(base), "/tmp/vj_cd_synth_%ld", (long)getpid());
if (mkdir(base, 0755) != 0 && errno != EEXIST)
{
fprintf(stderr, " SKIP cannot create scratch dir %s\n", base);
C.retro_deinit();
dlclose(C.handle);
return 0;
}
/* Pass 1: placeholder MSF, just to learn the image geometry. */
if (!make_disc(base, 0, cue, sizeof(cue)))
{
fprintf(stderr, " SKIP cannot write synthetic disc under %s\n", base);
scrub_scratch(base);
C.retro_deinit();
dlclose(C.handle);
return 0;
}
if (!load_disc(cue))
{
fprintf(stderr, " FAIL synthetic disc did not load\n");
scrub_scratch(base);
C.retro_deinit();
dlclose(C.handle);
return 1;
}
s2FirstLBA = p_cd_s2_first();
discSectors = p_cd_total();
payloadLBA = s2FirstLBA + PAYLOAD_SECTOR;
p_unload_game();
/* Pass 2: same geometry, real MSF baked into the stub. */
if (!make_disc(base, lba_to_msf(payloadLBA), cue, sizeof(cue)))
{
fprintf(stderr, " FAIL cannot rewrite synthetic disc\n");
scrub_scratch(base);
C.retro_deinit();
dlclose(C.handle);
return 1;
}
if (!load_disc(cue))
{
fprintf(stderr, " FAIL synthetic disc did not reload\n");
scrub_scratch(base);
C.retro_deinit();
dlclose(C.handle);
return 1;
}
ram = C.GetRamPtr(); /* re-latch: a reload may re-seat main RAM */
fprintf(stderr, "synthetic disc: session2 LBA %u, %u sectors, "
"payload LBA %u\n", s2FirstLBA, discSectors, payloadLBA);
RUN_TEST(synth_disc_lba_addressing);
RUN_TEST(long_rounded_tail_holds_real_disc_bytes);
RUN_TEST(delivery_is_streamed_not_instant);
RUN_TEST(arming_over_inflight_drops_tail_and_completion);
/* Fresh boot for the end-to-end run so the 68K starts at the stub. */
p_unload_game();
if (!load_disc(cue))
{
fprintf(stderr, " FAIL synthetic disc did not reload for e2e\n");
rc = 1;
goto out;
}
ram = C.GetRamPtr();
RUN_TEST(stub_driven_read_end_to_end);
rc = TEST_REPORT();
out:
p_unload_game();
C.retro_deinit();
dlclose(C.handle);
scrub_scratch(base);
return rc;
}
+715
View File
@@ -0,0 +1,715 @@
/*
* test_cd_synth_subq.c -- BUTCH Q-subcode serial window (SUBDATA), pinned
* against a SYNTHETIC audio disc. Issue #291 (VLM stays muted).
*
* THE CONSUMER IS THE ORACLE
* --------------------------
* The contract asserted here is not an invention of this emulator: it was
* recovered at runtime from the retail CD BIOS VLM's own DSP subcode
* handler ($F1BD42..$F1BE02, docs/vlm-audio-cd-plan.md section 8.8). That
* handler polls the low word of SUBDATA ($DFFF1A) and requires
*
* bits 15..8 the current Q-channel byte
* bit 4 "valid"
* bits 3..0 byte sequence number 0..11 within the Q frame
*
* assembles the 12 bytes in sequence order, CRCs ALL 12 with
* CRC-16/CCITT (poly $1021, init 0) and requires the residual $1D0F --
* the standard residue of a Red Book Q frame whose stored CRC is
* inverted. Bit 30 of the first Q word (CONTROL bit 2, "data track") is
* the VLM's mute gate: $40000000 at VLM init means "assume data, stay
* muted", and only a CRC-valid audio-control frame clears it. Every
* assertion below is that contract, re-implemented independently.
*
* INVARIANTS PINNED (each with a documented negative control)
* ----------------------------------------------------------
* 1. Disarmed silence -- without an SBCNTRL arm write, SUBDATA reads
* return the RAM-backed $0000 they always did (pre-#291 behavior;
* guards every data-disc title, none of which arms subcode).
* NEGATIVE CONTROL: drop `subQArmed` from the read gate in
* CDROMReadWord() (src/cd/cdrom.c) -> test 1 fails.
* 2. Word format and pacing -- armed and playing, every read shows
* bit 4 set, a sequence number that steps 0..11 cyclically, and
* exactly 49 streamed samples per byte (588 samples per sector /
* 12 bytes, so Q stays locked to the audio).
* NEGATIVE CONTROL: serve `subQSeq` without the $10 valid bit ->
* tests 2 and 3 fail.
* 3. Frame content -- an aligned 12-byte frame is a mode-1 (ADR 1)
* position frame, all BCD: audio CONTROL nibble (bit "data track"
* CLEAR -- the unmute bit), the seeked track's number, INDEX 01,
* zero relative time at the track start, absolute time = LBA+150,
* and a CRC that yields the $1D0F residual the DSP demands.
* NEGATIVE CONTROL: store the CRC non-inverted in CDROMBuildSubQ()
* -> test 3 fails (residual becomes $0000, not $1D0F).
* 4. Pregap -- two sectors before a track's INDEX 01 the frame says
* INDEX 00, relative time counting DOWN, control still audio.
* 5. Q advances with playback -- after streaming N sectors' worth of
* samples, the absolute position has advanced by N.
* NEGATIVE CONTROL: never rebuild the frame on sequence wrap ->
* test 5 fails (position frozen).
* 6. Stop mutes the window -- after $0200 Stop, reads fall back to
* $0000 (subcode exists only while the disc streams).
*
* Build: make test/test_cd_synth_subq (needs TEST_EXPORTS=1)
* Run: DYLD_LIBRARY_PATH=. test/test_cd_synth_subq
*/
#include "test_framework.h"
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>
static struct vj_core C;
/* ------------------------------------------------------------------ */
/* Core internals resolved by dlsym */
/* ------------------------------------------------------------------ */
static bool (*p_open_image)(const char *);
static void (*p_close_image)(void);
static uint32_t (*p_num_tracks)(void);
static uint8_t (*p_track_info)(uint32_t, uint32_t);
static void (*p_butch_exec)(uint32_t);
static void (*p_seek_state)(uint32_t *, uint32_t *, uint32_t *);
static void (*p_ssi_xmit)(void);
/* ------------------------------------------------------------------ */
/* BUTCH register offsets (CDROMReadWord/CDROMWriteWord mask to & $FF) */
/* ------------------------------------------------------------------ */
#define R_BUTCH_LO 0x02u
#define R_DS_DATA 0x0Au
#define R_I2CNTRL_LO 0x12u
#define R_SBCNTRL_LO 0x16u /* SBCNTRL ($14) low word */
#define R_SUBDATA_LO 0x1Au /* SUBDATA ($18) low word ($DFFF1A) */
#define ST_RX_FULL 0x2000u
#define I2S_DATA_ENABLE 0x0001u
#define SUBQ_VALID 0x0010u
#define SUBQ_SEQ_MASK 0x000Fu
#define SUBQ_BYTES 12u
#define SAMPLES_PER_BYTE 49u /* 588 samples per sector / 12 */
#define CALLER 0u
/* ------------------------------------------------------------------ */
/* Synthetic disc geometry (same shape as test_cd_synth_cdda.c) */
/* ------------------------------------------------------------------ */
#define SECTOR_BYTES 2352u
#define SAMPLES_PER_SECT (SECTOR_BYTES / 4u)
#define NUM_TRACKS 3u
#define AUDIO_SECTORS 150u /* 2 s per track */
#define PREGAP_SECTORS 150u
#define MAX_SEEK_TICKS 200000u
#define MAX_DSA_TICKS 4096u
/* ------------------------------------------------------------------ */
/* Reference Q math -- independent re-implementation of the contract */
/* ------------------------------------------------------------------ */
static uint16_t ref_crc16(const uint8_t *d, uint32_t len)
{
uint16_t crc = 0;
uint32_t i, b;
for (i = 0; i < len; i++)
{
crc ^= (uint16_t)((uint16_t)d[i] << 8);
for (b = 0; b < 8; b++)
crc = (crc & 0x8000u) ? (uint16_t)((crc << 1) ^ 0x1021u)
: (uint16_t)(crc << 1);
}
return crc;
}
static uint8_t ref_bcd(uint32_t v)
{
return (uint8_t)(((v / 10u) << 4) | (v % 10u));
}
static uint32_t from_bcd(uint8_t b)
{
return ((uint32_t)(b >> 4)) * 10u + (uint32_t)(b & 0x0Fu);
}
/* Absolute frame count encoded in Q bytes 7..9 (AMIN/ASEC/AFRAME). */
static uint32_t q_abs_frames(const uint8_t *q)
{
return (from_bcd(q[7]) * 60u + from_bcd(q[8])) * 75u + from_bcd(q[9]);
}
/* ------------------------------------------------------------------ */
/* Disc construction: constant non-zero square tone per track */
/* ------------------------------------------------------------------ */
static void track_bin_name(char *out, size_t outLen, const char *dir,
uint32_t track)
{
snprintf(out, outLen, "%s/track%02u.bin", dir, (unsigned)track);
}
static bool write_track_bin(const char *dir, uint32_t track)
{
char path[1024];
uint8_t *bin;
uint32_t pregap = (track == 1u) ? 0u : PREGAP_SECTORS;
size_t binLen = (size_t)(pregap + AUDIO_SECTORS) * SECTOR_BYTES;
size_t gapLen = (size_t)pregap * SECTOR_BYTES;
uint32_t n;
size_t written;
FILE *f;
bool ok = false;
bin = (uint8_t *)calloc(1, binLen);
if (!bin)
return false;
/* A ~441 Hz square wave: the subcode contract never looks at the
* PCM, it only has to be a playing stream. */
for (n = 0; n < AUDIO_SECTORS * SAMPLES_PER_SECT; n++)
{
size_t o = gapLen + (size_t)n * 4u;
int16_t v = ((n / 50u) & 1u) ? (int16_t)-16000 : (int16_t)16000;
bin[o + 0] = (uint8_t)((uint16_t)v & 0xFFu);
bin[o + 1] = (uint8_t)(((uint16_t)v >> 8) & 0xFFu);
bin[o + 2] = bin[o + 0];
bin[o + 3] = bin[o + 1];
}
track_bin_name(path, sizeof(path), dir, track);
f = fopen(path, "wb");
if (!f)
goto done;
written = fwrite(bin, 1, binLen, f);
fclose(f);
ok = (written == binLen);
done:
free(bin);
return ok;
}
static bool make_disc(const char *dir, char *cueOut, size_t cueOutLen)
{
uint32_t t;
FILE *f;
for (t = 1u; t <= NUM_TRACKS; t++)
if (!write_track_bin(dir, t))
return false;
snprintf(cueOut, cueOutLen, "%s/musiccd.cue", dir);
f = fopen(cueOut, "wb");
if (!f)
return false;
fprintf(f, "REM SESSION 01\n");
for (t = 1u; t <= NUM_TRACKS; t++)
{
fprintf(f, "FILE \"track%02u.bin\" BINARY\n", (unsigned)t);
fprintf(f, " TRACK %02u AUDIO\n", (unsigned)t);
if (t == 1u)
fprintf(f, " INDEX 01 00:00:00\n");
else
fprintf(f, " INDEX 00 00:00:00\n"
" INDEX 01 00:02:00\n");
}
fclose(f);
return true;
}
static void scrub_scratch(const char *base)
{
char path[1024];
uint32_t t;
for (t = 1u; t <= NUM_TRACKS; t++)
{
track_bin_name(path, sizeof(path), base, t);
remove(path);
}
snprintf(path, sizeof(path), "%s/musiccd.cue", base);
remove(path);
rmdir(base);
}
/* ------------------------------------------------------------------ */
/* BUTCH driving helpers (identical moves to test_cd_synth_cdda.c) */
/* ------------------------------------------------------------------ */
static uint16_t butch_status(void)
{
return C.CDROMReadWord(R_BUTCH_LO, CALLER);
}
static void dsa_send(uint16_t cmd)
{
C.CDROMWriteWord(R_DS_DATA, cmd, CALLER);
}
static uint16_t dsa_recv(void)
{
return C.CDROMReadWord(R_DS_DATA, CALLER);
}
static uint32_t wait_rx_full(uint32_t max)
{
uint32_t n = 0;
while (n <= max)
{
if (butch_status() & ST_RX_FULL)
return n;
p_butch_exec(0);
n++;
}
return 0xFFFFFFFFu;
}
static void dsa_seek(uint32_t lba)
{
uint32_t tf = lba + 150u;
dsa_send((uint16_t)(0x1000u | (tf / 4500u)));
dsa_send((uint16_t)(0x1100u | ((tf / 75u) % 60u)));
dsa_send((uint16_t)(0x1200u | (tf % 75u)));
}
static uint32_t seek_dones(void)
{
uint32_t dones = 0;
p_seek_state(NULL, &dones, NULL);
return dones;
}
static uint32_t wait_seek_done(uint32_t before, uint32_t max)
{
uint32_t n = 0;
while (n <= max)
{
if (seek_dones() != before)
return n;
p_butch_exec(0);
n++;
}
return 0xFFFFFFFFu;
}
/* Reset + I2S enable + seek, leaving the drive playing at `lba`. Does
* NOT arm subcode -- tests do that explicitly so test 1 can assert the
* disarmed default. */
static bool prime_stream(uint32_t lba)
{
uint32_t before;
uint16_t r;
C.CDROMReset();
C.CDROMWriteWord(R_I2CNTRL_LO, I2S_DATA_ENABLE, CALLER);
before = seek_dones();
dsa_seek(lba);
if (wait_seek_done(before, MAX_SEEK_TICKS) == 0xFFFFFFFFu)
{
fprintf(stderr, " (seek to LBA %u never completed)\n", lba);
return false;
}
if (wait_rx_full(MAX_DSA_TICKS) == 0xFFFFFFFFu)
{
fprintf(stderr, " (seek response never became visible)\n");
return false;
}
r = dsa_recv();
if (r != 0x0100)
{
fprintf(stderr, " (seek response was $%04X, expected $0100)\n", r);
return false;
}
return true;
}
static void arm_subcode(void)
{
/* The VLM writes $00F2 to SBCNTRL's low word before Play; any
* nonzero value arms capture. */
C.CDROMWriteWord(R_SBCNTRL_LO, 0x00F2u, CALLER);
}
static uint16_t subq_read(void)
{
return C.CDROMReadWord(R_SUBDATA_LO, CALLER);
}
/* Stream one stereo sample (the serializer's clock). */
static void ssi_step(void)
{
p_ssi_xmit();
}
/* Stream until the serializer has just wrapped to seq 0, then collect
* one aligned 12-byte frame. Returns false if no wrap/valid data shows
* up in a generous window. */
static bool collect_frame(uint8_t *q)
{
uint32_t guard;
uint16_t w;
uint32_t seq;
/* Get onto a seq 11 -> 0 boundary. */
for (guard = 0; guard < 4u * SAMPLES_PER_BYTE * SUBQ_BYTES; guard++)
{
w = subq_read();
if ((w & SUBQ_VALID) && (w & SUBQ_SEQ_MASK) == 11u)
break;
ssi_step();
}
if (guard >= 4u * SAMPLES_PER_BYTE * SUBQ_BYTES)
return false;
while ((subq_read() & SUBQ_SEQ_MASK) == 11u)
ssi_step();
/* Now at seq 0 of a fresh frame: read each byte as it goes by. */
for (seq = 0; seq < SUBQ_BYTES; seq++)
{
w = subq_read();
if (!(w & SUBQ_VALID) || (w & SUBQ_SEQ_MASK) != seq)
return false;
q[seq] = (uint8_t)(w >> 8);
while ((subq_read() & SUBQ_SEQ_MASK) == seq && seq != SUBQ_BYTES - 1u)
ssi_step();
}
return true;
}
/* Disc LBA of track t's INDEX 01, via the same TOC path the BIOS uses. */
static uint32_t track_data_lba(uint32_t t)
{
uint32_t m = p_track_info(t, 0);
uint32_t s = p_track_info(t, 1);
uint32_t f = p_track_info(t, 2);
uint32_t abs = ((m * 60u) + s) * 75u + f;
return (abs >= 150u) ? (abs - 150u) : 0u;
}
/* ------------------------------------------------------------------ */
/* 1. Disarmed reads stay zero (the pre-#291 contract for every title) */
/* ------------------------------------------------------------------ */
TEST(disarmed_subdata_reads_return_zero)
{
uint32_t n;
if (!prime_stream(track_data_lba(1u)))
FAIL("could not prime the stream");
for (n = 0; n < 3u * SAMPLES_PER_SECT; n++)
{
uint16_t w = subq_read();
if (w != 0)
FAIL("sample %u: disarmed SUBDATA read returned $%04X, "
"expected $0000", n, w);
ssi_step();
}
}
/* ------------------------------------------------------------------ */
/* 2. Word format and pacing */
/* ------------------------------------------------------------------ */
TEST(word_format_valid_bit_and_49_sample_pacing)
{
uint32_t n, runLen = 0, runsChecked = 0;
uint16_t prev;
if (!prime_stream(track_data_lba(1u)))
FAIL("could not prime the stream");
arm_subcode();
ssi_step(); /* first sample builds the frame */
prev = subq_read();
if (!(prev & SUBQ_VALID))
FAIL("armed+playing read has no valid bit: $%04X", prev);
for (n = 0; n < 4u * SAMPLES_PER_SECT; n++)
{
uint16_t w = subq_read();
if (!(w & SUBQ_VALID))
FAIL("sample %u: valid bit dropped mid-stream ($%04X)", n, w);
if ((w & SUBQ_SEQ_MASK) > 11u)
FAIL("sample %u: sequence %u out of range", n,
(unsigned)(w & SUBQ_SEQ_MASK));
if ((w & SUBQ_SEQ_MASK) != (prev & SUBQ_SEQ_MASK))
{
uint32_t want = ((prev & SUBQ_SEQ_MASK) + 1u) % SUBQ_BYTES;
if ((w & SUBQ_SEQ_MASK) != want)
FAIL("sequence jumped %u -> %u (expected %u)",
(unsigned)(prev & SUBQ_SEQ_MASK),
(unsigned)(w & SUBQ_SEQ_MASK), want);
/* Interior runs must be exactly 49 samples. (The first run
* after arming can be partial; skip it.) */
if (runsChecked > 0 && runLen != SAMPLES_PER_BYTE)
FAIL("sequence %u held for %u samples, expected %u",
(unsigned)(prev & SUBQ_SEQ_MASK), runLen,
SAMPLES_PER_BYTE);
runsChecked++;
runLen = 0;
}
runLen++;
prev = w;
ssi_step();
}
if (runsChecked < 2u * SUBQ_BYTES)
FAIL("only %u sequence transitions in 4 sectors", runsChecked);
}
/* ------------------------------------------------------------------ */
/* 3. Frame content: BCD position, audio control, $1D0F CRC residual */
/* ------------------------------------------------------------------ */
TEST(frame_is_valid_adr1_position_with_1d0f_residual)
{
uint8_t q[SUBQ_BYTES];
uint32_t d = track_data_lba(2u);
if (!prime_stream(d))
FAIL("could not prime the stream at track 2");
arm_subcode();
if (!collect_frame(q))
FAIL("no aligned Q frame within the collection window");
/* The one check the VLM's DSP actually enforces before unmuting: */
if (ref_crc16(q, SUBQ_BYTES) != 0x1D0Fu)
FAIL("CRC residual over 12 bytes is $%04X, the DSP requires $1D0F "
"(CRC must be stored inverted)",
ref_crc16(q, SUBQ_BYTES));
if (q[0] != 0x01u)
FAIL("CONTROL/ADR byte $%02X, expected $01 (audio, ADR 1; bit 6 "
"set here is the mute bit)", q[0]);
if (q[1] != ref_bcd(2u))
FAIL("track byte $%02X, expected $02", q[1]);
if (q[2] != 0x01u)
FAIL("index byte $%02X, expected $01 at INDEX 01", q[2]);
if (q[6] != 0x00u)
FAIL("ZERO byte is $%02X", q[6]);
/* Position: the frame was collected within a few sectors of the
* seek target, so relative time is tiny and absolute time is
* near d+150. Frame-exact equality is deliberately not asserted --
* collect_frame() streams an unspecified number of sectors while
* aligning. */
{
uint32_t rel = (from_bcd(q[3]) * 60u + from_bcd(q[4])) * 75u
+ from_bcd(q[5]);
uint32_t abs = q_abs_frames(q);
if (rel > 10u)
FAIL("relative position %u frames from INDEX 01, expected <=10",
rel);
if (abs < d + 150u || abs > d + 160u)
FAIL("absolute position %u, expected %u..%u",
abs, d + 150u, d + 160u);
if (abs != d + 150u + rel)
FAIL("absolute (%u) and relative (%u) positions disagree "
"about the seek target %u", abs, rel, d);
}
}
/* ------------------------------------------------------------------ */
/* 4. Pregap: INDEX 00, relative time counts down, control still audio */
/* ------------------------------------------------------------------ */
TEST(pregap_frame_has_index_zero_and_audio_control)
{
uint8_t q[SUBQ_BYTES];
uint32_t d = track_data_lba(3u);
if (!prime_stream(d - 20u)) /* 20 sectors inside track 3's pregap */
FAIL("could not prime the stream in track 3's pregap");
arm_subcode();
if (!collect_frame(q))
FAIL("no aligned Q frame within the collection window");
if (ref_crc16(q, SUBQ_BYTES) != 0x1D0Fu)
FAIL("pregap frame CRC residual $%04X != $1D0F",
ref_crc16(q, SUBQ_BYTES));
if (q[0] != 0x01u)
FAIL("pregap CONTROL/ADR $%02X, expected $01 -- a data-control "
"pregap would keep the VLM muted", q[0]);
if (q[1] != ref_bcd(3u))
FAIL("pregap track byte $%02X, expected $03", q[1]);
if (q[2] != 0x00u)
FAIL("pregap index byte $%02X, expected $00", q[2]);
{
uint32_t rel = (from_bcd(q[3]) * 60u + from_bcd(q[4])) * 75u
+ from_bcd(q[5]);
if (rel < 5u || rel > 20u)
FAIL("pregap relative countdown %u, expected 5..20 "
"(20 sectors out, minus alignment drift)", rel);
}
}
/* ------------------------------------------------------------------ */
/* 5. Q advances with playback */
/* ------------------------------------------------------------------ */
TEST(q_position_advances_one_sector_per_588_samples)
{
uint8_t q1[SUBQ_BYTES], q2[SUBQ_BYTES];
uint32_t a1, a2, n;
if (!prime_stream(track_data_lba(1u)))
FAIL("could not prime the stream");
arm_subcode();
if (!collect_frame(q1))
FAIL("no first frame");
a1 = q_abs_frames(q1);
for (n = 0; n < 5u * SAMPLES_PER_SECT; n++)
ssi_step();
if (!collect_frame(q2))
FAIL("no second frame");
a2 = q_abs_frames(q2);
/* 5 sectors streamed between the collections, plus up to ~2 sectors
* of alignment inside each collect_frame(). */
if (a2 <= a1)
FAIL("Q position did not advance (%u -> %u)", a1, a2);
if (a2 - a1 < 5u || a2 - a1 > 9u)
FAIL("Q advanced %u sectors over ~5 sectors of streaming", a2 - a1);
}
/* ------------------------------------------------------------------ */
/* 6. Stop mutes the window */
/* ------------------------------------------------------------------ */
TEST(stop_returns_subdata_to_zero)
{
uint32_t n;
uint16_t w;
if (!prime_stream(track_data_lba(1u)))
FAIL("could not prime the stream");
arm_subcode();
ssi_step();
w = subq_read();
if (!(w & SUBQ_VALID))
FAIL("no valid subcode before the stop ($%04X)", w);
dsa_send(0x0200u); /* Stop */
if (wait_rx_full(MAX_DSA_TICKS) == 0xFFFFFFFFu)
FAIL("no response to $0200 Stop");
(void)dsa_recv();
for (n = 0; n < 4u; n++)
{
ssi_step();
w = subq_read();
if (w != 0)
FAIL("SUBDATA still live after Stop: $%04X", w);
}
}
/* ------------------------------------------------------------------ */
/* Symbol resolution */
/* ------------------------------------------------------------------ */
static bool resolve_symbols(void)
{
p_open_image = (bool (*)(const char *)) dlsym(C.handle, "CDIntfOpenImage");
p_close_image = (void (*)(void)) dlsym(C.handle, "CDIntfCloseImage");
p_num_tracks = (uint32_t (*)(void)) dlsym(C.handle, "CDIntfGetNumTracks");
p_track_info = (uint8_t (*)(uint32_t, uint32_t))
dlsym(C.handle, "CDIntfGetTrackInfo");
p_butch_exec = (void (*)(uint32_t)) dlsym(C.handle, "BUTCHExec");
p_seek_state = (void (*)(uint32_t *, uint32_t *, uint32_t *))
dlsym(C.handle, "CDROMDiagGetSeekWedgeState");
p_ssi_xmit = (void (*)(void)) dlsym(C.handle, "SetSSIWordsXmittedFromButch");
return p_open_image && p_close_image && p_num_tracks && p_track_info &&
p_butch_exec && p_seek_state && p_ssi_xmit &&
C.CDROMInit && C.CDROMReset && C.CDROMReadWord && C.CDROMWriteWord;
}
int main(int argc, char *argv[])
{
char base[512];
char cue[1024];
int rc = 1;
bool opened = false;
(void)argc; (void)argv;
TEST_INIT("BUTCH Q-subcode serial window (synthetic audio disc)");
if (!vj_core_load(&C))
{
fprintf(stderr, "FATAL: failed to load core\n");
return 1;
}
vj_core_init(&C);
if (!resolve_symbols())
{
fprintf(stderr, " FAIL missing test exports "
"(build with `make TEST_EXPORTS=1`)\n");
vj_core_unload(&C);
return 1;
}
snprintf(base, sizeof(base), "/tmp/vj_cd_subq_%ld", (long)getpid());
if (mkdir(base, 0755) != 0 && errno != EEXIST)
{
fprintf(stderr, " SKIP cannot create scratch dir %s\n", base);
vj_core_unload(&C);
return 0;
}
if (!make_disc(base, cue, sizeof(cue)))
{
fprintf(stderr, " SKIP cannot write synthetic disc under %s\n", base);
goto out;
}
if (!p_open_image(cue))
{
fprintf(stderr, " FAIL synthetic disc did not load\n");
goto out;
}
opened = true;
C.CDROMInit();
C.CDROMReset();
RUN_TEST(disarmed_subdata_reads_return_zero);
RUN_TEST(word_format_valid_bit_and_49_sample_pacing);
RUN_TEST(frame_is_valid_adr1_position_with_1d0f_residual);
RUN_TEST(pregap_frame_has_index_zero_and_audio_control);
RUN_TEST(q_position_advances_one_sector_per_588_samples);
RUN_TEST(stop_returns_subdata_to_zero);
rc = TEST_REPORT();
out:
if (opened)
p_close_image();
vj_core_unload(&C);
scrub_scratch(base);
return rc;
}
+806
View File
@@ -0,0 +1,806 @@
/*
* test/test_jgd.c — Jaguar GameDrive (JagGD) detection + banking test.
*
* Synthetic-only (no private ROMs needed, like test_cd_synth_*): builds a
* probe cartridge image at runtime whose 68K program replays the exact
* logic of RetroHQ's published gdbios_bindings.s — the GDWaitData
* handshake, the HW-version command (12), the GDBIOS install command
* ($80), then blob calls (GD_BIOSVersion / GD_ROMSetPages / GD_ROMSetPage
* / GD_ROMWriteEnable) — and stores every intermediate result at fixed
* main-RAM addresses this harness reads back via dlsym'd core internals.
*
* Unlike the real bindings, the probe's handshake spin loops carry a
* bail-out counter: on a machine with no GD the real code spins forever
* (GDWaitData .waitAck), so the "option disabled" phase asserts the
* timeout marker — proof the wedge would have happened — instead of
* hanging the suite.
*
* Phases (separate core lifecycles in one process, which also exercises
* the iOS static-state reset contract):
* A) virtualjaguar_jgd=disabled + 16 MB image: detect must FAIL cleanly
* B) virtualjaguar_jgd=auto + 16 MB image: install + banking + straddle
* + write-enable, then savestate round-trip (v8) and the pre-v8
* degradation contract (v7-headered state loads, GD resets)
* C) virtualjaguar_jgd=enabled + 2 MB image: the ForceJGD path for
* GD-locked images smaller than the cart window
*
* Exit codes: 0 = pass, 1 = fail, 2 = harness error.
*
* Build: cc -O2 -Wall -std=c99 $(INCFLAGS) -o test/test_jgd \
* test/test_jgd.c test/harness/harness.c -ldl -lm
*/
#include "harness/harness.h"
#include "state.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#define MAX_RESULTS 64
/* Probe result mailbox in Jaguar main RAM. */
#define R_FLAG 0x10000 /* progress / final magic */
#define R_ERR 0x10004 /* failure marker */
#define R_HWVER 0x10010 /* u32 from command 12 */
#define R_BLOBSIZE 0x10014 /* u16 size prefix from command $80 */
#define R_BLOBHDR 0x10018 /* blob version<<16 | function count */
#define R_BIOSVER 0x1001C /* GD_BIOSVersion() */
#define R_MARKERS 0x10020 /* six u32 window markers after ROMSetPages */
#define R_STRADDLE 0x10040 /* u32 read straddling the page 0/1 boundary */
#define R_SETPAGE 0x10044 /* marker after ROMSetPage(2,3) */
#define R_WREN 0x10048 /* read-back of write-enabled cart write */
#define R_WRDIS 0x1004C /* read-back after write disable (must miss) */
#define MAGIC_DONE 0xC0DE600Du
#define MAGIC_FAILED 0xDEADDEADu
#define ERR_TIMEOUT 0xDEAD0001u
#define ERR_VERSION 0xDEAD0002u
/* Blob served by jaggd.c (kept in sync with src/core/jaggd.c). */
#define EXPECT_BLOB_SIZE 208
#define EXPECT_BLOB_HDR 0x0100001Au /* version $0100, 26 functions */
#define EXPECT_HWVER 0x03000102u /* FW $03.00 (>= $01.11), ASIC $01.02 */
#define EXPECT_BIOSVER 0x00000100u
/* JGD chunk byte count (see JGDStateSave): 16 + 6*2 + 512. */
#define EXPECT_JGD_CHUNK 540
#define STATE_OFF_VERSION 4
typedef size_t (*jgd_state_save_fn)(uint8_t *buf);
typedef size_t (*serialize_size_fn)(void);
/* bool, not int: retro_serialize/unserialize return bool, and UBSan's
* function-type-mismatch check (Linux CI) rejects calls through a
* mistyped pointer even when the ABI happens to coincide. Matches
* test_state_compat.c. */
typedef bool (*serialize_fn)(void *data, size_t size);
typedef bool (*unserialize_fn)(const void *data, size_t size);
typedef void (*reset_fn)(void);
typedef uint32_t (*read_long_fn)(uint32_t offset, uint32_t who);
static int pass_count = 0;
static int fail_count = 0;
static harness_result results[MAX_RESULTS];
static unsigned num_results = 0;
static char detail_store[MAX_RESULTS][192];
static void check(int cond, const char *name, const char *fmt, ...)
{
va_list ap;
if (num_results >= MAX_RESULTS)
return;
va_start(ap, fmt);
vsnprintf(detail_store[num_results], sizeof(detail_store[0]), fmt, ap);
va_end(ap);
results[num_results].status = cond ? "PASS" : "FAIL";
results[num_results].name = name;
results[num_results].detail = detail_store[num_results];
num_results++;
if (cond)
pass_count++;
else
fail_count++;
}
/* ================================================================
* Probe ROM builder — 68K hand-assembly (big-endian), same approach
* as test_boot_patterns.c's ROM builder.
* ================================================================ */
#define CODE_ADDR 0x00802000u
#define BLOB_BUF 0x00004000u /* long-aligned RAM buffer for the blob */
#define GD_STATUS 0x00F16002u
#define GD_DATA 0x00F16004u
#define GD_DATAB 0x00F16005u
/* Bounded-spin iteration count for the disabled phase (the real
* bindings spin forever; ~50k iterations is a dozen frames). */
#define SPIN_LIMIT 50000
static uint8_t *g_rom;
static uint32_t g_pos; /* current emit offset within the image */
static void e16(uint16_t v)
{
g_rom[g_pos++] = (uint8_t)(v >> 8);
g_rom[g_pos++] = (uint8_t)(v & 0xFF);
}
static void e32(uint32_t v)
{
e16((uint16_t)(v >> 16));
e16((uint16_t)(v & 0xFFFF));
}
/* move.l #imm,abs.l */
static void e_movel_imm_abs(uint32_t imm, uint32_t addr)
{ e16(0x23FC); e32(imm); e32(addr); }
/* move.w #imm,abs.l */
static void e_movew_imm_abs(uint16_t imm, uint32_t addr)
{ e16(0x33FC); e16(imm); e32(addr); }
/* move.w abs.l,dn */
static void e_movew_abs_dn(uint32_t addr, int dn)
{ e16((uint16_t)(0x3039 | (dn << 9))); e32(addr); }
/* move.l abs.l,dn */
static void e_movel_abs_dn(uint32_t addr, int dn)
{ e16((uint16_t)(0x2039 | (dn << 9))); e32(addr); }
/* move.l dn,abs.l */
static void e_movel_dn_abs(int dn, uint32_t addr)
{ e16((uint16_t)(0x23C0 | dn)); e32(addr); }
/* move.w dn,abs.l */
static void e_movew_dn_abs(int dn, uint32_t addr)
{ e16((uint16_t)(0x33C0 | dn)); e32(addr); }
/* move.b abs.l,dn */
static void e_moveb_abs_dn(uint32_t addr, int dn)
{ e16((uint16_t)(0x1039 | (dn << 9))); e32(addr); }
/* move.b abs.l,(a0)+ */
static void e_moveb_abs_a0inc(uint32_t addr)
{ e16(0x10F9); e32(addr); }
/* move.l #imm,dn */
static void e_movel_imm_dn(int dn, uint32_t imm)
{ e16((uint16_t)(0x203C | (dn << 9))); e32(imm); }
/* move.w #imm,dn */
static void e_movew_imm_dn(int dn, uint16_t imm)
{ e16((uint16_t)(0x303C | (dn << 9))); e16(imm); }
/* movea.l #imm,an */
static void e_movea_imm(int an, uint32_t imm)
{ e16((uint16_t)(0x207C | (an << 9))); e32(imm); }
/* moveq #v,dn */
static void e_moveq(int dn, int v)
{ e16((uint16_t)(0x7000 | (dn << 9) | (v & 0xFF))); }
/* btst #bit,d0 */
static void e_btst_d0(int bit)
{ e16(0x0800); e16((uint16_t)bit); }
/* subq.l #1,dn */
static void e_subql_1(int dn)
{ e16((uint16_t)(0x5380 | dn)); }
/* subq.w #1,dn */
static void e_subqw_1(int dn)
{ e16((uint16_t)(0x5340 | dn)); }
/* andi.l #imm,d0 */
static void e_andil_d0(uint32_t imm)
{ e16(0x0280); e32(imm); }
/* cmpi.w #imm,d0 */
static void e_cmpiw_d0(uint16_t imm)
{ e16(0x0C40); e16(imm); }
/* clr.w abs.l */
static void e_clrw_abs(uint32_t addr)
{ e16(0x4279); e32(addr); }
/* swap d0 */
static void e_swap_d0(void)
{ e16(0x4840); }
/* move.w d0,d1 */
static void e_movew_d0_d1(void)
{ e16(0x3200); }
/* ror.w #8,d0 */
static void e_rorw8_d0(void)
{ e16(0xE058); }
/* jsr d16(a6) */
static void e_jsr_a6(int disp)
{ e16(0x4EAE); e16((uint16_t)disp); }
/* rts */
static void e_rts(void)
{ e16(0x4E75); }
/* bra.s self */
static void e_bra_self(void)
{ e16(0x60FE); }
/* Short conditional/unconditional branches to a KNOWN (backward) target,
* and a forward-patch variant. */
static void e_bcc_s_to(uint16_t opcode, uint32_t target)
{
int disp = (int)target - (int)(g_pos + 2);
e16((uint16_t)(opcode | (disp & 0xFF)));
}
static uint32_t e_bcc_s_fwd(uint16_t opcode) /* returns patch position */
{
uint32_t at = g_pos;
e16(opcode); /* displacement patched later */
return at;
}
static void patch_bcc_s(uint32_t at, uint32_t target)
{
int disp = (int)target - (int)(at + 2);
g_rom[at + 1] = (uint8_t)(disp & 0xFF);
}
/* bra.w / bsr.w to a known target */
static void e_braw_to(uint32_t target)
{
e16(0x6000);
e16((uint16_t)((int)target - (int)g_pos));
}
static void e_bsrw_to(uint32_t target)
{
e16(0x6100);
e16((uint16_t)((int)target - (int)g_pos));
}
/* dbra dn,<target> */
static void e_dbra_to(int dn, uint32_t target)
{
e16((uint16_t)(0x51C8 | dn));
e16((uint16_t)((int)target - (int)g_pos));
}
/*
* Build the probe image. size must be a multiple of 1 MB. Every 1 MB
* bank that exists in the image gets:
* +$00 u16 pattern (0x50+bank)<<8 | (0xA0+bank) [straddle low half]
* +$10 u32 0xC0DE0000 | bank [window marker]
* and bank 0 additionally carries the cart header, the code, and
* +$FFFFC u32 0xAA55A0A0 [straddle high half]
*/
static void build_probe_rom(uint8_t *rom, uint32_t size)
{
uint32_t banks = size >> 20;
uint32_t b;
uint32_t l_waitdata, l_xword, l_timeout, l_verfail, l_main;
uint32_t l_wd1, l_wd2, l_drain, l_copy;
uint32_t p_sel, p_ok, p_fwok, p_nodrain;
memset(rom, 0, size);
g_rom = rom;
for (b = 0; b < banks; b++)
{
uint32_t base = b << 20;
rom[base + 0] = (uint8_t)(0x50 + b);
rom[base + 1] = (uint8_t)(0xA0 + b);
rom[base + 0x10] = 0xC0;
rom[base + 0x11] = 0xDE;
rom[base + 0x12] = 0x00;
rom[base + 0x13] = (uint8_t)b;
}
/* bank 0, last long before the page 0/1 boundary */
rom[0xFFFFC] = 0xAA; rom[0xFFFFD] = 0x55;
rom[0xFFFFE] = 0xA0; rom[0xFFFFF] = 0xA0;
/* Cart header: universal marker + run address (patched to main below) */
rom[0x400] = 0x04; rom[0x401] = 0x04; rom[0x402] = 0x04; rom[0x403] = 0x04;
g_pos = 0x2000;
/* ---- timeout_fail ---- */
l_timeout = g_pos;
e_movel_imm_abs(ERR_TIMEOUT, R_ERR);
e_movel_imm_abs(MAGIC_FAILED, R_FLAG);
e_bra_self();
/* ---- version_fail ---- */
l_verfail = g_pos;
e_movel_imm_abs(ERR_VERSION, R_ERR);
e_movel_imm_abs(MAGIC_FAILED, R_FLAG);
e_bra_self();
/* ---- gd_waitdata (bounded) ----
* spin HAVE_DATA==0; write $0011; spin HAVE_DATA==1. Real bindings
* spin forever; the bail-out proves the no-GD wedge without hanging. */
l_waitdata = g_pos;
e_movel_imm_dn(7, SPIN_LIMIT);
l_wd1 = g_pos;
e_movew_abs_dn(GD_STATUS, 0);
e_btst_d0(3);
p_sel = e_bcc_s_fwd(0x6700); /* beq.s .sel */
e_subql_1(7);
e_bcc_s_to(0x6600, l_wd1); /* bne.s .wd1 */
e_braw_to(l_timeout);
patch_bcc_s(p_sel, g_pos); /* .sel: */
e_movew_imm_abs(0x0011, GD_STATUS);
e_movel_imm_dn(7, SPIN_LIMIT);
l_wd2 = g_pos;
e_movew_abs_dn(GD_STATUS, 0);
e_btst_d0(3);
p_ok = e_bcc_s_fwd(0x6600); /* bne.s .ok */
e_subql_1(7);
e_bcc_s_to(0x6600, l_wd2); /* bne.s .wd2 */
e_braw_to(l_timeout);
patch_bcc_s(p_ok, g_pos); /* .ok: */
e_rts();
/* ---- gd_xword: word exchange, tx low byte first, rx high first ---- */
l_xword = g_pos;
e_movew_dn_abs(0, GD_DATA);
e_moveb_abs_dn(GD_DATAB, 0);
e_rorw8_d0();
e_movew_dn_abs(0, GD_DATA);
e_moveb_abs_dn(GD_DATAB, 0);
e_rts();
/* ---- main ---- */
l_main = g_pos;
e_movel_imm_abs(1, R_FLAG);
/* HW version (command 12) */
e_movew_imm_abs(0x0010, GD_STATUS);
e_bsrw_to(l_waitdata);
e_movel_imm_abs(2, R_FLAG);
e_movew_imm_dn(0, 12);
e_bsrw_to(l_xword);
e_moveq(0, 0);
e_bsrw_to(l_xword);
e_movew_imm_abs(0x0010, GD_STATUS);
e_bsrw_to(l_waitdata);
e_bsrw_to(l_xword); /* firmware word */
e_swap_d0();
e_bsrw_to(l_xword); /* ASIC word */
e_movel_dn_abs(0, R_HWVER);
/* GD_Install's firmware gate: FW must be >= $0111 */
e_swap_d0();
e_cmpiw_d0(0x0111);
p_fwok = e_bcc_s_fwd(0x6C00); /* bge.s .fwok */
e_braw_to(l_verfail);
patch_bcc_s(p_fwok, g_pos); /* .fwok: */
e_clrw_abs(GD_STATUS);
/* Drain the rx latch ("in case of FIFO DMA termination") */
l_drain = g_pos;
e_movew_abs_dn(GD_STATUS, 0);
e_btst_d0(5);
p_nodrain = e_bcc_s_fwd(0x6700); /* beq.s .nodrain */
e_movew_abs_dn(GD_DATA, 1); /* word read drains the latch */
e_bcc_s_to(0x6000, l_drain); /* bra.s .drain */
patch_bcc_s(p_nodrain, g_pos); /* .nodrain: */
/* Install the GDBIOS blob (command $80) */
e_movew_imm_abs(0x0010, GD_STATUS);
e_bsrw_to(l_waitdata);
e_movew_imm_dn(0, 0x80);
e_bsrw_to(l_xword);
e_moveq(0, 0);
e_bsrw_to(l_xword);
e_movew_imm_abs(0x0010, GD_STATUS);
e_bsrw_to(l_waitdata);
e_bsrw_to(l_xword); /* u16 blob size */
e_movew_imm_abs(0x0010, GD_STATUS); /* done with this block */
e_andil_d0(0xFFFF);
e_movel_dn_abs(0, R_BLOBSIZE);
/* copy loop (our blob is 208 bytes: a single <=512 block) */
e_movea_imm(0, BLOB_BUF);
e_movew_d0_d1();
e_bsrw_to(l_waitdata);
e_subqw_1(1);
l_copy = g_pos;
e_movew_dn_abs(1, GD_DATA); /* dummy tx clocks a byte out */
e_moveb_abs_a0inc(GD_DATAB);
e_dbra_to(1, l_copy);
e_movew_imm_abs(0x0010, GD_STATUS);
e_clrw_abs(GD_STATUS);
e_movel_imm_abs(3, R_FLAG);
/* Blob header: version + function count */
e_movel_abs_dn(BLOB_BUF, 0);
e_movel_dn_abs(0, R_BLOBHDR);
/* a6 = blob base (GDFunc convention), call GD_BIOSVersion (3) */
e_movea_imm(6, BLOB_BUF);
e_jsr_a6(3 * 4);
e_andil_d0(0xFFFF);
e_movel_dn_abs(0, R_BIOSVER);
/* GD_ROMSetPages($BCDEF0): page0=0, pages 1..5 = banks 15,14,13,12,11 */
e_movel_imm_dn(0, 0x00BCDEF0);
e_jsr_a6(6 * 4);
/* Window markers */
e_movel_abs_dn(0x800010, 0); e_movel_dn_abs(0, R_MARKERS + 0);
e_movel_abs_dn(0x900010, 0); e_movel_dn_abs(0, R_MARKERS + 4);
e_movel_abs_dn(0xA00010, 0); e_movel_dn_abs(0, R_MARKERS + 8);
e_movel_abs_dn(0xB00010, 0); e_movel_dn_abs(0, R_MARKERS + 12);
e_movel_abs_dn(0xC00010, 0); e_movel_dn_abs(0, R_MARKERS + 16);
e_movel_abs_dn(0xD00010, 0); e_movel_dn_abs(0, R_MARKERS + 20);
/* Long read straddling the page 0 / page 1 bank boundary */
e_movel_abs_dn(0x8FFFFE, 0);
e_movel_dn_abs(0, R_STRADDLE);
/* GD_ROMSetPage(2, 3): d0 = page<<16 | bank */
e_movel_imm_dn(0, 0x00020003);
e_jsr_a6(5 * 4);
e_movel_abs_dn(0xA00010, 0);
e_movel_dn_abs(0, R_SETPAGE);
/* GD_ROMWriteEnable(1): write through page 1 (bank 15), read back */
e_moveq(0, 1);
e_jsr_a6(4 * 4);
e_movew_imm_abs(0x1234, 0x900100);
e_movew_abs_dn(0x900100, 0);
e_andil_d0(0xFFFF);
e_movel_dn_abs(0, R_WREN);
/* GD_ROMWriteEnable(0): the same write must now miss */
e_moveq(0, 0);
e_jsr_a6(4 * 4);
e_movew_imm_abs(0x4321, 0x900104);
e_movew_abs_dn(0x900104, 0);
e_andil_d0(0xFFFF);
e_movel_dn_abs(0, R_WRDIS);
e_movel_imm_abs(MAGIC_DONE, R_FLAG);
e_bra_self();
/* Run address -> main */
rom[0x404] = (uint8_t)((CODE_ADDR + (l_main - 0x2000)) >> 24);
rom[0x405] = (uint8_t)((CODE_ADDR + (l_main - 0x2000)) >> 16);
rom[0x406] = (uint8_t)((CODE_ADDR + (l_main - 0x2000)) >> 8);
rom[0x407] = (uint8_t)((CODE_ADDR + (l_main - 0x2000)) & 0xFF);
}
/* ================================================================
* Host-side helpers
* ================================================================ */
static uint32_t ram32(uint8_t *ram, uint32_t addr)
{
return ((uint32_t)ram[addr] << 24) | ((uint32_t)ram[addr + 1] << 16)
| ((uint32_t)ram[addr + 2] << 8) | ram[addr + 3];
}
static int write_rom_file(const char *path, const uint8_t *data, uint32_t size)
{
FILE *f = fopen(path, "wb");
if (!f)
return 0;
if (fwrite(data, 1, size, f) != size)
{
fclose(f);
return 0;
}
fclose(f);
return 1;
}
/* One core lifecycle: load the image with the given option value and run.
* Returns the harness config through *cfg (caller shuts it down). */
static int run_phase(harness_config *cfg, int argc, char **argv,
const char *rom_path, const char *jgd_value,
unsigned frames)
{
harness_config fresh = HARNESS_CONFIG_DEFAULT;
*cfg = fresh;
cfg->frames = frames;
cfg->quiet = 1;
if (!harness_init_from_args(cfg, argc, argv))
return 0;
cfg->rom_path = rom_path;
harness_set_option(cfg, "virtualjaguar_jgd", jgd_value);
if (!harness_load_rom(cfg))
return 0;
harness_run(cfg);
return 1;
}
int main(int argc, char **argv)
{
char rom16_path[512], rom2_path[512];
const char *tmpdir;
uint8_t *rom16, *rom2;
harness_config cfg;
uint8_t *ram;
uint8_t **ram_ptr;
uint8_t *active_ptr, *wren_ptr, *page_ptr;
int rc;
tmpdir = getenv("TMPDIR");
if (!tmpdir || !tmpdir[0])
tmpdir = "/tmp";
snprintf(rom16_path, sizeof(rom16_path), "%s/vj_jgd_probe16_%ld.j64",
tmpdir, (long)getpid());
snprintf(rom2_path, sizeof(rom2_path), "%s/vj_jgd_probe2_%ld.j64",
tmpdir, (long)getpid());
rom16 = (uint8_t *)malloc(16u << 20);
rom2 = (uint8_t *)malloc(2u << 20);
if (!rom16 || !rom2)
{
fprintf(stderr, "Out of memory building probe images\n");
return 2;
}
build_probe_rom(rom16, 16u << 20);
build_probe_rom(rom2, 2u << 20);
if (!write_rom_file(rom16_path, rom16, 16u << 20)
|| !write_rom_file(rom2_path, rom2, 2u << 20))
{
fprintf(stderr, "Cannot write probe images under %s\n", tmpdir);
free(rom16); free(rom2);
return 2;
}
/* ============================================================
* Phase A: option disabled -> GD detect must FAIL cleanly.
* The probe's bounded GDWaitData must hit its bail-out (the real
* bindings would spin in .waitAck forever = the stock-console
* hang), and the core must still be running frames.
* ============================================================ */
if (!run_phase(&cfg, argc, argv, rom16_path, "disabled", 90))
{
fprintf(stderr, "Phase A: harness init/load failed\n");
rc = 2;
goto cleanup_files;
}
ram_ptr = (uint8_t **)harness_dlsym(&cfg, "jaguarMainRAM");
active_ptr = (uint8_t *)harness_dlsym(&cfg, "jgdActive");
if (!ram_ptr || !active_ptr)
{
fprintf(stderr, "Cannot resolve jaguarMainRAM/jgdActive "
"(needs a TEST_EXPORTS=1 build)\n");
harness_shutdown(&cfg);
rc = 2;
goto cleanup_files;
}
ram = *ram_ptr;
check(*active_ptr == 0, "disabled_not_active",
"jgdActive=%u with option disabled", *active_ptr);
check(ram32(ram, R_FLAG) == MAGIC_FAILED
&& ram32(ram, R_ERR) == ERR_TIMEOUT,
"disabled_detect_times_out",
"flag=$%08X err=$%08X (expect $%08X/$%08X: the handshake never "
"acked, so unbounded GD code would have wedged)",
ram32(ram, R_FLAG), ram32(ram, R_ERR), MAGIC_FAILED, ERR_TIMEOUT);
check(ram32(ram, R_HWVER) == 0, "disabled_no_hwversion",
"R_HWVER=$%08X (expect 0: transaction never reached command 12)",
ram32(ram, R_HWVER));
check(cfg.video.total_frames_rendered >= 90, "disabled_core_not_hung",
"%u frames rendered (bail-out kept the suite alive)",
cfg.video.total_frames_rendered);
harness_shutdown(&cfg);
/* ============================================================
* Phase B: auto + 16 MB image -> full install + banking.
* ============================================================ */
if (!run_phase(&cfg, argc, argv, rom16_path, "auto", 30))
{
fprintf(stderr, "Phase B: harness init/load failed\n");
rc = 2;
goto cleanup_files;
}
ram_ptr = (uint8_t **)harness_dlsym(&cfg, "jaguarMainRAM");
active_ptr = (uint8_t *)harness_dlsym(&cfg, "jgdActive");
wren_ptr = (uint8_t *)harness_dlsym(&cfg, "jgdWriteEnabled");
page_ptr = (uint8_t *)harness_dlsym(&cfg, "jgdPage");
if (!ram_ptr || !active_ptr || !wren_ptr || !page_ptr)
{
fprintf(stderr, "Cannot resolve JGD internals\n");
harness_shutdown(&cfg);
rc = 2;
goto cleanup_files;
}
ram = *ram_ptr;
check(*active_ptr == 1, "auto_active_over_6mb",
"jgdActive=%u for a 16 MB image in auto mode", *active_ptr);
check(ram32(ram, R_FLAG) == MAGIC_DONE, "probe_completed",
"flag=$%08X err=$%08X (expect $%08X)",
ram32(ram, R_FLAG), ram32(ram, R_ERR), MAGIC_DONE);
check(ram32(ram, R_HWVER) == EXPECT_HWVER, "hw_version",
"cmd 12 -> $%08X (expect $%08X; FW word must be >= $0111)",
ram32(ram, R_HWVER), EXPECT_HWVER);
check(ram32(ram, R_BLOBSIZE) == EXPECT_BLOB_SIZE, "blob_size",
"cmd $80 size prefix = %u (expect %u)",
ram32(ram, R_BLOBSIZE), EXPECT_BLOB_SIZE);
check(ram32(ram, R_BLOBHDR) == EXPECT_BLOB_HDR, "blob_header",
"blob version/funccount = $%08X (expect $%08X)",
ram32(ram, R_BLOBHDR), EXPECT_BLOB_HDR);
check(ram32(ram, R_BIOSVER) == EXPECT_BIOSVER, "blob_biosversion",
"GD_BIOSVersion() = $%08X (expect $%08X)",
ram32(ram, R_BIOSVER), EXPECT_BIOSVER);
check(ram32(ram, R_MARKERS + 0) == 0xC0DE0000u
&& ram32(ram, R_MARKERS + 4) == 0xC0DE000Fu
&& ram32(ram, R_MARKERS + 8) == 0xC0DE000Eu
&& ram32(ram, R_MARKERS + 12) == 0xC0DE000Du
&& ram32(ram, R_MARKERS + 16) == 0xC0DE000Cu
&& ram32(ram, R_MARKERS + 20) == 0xC0DE000Bu,
"banking_markers",
"windows after ROMSetPages($BCDEF0): %08X %08X %08X %08X %08X %08X",
ram32(ram, R_MARKERS + 0), ram32(ram, R_MARKERS + 4),
ram32(ram, R_MARKERS + 8), ram32(ram, R_MARKERS + 12),
ram32(ram, R_MARKERS + 16), ram32(ram, R_MARKERS + 20));
check(ram32(ram, R_STRADDLE) == 0xA0A05FAFu, "boundary_straddle",
"long at $8FFFFE = $%08X (expect $A0A05FAF: bank 0 tail + "
"bank 15 head)", ram32(ram, R_STRADDLE));
check(ram32(ram, R_SETPAGE) == 0xC0DE0003u, "single_page_remap",
"window 2 after ROMSetPage(2,3) = $%08X (expect $C0DE0003)",
ram32(ram, R_SETPAGE));
check(ram32(ram, R_WREN) == 0x1234u, "rom_write_enable",
"write-enabled cart write reads back $%04X (expect $1234)",
ram32(ram, R_WREN));
check(ram32(ram, R_WRDIS) == 0u, "rom_write_disable",
"write-protected cart write reads back $%04X (expect $0000)",
ram32(ram, R_WRDIS));
check(page_ptr[0] == 0 && page_ptr[1] == 15 && page_ptr[2] == 3
&& page_ptr[3] == 13 && page_ptr[4] == 12 && page_ptr[5] == 11,
"page_table_state",
"jgdPage = {%u,%u,%u,%u,%u,%u} (expect {0,15,3,13,12,11})",
page_ptr[0], page_ptr[1], page_ptr[2],
page_ptr[3], page_ptr[4], page_ptr[5]);
check(*wren_ptr == 0, "write_enable_left_clear",
"jgdWriteEnabled=%u after the probe disabled it", *wren_ptr);
/* ---- Savestate round-trip (still phase B's session) ---- */
{
jgd_state_save_fn jgd_save =
(jgd_state_save_fn)harness_dlsym(&cfg, "JGDStateSave");
serialize_size_fn ser_size =
(serialize_size_fn)harness_dlsym(&cfg, "retro_serialize_size");
serialize_fn ser = (serialize_fn)harness_dlsym(&cfg, "retro_serialize");
unserialize_fn unser =
(unserialize_fn)harness_dlsym(&cfg, "retro_unserialize");
reset_fn do_reset = (reset_fn)harness_dlsym(&cfg, "retro_reset");
read_long_fn jag_read_long =
(read_long_fn)harness_dlsym(&cfg, "JaguarReadLong");
uint8_t *state = NULL, *state7 = NULL;
uint8_t chunk[1024];
size_t ssize;
if (!jgd_save || !ser_size || !ser || !unser || !do_reset
|| !jag_read_long)
{
fprintf(stderr, "Cannot resolve savestate symbols\n");
harness_shutdown(&cfg);
rc = 2;
goto cleanup_files;
}
check(jgd_save(chunk) == EXPECT_JGD_CHUNK, "jgd_chunk_size",
"JGDStateSave wrote %lu bytes (expect %d; active and "
"inactive layouts must match)",
(unsigned long)jgd_save(chunk), EXPECT_JGD_CHUNK);
ssize = ser_size();
state = (uint8_t *)malloc(ssize);
state7 = (uint8_t *)malloc(ssize);
if (!state || !state7)
{
fprintf(stderr, "Out of memory for states\n");
free(state); free(state7);
harness_shutdown(&cfg);
rc = 2;
goto cleanup_files;
}
check(ser(state, ssize) != 0, "v8_serialize",
"retro_serialize of a banked GameDrive session");
{
uint32_t hdr_ver;
memcpy(&hdr_ver, state + STATE_OFF_VERSION, 4);
check(hdr_ver == (uint32_t)STATE_VERSION
&& STATE_VERSION == 8,
"v8_header", "state header version=%u (expect 8)", hdr_ver);
}
/* Console reset returns the mapping to identity... */
do_reset();
check(page_ptr[0] == 0 && page_ptr[1] == 1 && page_ptr[2] == 2
&& page_ptr[3] == 3 && page_ptr[4] == 4 && page_ptr[5] == 5,
"reset_returns_identity",
"jgdPage after retro_reset = {%u,%u,%u,%u,%u,%u}",
page_ptr[0], page_ptr[1], page_ptr[2],
page_ptr[3], page_ptr[4], page_ptr[5]);
/* ...and the state load restores the remap. */
check(unser(state, ssize) != 0, "v8_unserialize", "state loads back");
check(page_ptr[0] == 0 && page_ptr[1] == 15 && page_ptr[2] == 3
&& page_ptr[3] == 13 && page_ptr[4] == 12 && page_ptr[5] == 11,
"v8_pages_restored",
"jgdPage after load = {%u,%u,%u,%u,%u,%u} (expect {0,15,3,13,12,11})",
page_ptr[0], page_ptr[1], page_ptr[2],
page_ptr[3], page_ptr[4], page_ptr[5]);
check(jag_read_long(0xA00010, 0) == 0xC0DE0003u,
"v8_banked_read_after_load",
"JaguarReadLong($A00010) = $%08X (expect $C0DE0003)",
jag_read_long(0xA00010, 0));
/* Pre-v8 contract: a v7-headered state (no JGD chunk) must load,
* with the GD falling back to its reset mapping. */
memcpy(state7, state, ssize);
{
uint32_t v7 = 7;
memcpy(state7 + STATE_OFF_VERSION, &v7, 4);
}
check(unser(state7, ssize) != 0, "v7_state_loads",
"v7-headered state accepted");
check(page_ptr[0] == 0 && page_ptr[1] == 1 && page_ptr[2] == 2
&& page_ptr[3] == 3 && page_ptr[4] == 4 && page_ptr[5] == 5,
"v7_jgd_defaults_to_reset",
"jgdPage after v7 load = {%u,%u,%u,%u,%u,%u} (expect identity)",
page_ptr[0], page_ptr[1], page_ptr[2],
page_ptr[3], page_ptr[4], page_ptr[5]);
free(state);
free(state7);
}
harness_shutdown(&cfg);
/* ============================================================
* Phase C: forced enable + 2 MB image (the "GD-locked homebrew
* smaller than the cart window" / ForceJGD path).
* ============================================================ */
if (!run_phase(&cfg, argc, argv, rom2_path, "enabled", 30))
{
fprintf(stderr, "Phase C: harness init/load failed\n");
rc = 2;
goto cleanup_files;
}
ram_ptr = (uint8_t **)harness_dlsym(&cfg, "jaguarMainRAM");
active_ptr = (uint8_t *)harness_dlsym(&cfg, "jgdActive");
if (!ram_ptr || !active_ptr)
{
fprintf(stderr, "Cannot resolve internals (phase C)\n");
harness_shutdown(&cfg);
rc = 2;
goto cleanup_files;
}
ram = *ram_ptr;
check(*active_ptr == 1, "forced_active_small_image",
"jgdActive=%u for a 2 MB image with option enabled", *active_ptr);
check(ram32(ram, R_FLAG) == MAGIC_DONE, "forced_probe_completed",
"flag=$%08X err=$%08X (expect $%08X)",
ram32(ram, R_FLAG), ram32(ram, R_ERR), MAGIC_DONE);
check(ram32(ram, R_HWVER) == EXPECT_HWVER, "forced_hw_version",
"cmd 12 -> $%08X (expect $%08X)", ram32(ram, R_HWVER), EXPECT_HWVER);
/* Banks 2-15 don't exist in a 2 MB image: their SDRAM reads zeros. */
check(ram32(ram, R_MARKERS + 0) == 0xC0DE0000u
&& ram32(ram, R_MARKERS + 4) == 0
&& ram32(ram, R_MARKERS + 20) == 0,
"forced_missing_banks_zero",
"window 0 = $%08X, windows 1/5 = $%08X/$%08X (missing banks read 0)",
ram32(ram, R_MARKERS + 0), ram32(ram, R_MARKERS + 4),
ram32(ram, R_MARKERS + 20));
check(ram32(ram, R_STRADDLE) == 0xA0A00000u, "forced_straddle",
"long at $8FFFFE = $%08X (expect $A0A00000)",
ram32(ram, R_STRADDLE));
check(ram32(ram, R_WREN) == 0x1234u, "forced_write_enable",
"write-enabled write into a missing bank reads back $%04X",
ram32(ram, R_WREN));
harness_report(&cfg, results, num_results);
if (!cfg.json_output)
printf(" %d passed, %d failed\n", pass_count, fail_count);
harness_shutdown(&cfg);
rc = fail_count > 0 ? 1 : 0;
cleanup_files:
remove(rom16_path);
remove(rom2_path);
free(rom16);
free(rom2);
return rc;
}
+217 -43
View File
@@ -8,7 +8,10 @@
* DAC i2sNonZeroCount and bumped STATE_VERSION 2 -> 3 while
* retro_unserialize still demanded an exact version match, which
* silently invalidated every state written by v2.3.0 / v2.3.1.
* 2. A state OLDER than STATE_MIN_VERSION (v1) must be refused.
* Every version a RELEASED core ever wrote is covered: 1 (v2.2.0),
* 2 (v2.3.0 / v2.3.1), 3 (v2.3.2) and the current 7 (v3.0.0).
* Issue #268.
* 2. A state OLDER than STATE_MIN_VERSION (v0) must be refused.
* 3. A state NEWER than STATE_VERSION must be refused — we cannot know
* what fields it carries.
* 4. A bad magic must be refused.
@@ -34,7 +37,16 @@
* the match trustworthy: the pattern must occur exactly once, and — since
* DAC is serialized last and retro_serialize zero-fills the tail —
* everything after the block must be zero. No 2.4 MB binary fixture is
* committed; the v2 state is synthesized at runtime from a v3 one.
* committed; the v1/v2/v3 states are synthesized at runtime from a current
* one by synth_legacy_state().
*
* That synthesis has to rebuild the CDROM chunk, not just trim fields off
* the end of the state. The CD-support work replaced the old block's two
* cdBuf2/cdBuf3 staging buffers with the BUTCH/FIFO/DSA/SSI working set,
* shrinking the chunk by 2627 bytes, and only the last 28 of those bytes
* were version-gated. A fixture built by trimming alone is not a v1/v2/v3
* state, and a test built on one reports PASS while every released state
* silently mis-parses from the CDROM chunk onward.
*
* Exit codes: 0 = pass, 1 = fail, 2 = harness error, 77 = skip (ROM absent)
*
@@ -69,7 +81,7 @@ const char *__lsan_default_suppressions(void) {
}
#endif
#define MAX_RESULTS 16
#define MAX_RESULTS 24
#define DEFAULT_ROM "test/roms/yarc.j64"
#define DEFAULT_FRAMES 120
/* Frames run between capturing the v3 state and loading the v2 fixture,
@@ -89,14 +101,35 @@ const char *__lsan_default_suppressions(void) {
* i2sNonZeroCount (uint32, 4), i2sPhase (double, 8), i2sRateRatio
* (double, 8) = 37 bytes, with i2sNonZeroCount at offset 17.
*
* STATE_VERSION_DAC_REGISTERS (v8) appends the I2S hardware registers
* themselves: *ltxd (uint16, 2), *rtxd (uint16, 2), *sclk (uint8, 1),
* *smode (uint32, 4), lrxd (uint16, 2), rrxd (uint16, 2), sstat (uint8,
* 1) = 14 more, for 51. They live in jagMemSpace at $F1A148-$F1A157,
* which no STATE_SAVE_BUF covers, so before v8 they were restored as
* whatever the previous run left behind -- see
* test/tools/test_runahead_determinism.c. The v1/v2/v3 fixture offsets
* below are unaffected: the new fields are appended, not interleaved.
*
* The size is asserted deliberately: if someone adds or reorders a DAC
* field, THIS TEST FAILS LOUDLY rather than silently splicing the wrong
* four bytes out of the fixture and "passing". To update, re-derive both
* numbers from DACStateSave's field order and sizes. */
#define EXPECTED_DAC_BLOCK_SIZE 37
#define EXPECTED_DAC_BLOCK_SIZE 51
#define DAC_I2S_NONZEROCOUNT_OFFSET 17
#define DAC_I2S_NONZEROCOUNT_SIZE 4
/* The v1 DAC block (release v2.2.0) is just bufferIndex + numberOfSamples
* + bufferDone = 9 bytes. In the v2 layout the four resampler fields
* (i2sWritePos 4, i2sWriteCount 4, i2sPhase 8, i2sRateRatio 8 = 24 bytes)
* sit contiguously right after that prefix (i2sNonZeroCount is v3+). */
#define DAC_V1_PREFIX_SIZE 9
#define DAC_I2S_RESAMPLER_SIZE 24
/* Offsets of the resampler fields inside the CURRENT block, used to build
* the expected post-v1-load block. Offsets, not sizes, so appending the
* v8 register tail does not move them. */
#define DAC_I2S_WRITEPOS_OFFSET 9 /* writePos+writeCount, 8 bytes */
#define DAC_I2S_PHASE_OFFSET 21 /* phase+ratio, 16 bytes */
/* Trailing CDROM-block fields a v2/v3 state does not carry, verified
* against the end of CDROMStateSave() in src/cd/cdrom.c:
* - STATE_VERSION_CDROM_DSA_QUEUE (v4): dsaQueue (DSA_QUEUE_SIZE=4 x
@@ -112,6 +145,26 @@ const char *__lsan_default_suppressions(void) {
* would not be unique. */
#define CDROM_DSA_TAIL_SIZE 28
/* The pre-CD-support CDROM block (STATE_VERSION_CDROM_RESTRUCTURE), i.e.
* what releases v2.2.0 / v2.3.0 / v2.3.1 / v2.3.2 wrote:
*
* cdRam 256 + cdCmd/cdPtr 4 + haveCDGoodness 1 + min/sec/frm/block 16
* + cdBuf 2448 + cdBufPtr 4 + trackNum/minTrack/maxTrack 3
* + currentState 4 + counter 2 + cmdTx 1 + busCmd 2 + rxData/txData 4
* + rxDataBit 2 + firstTime 1 = 2748
* + cdBuf2 (2532+96) + cdBuf3 (2532+96) = 5256
* total = 8004
*
* The prefix is byte-identical in every version; everything after it was
* replaced wholesale by the BUTCH/FIFO/DSA/SSI working set. A fixture that
* keeps the CURRENT tail is not a v1/v2/v3 state at all — it is 2627 bytes
* short — so synthesizing one means swapping the tail, not just trimming
* the version-gated fields off the end. */
#define CDROM_LEGACY_PREFIX_SIZE 2748
#define CDROM_LEGACY_STAGING_SIZE 5256
#define CDROM_LEGACY_BLOCK_SIZE (CDROM_LEGACY_PREFIX_SIZE \
+ CDROM_LEGACY_STAGING_SIZE)
/* Trailing v7 additions a v2..v6 state does not carry: the MT block's
* latched $80AAA8 override flag (1 byte) plus the entire NVM BIOS block
* that follows it (nvm_size, probed at runtime). Together they sit
@@ -192,6 +245,73 @@ static unsigned find_pattern(const uint8_t *hay, size_t hay_len,
return count;
}
/* Compose a genuine pre-CD-support fixture (version 1, 2 or 3) from the
* current-version state in `src`.
*
* Rebuilt piecewise rather than spliced in place because the old layout is
* both shorter (no MT override flag, no NVM block, no DSA tail, a smaller
* DAC block) and LONGER (5256 bytes of cdBuf2/cdBuf3 where the current
* block carries 2601 bytes of BUTCH/FIFO/DSA/SSI state) than the current
* one. A run of trim-only memmoves cannot express that.
*
* Writes the header version, returns the payload length, and reports where
* the DAC block landed via *dac_off_out. */
static size_t synth_legacy_state(uint8_t *dst, const uint8_t *src,
size_t state_size, size_t cdrom_start,
size_t cdrom_end, size_t v7_tail,
size_t dac_off, unsigned version,
size_t *dac_off_out)
{
size_t pos = 0;
size_t n;
/* Header through the CDROM block's version-invariant prefix. */
n = cdrom_start + CDROM_LEGACY_PREFIX_SIZE;
memcpy(dst, src, n);
pos = n;
/* cdBuf2 + cdBuf3. Their contents are irrelevant to the loader (it
* skips them by byte count); zeros are what a cartridge title's stub
* BUTCH would have held anyway. */
memset(dst + pos, 0, CDROM_LEGACY_STAGING_SIZE);
pos += CDROM_LEGACY_STAGING_SIZE;
/* Joystick + Memory Track, minus the v7 MT override flag and the whole
* NVM BIOS block that follows it. */
n = (dac_off - v7_tail) - cdrom_end;
memcpy(dst + pos, src + cdrom_end, n);
pos += n;
if (dac_off_out)
*dac_off_out = pos;
/* DAC, in the layout that version carried. */
if (version >= 3) {
memcpy(dst + pos, src + dac_off, EXPECTED_DAC_BLOCK_SIZE);
pos += EXPECTED_DAC_BLOCK_SIZE;
} else if (version == 2) {
/* Everything up to i2sNonZeroCount, then phase + rateRatio. */
memcpy(dst + pos, src + dac_off, DAC_I2S_NONZEROCOUNT_OFFSET);
pos += DAC_I2S_NONZEROCOUNT_OFFSET;
memcpy(dst + pos,
src + dac_off + DAC_I2S_NONZEROCOUNT_OFFSET
+ DAC_I2S_NONZEROCOUNT_SIZE,
EXPECTED_DAC_BLOCK_SIZE - DAC_I2S_NONZEROCOUNT_OFFSET
- DAC_I2S_NONZEROCOUNT_SIZE);
pos += EXPECTED_DAC_BLOCK_SIZE - DAC_I2S_NONZEROCOUNT_OFFSET
- DAC_I2S_NONZEROCOUNT_SIZE;
} else {
memcpy(dst + pos, src + dac_off, DAC_V1_PREFIX_SIZE);
pos += DAC_V1_PREFIX_SIZE;
}
/* retro_unserialize checks size >= STATE_SIZE, so the fixture keeps the
* full allocation with a zero tail, exactly like a real one. */
memset(dst + pos, 0, state_size - pos);
put_u32(dst, STATE_OFF_VERSION, (uint32_t)version);
return pos;
}
/* Patch the header of a copy of `src` and try to load it. */
static bool try_load_patched(unserialize_fn unser, const uint8_t *src,
size_t len, size_t off, uint32_t value,
@@ -212,9 +332,12 @@ int main(int argc, char **argv)
unserialize_fn unser;
uint32_t **fb_ptr;
int *width_ptr, *height_ptr;
uint8_t *state_v3 = NULL, *state_v2 = NULL, *scratch = NULL;
uint8_t *state_v3 = NULL, *state_v2 = NULL, *state_v1 = NULL,
*state_v3l = NULL, *scratch = NULL;
uint8_t dac_v3[256], dac_now[256], dac_expect[256], dac_post[256];
uint8_t dac_expect_v1[256];
size_t state_size, dac_size, dac_size_now, dac_off = 0;
size_t dac_off_v2 = 0;
size_t cdrom_size, joy_size, mt_size, nvm_size, cdrom_end = 0;
size_t tail_nonzero = 0, i;
unsigned matches;
@@ -292,8 +415,10 @@ int main(int argc, char **argv)
state_v3 = (uint8_t *)malloc(state_size);
state_v2 = (uint8_t *)malloc(state_size);
state_v1 = (uint8_t *)malloc(state_size);
state_v3l = (uint8_t *)malloc(state_size);
scratch = (uint8_t *)malloc(state_size);
if (!state_v3 || !state_v2 || !scratch) {
if (!state_v3 || !state_v2 || !state_v1 || !state_v3l || !scratch) {
fprintf(stderr, "Out of memory\n");
goto done;
}
@@ -386,37 +511,21 @@ int main(int argc, char **argv)
"DAC block after same-version load is byte-identical to the saved one");
/* ---- 5. Synthesize a genuine v2-layout state -------------------- */
/* Splice out i2sNonZeroCount and shift the rest of the DAC block left,
* which is exactly what a v2.3.1 build wrote, then relabel the header.
* The buffer stays STATE_SIZE bytes (only zero padding follows the DAC
* block) so it still satisfies retro_unserialize's size check. */
memcpy(state_v2, state_v3, state_size);
{
/* Higher-offset cuts first so the later offsets stay valid. */
size_t v7_tail = MEMTRACK_OVERRIDE_SIZE + nvm_size;
size_t cut = dac_off + DAC_I2S_NONZEROCOUNT_OFFSET;
size_t cut3 = dac_off - v7_tail;
size_t cut2 = cdrom_end - CDROM_DSA_TAIL_SIZE;
memmove(state_v2 + cut,
state_v2 + cut + DAC_I2S_NONZEROCOUNT_SIZE,
state_size - cut - DAC_I2S_NONZEROCOUNT_SIZE);
memset(state_v2 + state_size - DAC_I2S_NONZEROCOUNT_SIZE, 0,
DAC_I2S_NONZEROCOUNT_SIZE);
/* A v2..v6 state predates the MT override flag and the NVM BIOS
* block (see STATE_VERSION_MEMTRACK_OVERRIDE): splice both out. */
memmove(state_v2 + cut3,
state_v2 + cut3 + v7_tail,
state_size - cut3 - v7_tail);
memset(state_v2 + state_size - v7_tail, 0, v7_tail);
/* A v2/v3 CDROM block also predates the DSA queue tail (see
* STATE_VERSION_CDROM_DSA_QUEUE): splice those bytes out too. */
memmove(state_v2 + cut2,
state_v2 + cut2 + CDROM_DSA_TAIL_SIZE,
state_size - cut2 - CDROM_DSA_TAIL_SIZE);
memset(state_v2 + state_size - CDROM_DSA_TAIL_SIZE, 0,
CDROM_DSA_TAIL_SIZE);
}
put_u32(state_v2, STATE_OFF_VERSION, (uint32_t)STATE_MIN_VERSION);
/* The CDROM prefix constant has to agree with the block the current
* core writes, or every fixture below is nonsense. Assert it rather
* than trusting the arithmetic in the comment. */
check(cdrom_size > CDROM_LEGACY_PREFIX_SIZE + CDROM_DSA_TAIL_SIZE
&& CDROM_LEGACY_BLOCK_SIZE > cdrom_size,
"cdrom_legacy_constants_sane",
"current CDROM block %lu bytes, legacy %d (prefix %d + staging %d)",
(unsigned long)cdrom_size, CDROM_LEGACY_BLOCK_SIZE,
CDROM_LEGACY_PREFIX_SIZE, CDROM_LEGACY_STAGING_SIZE);
synth_legacy_state(state_v2, state_v3, state_size,
cdrom_end - cdrom_size, cdrom_end,
MEMTRACK_OVERRIDE_SIZE + nvm_size, dac_off, 2u,
&dac_off_v2);
(void)dac_off_v2;
/* What the DAC block must look like after loading that fixture: every
* field as saved, except i2sNonZeroCount which a v2 state cannot carry
@@ -438,9 +547,9 @@ int main(int argc, char **argv)
/* ---- 6. The v2 state must load (libretro.c half of the fix) ----- */
check(unser(state_v2, state_size), "v2_state_loads",
"retro_unserialize() of a v%d-layout state (pre-fix: refused "
"retro_unserialize() of a v2-layout state (pre-fix: refused "
"because the gate demanded version == %d)",
STATE_MIN_VERSION, STATE_VERSION);
STATE_VERSION);
/* ---- 7. ...with the DAC block still aligned (dac.c half) -------- */
/* This is the only assertion that catches an unconditional consume of
@@ -491,12 +600,75 @@ int main(int argc, char **argv)
MIN_NONBLACK_FRACTION * 100.0);
}
/* ---- 8b. Synthesize and load a genuine v1-layout state ---------- */
/* v1 is what release v2.2.0 wrote (the only released core that ever
* wrote version 1): the v2 layout minus the four DAC resampler fields,
* which sit contiguously after the 9-byte DAC prefix (issue #268). */
synth_legacy_state(state_v1, state_v3, state_size,
cdrom_end - cdrom_size, cdrom_end,
MEMTRACK_OVERRIDE_SIZE + nvm_size, dac_off, 1u, NULL);
/* Expected DAC block after the v1 load: the saved prefix, every
* resampler field at its DACInit() default (writePos/writeCount/
* nonZeroCount 0, phase 0.0, rateRatio 1.0 — DACPrepareFrame
* re-derives all of them at the next frame boundary). */
memcpy(dac_expect_v1, dac_v3, dac_size);
memset(dac_expect_v1 + DAC_I2S_WRITEPOS_OFFSET, 0,
DAC_I2S_PHASE_OFFSET + 8 - DAC_I2S_WRITEPOS_OFFSET);
{
double one = 1.0;
memcpy(dac_expect_v1 + DAC_I2S_PHASE_OFFSET + 8, &one, sizeof(one));
}
check(unser(state_v1, state_size), "v1_state_loads",
"retro_unserialize() of a v1-layout state (release v2.2.0's "
"format; pre-#268 fix: refused because STATE_MIN_VERSION was 2)");
check((dac_size_now = dac_save(dac_now)) == dac_size
&& memcmp(dac_now, dac_expect_v1, dac_size) == 0,
"v1_dac_block_defaulted",
"post-load DAC block matches the saved prefix with the resampler "
"fields at their init defaults (fields must not shift)");
memcpy(dac_post, dac_now, dac_size);
for (frame = 0; frame < (unsigned)DRIFT_FRAMES; frame++)
harness_step(&cfg);
dac_size_now = dac_save(dac_now);
check(dac_size_now == dac_size
&& memcmp(dac_now, dac_post, dac_size) != 0,
"core_advances_after_v1_load",
"DAC state advanced over %d frames following the v1 load",
DRIFT_FRAMES);
/* ---- 8c. v3 layout (release v2.3.2) ---------------------------- */
/* The strictest alignment assertion in the file: a v3 state carries the
* DAC block in full, so after the load every one of its bytes must
* come back byte-identical. Any mis-sized chunk anywhere upstream —
* the CDROM restructure was exactly that — shifts the DAC read and this
* fails, while retro_unserialize still returns true. */
synth_legacy_state(state_v3l, state_v3, state_size,
cdrom_end - cdrom_size, cdrom_end,
MEMTRACK_OVERRIDE_SIZE + nvm_size, dac_off, 3u, NULL);
for (frame = 0; frame < (unsigned)DRIFT_FRAMES; frame++)
harness_step(&cfg);
check(unser(state_v3l, state_size), "v3_state_loads",
"retro_unserialize() of a v3-layout state (release v2.3.2's "
"format)");
check((dac_size_now = dac_save(dac_now)) == dac_size
&& memcmp(dac_now, dac_v3, dac_size) == 0,
"v3_dac_block_realigned",
"post-load DAC block is byte-identical to the saved one -- proves "
"every chunk before it was read in the v3 layout, not the v7 one");
/* ---- 9. Rejections -------------------------------------------- */
check(try_load_patched(unser, state_v3, state_size, STATE_OFF_VERSION,
1u, scratch) == false,
"v1_state_rejected",
"version 1 is below STATE_MIN_VERSION (%d) -- the v1->v2 layout "
"change shipped in v2.3.0", STATE_MIN_VERSION);
0u, scratch) == false,
"v0_state_rejected",
"version 0 is below STATE_MIN_VERSION (%d) -- no released core "
"ever wrote it", STATE_MIN_VERSION);
check(try_load_patched(unser, state_v3, state_size, STATE_OFF_VERSION,
(uint32_t)STATE_VERSION + 1u, scratch) == false,
@@ -521,6 +693,8 @@ report:
done:
free(state_v3);
free(state_v2);
free(state_v1);
free(state_v3l);
free(scratch);
harness_shutdown(&cfg);
return rc;
Regular → Executable
+57 -24
View File
@@ -6,6 +6,9 @@ ever change rows the core previously left unwritten, and must only ever
change them to opaque black. This checks that mechanically so a 46-title
sweep does not depend on reading 46 screenshots.
VJFBDIG3 adds a per-frame overscan column-band digest (columns x >= 320).
Both inputs must be VJFBDIG3; VJFBDIG2 dumps exit 2 with a clear error.
Checks, in order of severity:
1. Geometry must match frame for frame. Any difference is a hard failure.
2. Per-frame audio digests must match. A video-window change cannot alter
@@ -13,17 +16,20 @@ Checks, in order of severity:
3. Every row whose hash differs must be >= the patched build's written-row
extent for that frame, and its patched hash must equal the all-opaque-
black hash for that width. Anything else is a hard failure.
4. Overscan band_hash must match frame for frame (unless --ignore-band).
Usage: fb_row_diff.py <stock.bin> <patched.bin> [--label NAME] [--quiet]
Usage: fb_row_diff.py <stock.bin> <patched.bin> [--label NAME] [--ignore-band]
Exit: 0 = identical or only expected differences, 1 = unexpected difference,
2 = usage/format error.
2 = usage/format error (missing, unreadable, truncated or wrong-magic
input included).
"""
import struct
import sys
MAGIC = b"VJFBDIG2"
HDR = struct.Struct("<4I") # width, height, frame_hash, written_extent
MAGIC = b"VJFBDIG3"
HDR = struct.Struct("<4I") # width, rows, frame_hash, written_extent
BAND = struct.Struct("<6I") # x0, width, hash, nonblack, first_x, first_y
NO_EXTENT = 0xFFFFFFFF
@@ -49,7 +55,7 @@ def load(path):
with open(path, "rb") as f:
blob = f.read()
if blob[:8] != MAGIC:
raise ValueError(f"{path}: bad magic {blob[:8]!r}")
raise ValueError(f"{path}: bad magic {blob[:8]!r} (need VJFBDIG3)")
nvideo, naudio = struct.unpack_from("<2I", blob, 8)
frames = []
off = 16
@@ -58,7 +64,9 @@ def load(path):
off += HDR.size
rows = struct.unpack_from(f"<{h}I", blob, off)
off += 4 * h
frames.append((w, h, fh, extent, rows))
band = BAND.unpack_from(blob, off)
off += BAND.size
frames.append((w, h, fh, extent, rows, band))
audio = struct.unpack_from(f"<{naudio}I", blob, off) if naudio else ()
return frames, list(audio)
@@ -66,12 +74,16 @@ def load(path):
def main():
argv = sys.argv[1:]
label = "run"
ignore_band = False
args = []
i = 0
while i < len(argv):
if argv[i] == "--label" and i + 1 < len(argv):
label = argv[i + 1]
i += 2
elif argv[i] == "--ignore-band":
ignore_band = True
i += 1
elif argv[i].startswith("--"):
i += 1
else:
@@ -81,28 +93,32 @@ def main():
print(__doc__)
return 2
frames_a, audio_a = load(args[0])
frames_b, audio_b = load(args[1])
try:
frames_a, audio_a = load(args[0])
frames_b, audio_b = load(args[1])
except OSError as e:
print(f"FAIL {label}: cannot read {e.filename or '?'}: "
f"{e.strerror or e}", file=sys.stderr)
return 2
except struct.error as e:
print(f"FAIL {label}: truncated or malformed digest: {e}",
file=sys.stderr)
return 2
except ValueError as e:
print(f"FAIL {label}: {e}", file=sys.stderr)
return 2
unexpected = []
changed_frames = 0
changed_rows = set()
first_changed_frame = None
band_changed_frames = 0
max_band_nonblack = 0
any_band_nonblack = False
# How many frames actually had a tail gap for the fix to act on, and how
# often the written-row extent moved. Without these, "changed_frames=0"
# is ambiguous: it cannot distinguish "this title is unaffected" from
# "this run never reached an affected state", so a wall of OKs would
# imply coverage the sweep does not have.
#
# extent_changes also bounds a class the row check cannot see: the extent
# is read at end of frame, but rows are written across the frame, so a
# title that moves VDB mid-frame could have a legitimately-written row
# blanked using the new extent. A title whose extent barely moves cannot
# be hitting that; one that churns every frame deserves a look.
gap_frames = sum(1 for (_w, h, _fh, ext, _r) in frames_b
gap_frames = sum(1 for (_w, h, _fh, ext, _r, _b) in frames_b
if ext != NO_EXTENT and ext < h)
extents = [ext for (_w, _h, _fh, ext, _r) in frames_b if ext != NO_EXTENT]
extents = [ext for (_w, _h, _fh, ext, _r, _b) in frames_b if ext != NO_EXTENT]
extent_changes = sum(1 for a_, b_ in zip(extents, extents[1:]) if a_ != b_)
if len(frames_a) != len(frames_b):
@@ -116,12 +132,26 @@ def main():
f"first at audio frame {first}"))
for n, (fa, fb_) in enumerate(zip(frames_a, frames_b)):
wa, ha, fha, _exta, rowsa = fa
wb, hb, fhb, extb, rowsb = fb_
wa, ha, fha, _exta, rowsa, banda = fa
wb, hb, fhb, extb, rowsb, bandb = fb_
if bandb[3] > max_band_nonblack:
max_band_nonblack = bandb[3]
if bandb[3] > 0:
any_band_nonblack = True
if (wa, ha) != (wb, hb):
unexpected.append((n, -1, f"geometry {wa}x{ha} -> {wb}x{hb}"))
# "rows" is the stored row count (clamped), not presented height.
unexpected.append((n, -1, f"geometry w={wa} rows={ha} -> "
f"w={wb} rows={hb}"))
continue
if banda[2] != bandb[2]:
band_changed_frames += 1
if not ignore_band:
unexpected.append((n, -1, f"overscan band_hash differs "
f"({banda[2]:08X} -> {bandb[2]:08X})"))
if fha == fhb:
continue
@@ -146,6 +176,9 @@ def main():
f"gap_frames={gap_frames} extent_changes={extent_changes} "
f"changed_frames={changed_frames} changed_rows={rows_desc} "
f"first_changed_frame={first_changed_frame} "
f"band_changed_frames={band_changed_frames} "
f"max_band_nonblack={max_band_nonblack} "
f"band_nonblack_any={int(any_band_nonblack)} "
f"unexpected={len(unexpected)}")
for frame, row, why in unexpected[:10]:
print(f" frame {frame} row {row}: {why}")
+64 -4
View File
@@ -28,14 +28,26 @@
* host -- which is the intended use, two builds on one machine. The reader's
* all-black row constant likewise assumes little-endian XRGB8888 in memory.)
*
* magic "VJFBDIG2" 8 bytes
* magic "VJFBDIG3" 8 bytes
* uint32 video_frame_count
* uint32 audio_frame_count
* video_frame_count records of:
* uint32 width, height, frame_hash, written_extent
* uint32 width, rows, frame_hash, written_extent
* (rows is the stored row count, min(presented height, MAX_ROWS=512),
* not the presented height; readers must size row_hash[] by it)
* (written_extent is 0xFFFFFFFF when the core does not export
* TOMGetWrittenRowExtent, i.e. for a pre-fix reference build)
* uint32 row_hash[height]
* uint32 row_hash[rows]
* uint32 band_x0, band_width, band_hash, band_nonblack,
* band_first_x, band_first_y
* (overscan strip: columns x >= 320, hashed over the same stored rows.
* When width > 320: band_x0=320, band_width=width-320, band_hash is
* FNV-1a over the strip, band_nonblack counts pixels whose RGB is not
* zero, and band_first_x/band_first_y are the first such pixel in
* row-major order, or 0xFFFFFFFF each when the band is all black.
* When width <= 320 the band block is skipped and the fields are
* written as band_x0=0, band_width=0, band_hash=0, band_nonblack=0,
* band_first_x=0xFFFFFFFF, band_first_y=0xFFFFFFFF.)
* audio_frame_count records of:
* uint32 audio_hash (samples, peaks, non-silent count, RMS L/R)
*/
@@ -90,6 +102,13 @@ static void on_video(void *userdata, const void *data,
uint32_t frame_hash = FNV_SEED;
unsigned row;
unsigned rows = height;
uint32_t band_x0 = 0;
uint32_t band_width = 0;
uint32_t band_hash = 0;
uint32_t band_nonblack = 0;
uint32_t band_first_x = 0xFFFFFFFFu;
uint32_t band_first_y = 0xFFFFFFFFu;
unsigned col;
if (!data || !st->out)
return;
@@ -103,12 +122,53 @@ static void on_video(void *userdata, const void *data,
frame_hash = fnv1a(&row_hash[row], sizeof(uint32_t), frame_hash);
}
/* Overscan / border strip: columns at and past the 320-wide active area.
* AvP presents ~326 wide; every prior check folded these into row hashes. */
if (width > 320)
{
band_x0 = 320;
band_width = width - 320;
band_hash = FNV_SEED;
for (row = 0; row < rows; row++)
{
const uint8_t *line = (const uint8_t *)data + (size_t)row * pitch;
uint32_t pix;
band_hash = fnv1a(line + (size_t)band_x0 * 4,
(size_t)band_width * 4, band_hash);
for (col = band_x0; col < width; col++)
{
/* memcpy, not a byte-wise OR: pitch is a byte stride with no
* 4-byte-multiple guarantee, so casting line to uint32_t* and
* indexing it is UB on strict-alignment hosts. Reassembling
* the pixel by hand would instead pick the wrong three bytes
* on a big-endian host, where XRGB8888-as-uint32 puts RGB at
* bytes 1..3 rather than 0..2. */
memcpy(&pix, line + (size_t)col * 4, sizeof(pix));
if ((pix & 0x00FFFFFFu) != 0)
{
band_nonblack++;
if (band_first_x == 0xFFFFFFFFu)
{
band_first_x = col;
band_first_y = row;
}
}
}
}
}
put_u32(st->out, width);
put_u32(st->out, rows);
put_u32(st->out, frame_hash);
put_u32(st->out, st->get_extent ? st->get_extent() : 0xFFFFFFFFu);
for (row = 0; row < rows; row++)
put_u32(st->out, row_hash[row]);
put_u32(st->out, band_x0);
put_u32(st->out, band_width);
put_u32(st->out, band_hash);
put_u32(st->out, band_nonblack);
put_u32(st->out, band_first_x);
put_u32(st->out, band_first_y);
st->frames_written++;
}
@@ -158,7 +218,7 @@ int main(int argc, char **argv)
cfg.video_callback = on_video;
cfg.video_callback_data = &st;
fwrite("VJFBDIG2", 1, 8, st.out);
fwrite("VJFBDIG3", 1, 8, st.out);
put_u32(st.out, 0); /* video frame count, patched below */
put_u32(st.out, 0); /* audio frame count, patched below */
+223
View File
@@ -0,0 +1,223 @@
/*
* fmv_seek_probe.c -- per-field FMV presentation-clock + CD seek probe (#297)
*
* Diagnostic harness for the FMV scene-jump investigation. Emits one CSV
* row per emulated field with:
* - the ReadySoft presentation clock (48-bit fixed point; 16-bit integer
* at $562E, 32-bit fraction at $5630 -- Space Ace is +512 bytes)
* - CD diagnostic counters (seeks, fifoReads, fifoDrains, dsaIRQs)
* - HLE stream state (arm count, active, bytes, dest)
*
* Config via environment (keeps harness CLI parsing untouched):
* FMV_CSV=<path> CSV output path (default stdout)
* FMV_CLOCK=<hex> clock base address (default 562E; Space Ace 582E)
*
* Build:
* cc -O2 -Wall -std=c99 -I./test -I./libretro-common/include \
* -o test/tools/fmv_seek_probe test/tools/fmv_seek_probe.c \
* test/harness/harness.c -ldl -lm
*/
#include "harness/harness.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef void (*counters_fn)(uint32_t *, uint32_t *, uint32_t *, uint32_t *,
uint32_t *, uint32_t *, uint32_t *);
typedef void (*wedge_fn)(uint32_t *, uint32_t *, uint32_t *);
typedef uint32_t (*u32_fn)(void);
typedef bool (*bool_fn)(void);
typedef struct
{
FILE *csv;
uint8_t **ramp;
counters_fn counters;
wedge_fn wedge;
u32_fn hleArm;
bool_fn hleActive;
u32_fn hleBytes;
u32_fn hleDest;
uint32_t clockAddr;
uint32_t prevSeekStarts;
uint32_t prevArm;
uint32_t prevClockInt;
/* Optional PPM capture window: FMV_SHOT_FROM/TO/EVERY + FMV_SHOTDIR */
const char *shotDir;
unsigned shotFrom;
unsigned shotTo;
unsigned shotEvery;
unsigned curFrame;
harness_config *cfg;
} probe_state;
static void VideoHook(void *userdata, const void *data, unsigned width,
unsigned height, size_t pitch)
{
probe_state *st = (probe_state *)userdata;
const uint32_t *px;
char path[512];
FILE *f;
unsigned x, y;
int n;
st->curFrame = st->cfg ? st->cfg->current_frame : 0;
if (!st->shotDir || !data || width == 0 || height == 0)
return;
if (st->curFrame < st->shotFrom || st->curFrame > st->shotTo)
return;
if (st->shotEvery && (st->curFrame - st->shotFrom) % st->shotEvery)
return;
/* FMV_SHOTDIR is environment-controlled, so cap the write rather than
* trusting the caller's path length. */
n = snprintf(path, sizeof(path), "%s/f_%05u.ppm", st->shotDir,
st->curFrame);
if (n < 0 || (size_t)n >= sizeof(path))
return;
f = fopen(path, "wb");
if (!f)
return;
fprintf(f, "P6\n%u %u\n255\n", width, height);
for (y = 0; y < height; y++)
{
px = (const uint32_t *)((const uint8_t *)data + y * pitch);
for (x = 0; x < width; x++)
{
uint32_t p = px[x];
fputc((int)((p >> 16) & 0xFF), f);
fputc((int)((p >> 8) & 0xFF), f);
fputc((int)(p & 0xFF), f);
}
}
fclose(f);
}
static uint32_t RamRead32(uint8_t *ram, uint32_t addr)
{
return ((uint32_t)ram[addr] << 24) | ((uint32_t)ram[addr + 1] << 16)
| ((uint32_t)ram[addr + 2] << 8) | (uint32_t)ram[addr + 3];
}
static uint32_t RamRead16(uint8_t *ram, uint32_t addr)
{
return ((uint32_t)ram[addr] << 8) | (uint32_t)ram[addr + 1];
}
static bool FrameHook(void *userdata, unsigned frame)
{
probe_state *st = (probe_state *)userdata;
uint8_t *ram;
uint32_t butchExec = 0, fifoIRQs = 0, dsaIRQs = 0, fifoReads = 0;
uint32_t seeks = 0, globalDisabled = 0, hleBytesTotal = 0;
uint32_t seekStarts = 0, seekDones = 0, fifoDrains = 0;
uint32_t clkInt = 0, clkFrac = 0;
uint32_t arm = 0, bytes = 0, dest = 0;
int active = 0;
st->curFrame = frame;
ram = st->ramp ? *st->ramp : NULL;
if (st->counters)
st->counters(&butchExec, &fifoIRQs, &dsaIRQs, &fifoReads, &seeks,
&globalDisabled, &hleBytesTotal);
if (st->wedge)
st->wedge(&seekStarts, &seekDones, &fifoDrains);
if (ram)
{
clkInt = RamRead16(ram, st->clockAddr);
clkFrac = RamRead32(ram, st->clockAddr + 2);
}
if (st->hleArm) arm = st->hleArm();
if (st->hleActive) active = st->hleActive() ? 1 : 0;
if (st->hleBytes) bytes = st->hleBytes();
if (st->hleDest) dest = st->hleDest();
fprintf(st->csv,
"%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n",
frame, butchExec, clkInt, clkFrac, seeks, seekStarts, seekDones,
fifoDrains, fifoReads, dsaIRQs, arm, (unsigned)active, bytes, dest);
st->prevSeekStarts = seekStarts;
st->prevArm = arm;
st->prevClockInt = clkInt;
return true;
}
int main(int argc, char **argv)
{
harness_config cfg = HARNESS_CONFIG_DEFAULT;
probe_state st;
const char *csvPath;
const char *clockEnv;
harness_result res;
memset(&st, 0, sizeof(st));
st.clockAddr = 0x562E;
cfg.frames = 2000;
cfg.quiet = 1;
cfg.system_dir = "test/roms/private";
if (!harness_init_from_args(&cfg, argc, argv))
return 1;
clockEnv = getenv("FMV_CLOCK");
if (clockEnv && clockEnv[0])
st.clockAddr = (uint32_t)strtoul(clockEnv, NULL, 16);
csvPath = getenv("FMV_CSV");
st.csv = (csvPath && csvPath[0]) ? fopen(csvPath, "w") : stdout;
if (!st.csv)
{
fprintf(stderr, "fmv_seek_probe: cannot open CSV '%s'\n", csvPath);
return 1;
}
if (!harness_load_rom(&cfg))
return 1;
st.ramp = (uint8_t **)harness_dlsym(&cfg, "jaguarMainRAM");
st.counters = (counters_fn)harness_dlsym(&cfg, "CDROMDiagGetCounters");
st.wedge = (wedge_fn)harness_dlsym(&cfg, "CDROMDiagGetSeekWedgeState");
st.hleArm = (u32_fn)harness_dlsym(&cfg, "JaguarCDHLEStreamArmCount");
st.hleActive = (bool_fn)harness_dlsym(&cfg, "JaguarCDHLEStreamActive");
st.hleBytes = (u32_fn)harness_dlsym(&cfg, "JaguarCDHLEStreamBytes");
st.hleDest = (u32_fn)harness_dlsym(&cfg, "JaguarCDHLEStreamDest");
fprintf(st.csv,
"field,butch,clkint,clkfrac,seeks,seekstarts,seekdones,"
"fifodrains,fiforeads,dsairqs,hlearm,hleactive,hlebytes,hledest\n");
st.cfg = &cfg;
st.shotDir = getenv("FMV_SHOTDIR");
st.shotFrom = (unsigned)(getenv("FMV_SHOT_FROM")
? strtoul(getenv("FMV_SHOT_FROM"), NULL, 10) : 0);
st.shotTo = (unsigned)(getenv("FMV_SHOT_TO")
? strtoul(getenv("FMV_SHOT_TO"), NULL, 10) : 0);
st.shotEvery = (unsigned)(getenv("FMV_SHOT_EVERY")
? strtoul(getenv("FMV_SHOT_EVERY"), NULL, 10) : 1);
cfg.frame_callback = FrameHook;
cfg.frame_callback_data = &st;
if (st.shotDir)
{
cfg.video_callback = VideoHook;
cfg.video_callback_data = &st;
}
harness_run(&cfg);
if (st.csv != stdout)
fclose(st.csv);
res.status = "INFO";
res.name = "fmv_seek_probe";
res.detail = "per-field CSV written";
harness_report(&cfg, &res, 1);
harness_shutdown(&cfg);
return 0;
}
+13
View File
@@ -238,6 +238,7 @@ int main(int argc, char **argv)
{
long long t0, t1;
uint32_t rx0, rx1;
long frames = 0;
double rate, elapsed;
jerry_ww(0xF10030, 0x0040, 0); /* first byte starts the chain */
@@ -255,7 +256,10 @@ int main(int argc, char **argv)
rx0 = rx_total();
t0 = now_usec();
while (now_usec() - t0 < (long long)measure_sec * 1000000LL)
{
paced_frame(run_frame);
frames++;
}
t1 = now_usec();
rx1 = rx_total();
@@ -263,6 +267,15 @@ int main(int argc, char **argv)
rate = (double)(rx1 - rx0) / elapsed;
printf("[probe] %.1f exchanges/sec (%u exchanges in %.2f s, "
"wait=%s)\n", rate, rx1 - rx0, elapsed, wait_opt);
/* Pacing telemetry: how close the 60 fps frame slots came to
wall clock during the window. ~60 = the runner kept pace
(an under-ceiling exchange rate is then the core's doing);
well under 60 = frames overran, the runner itself was the
bottleneck. netlink_latency_test.sh uses this to decide
FAIL vs SKIP when the enabled rate lands under the frame
ceiling. */
printf("[probe] %.1f frames/sec paced (%ld frames)\n",
(double)frames / elapsed, frames);
harness_shutdown(&cfg);
if (min_rate >= 0.0 && rate < min_rate)
+101 -31
View File
@@ -8,10 +8,15 @@
# enabled (default): the adaptive wait rides out the RTT inside the
# frame and sustains a multiple of the quantized rate.
#
# CI runners vary widely in speed, so the assertion is a RATIO —
# enabled must beat disabled by >= 1.5x (measured ~2x locally, ~2.7x on
# GH Actions macOS) — plus an absolute ceiling on the control proving
# the injected delay actually quantized it.
# The assertions are ABSOLUTE, anchored to the 60 fps frame ceiling the
# bug imposes (issue #310): the probe paces retro_run at 60 fps, so a
# quantized control physically cannot exceed ~60 exchanges/sec, while
# the fixed path is uncapped and beats it. A ratio (the old >= 1.5x
# check) compresses under machine load — both rates fall together and
# the test flaked on loaded runners and in CI with the netlink code
# unmodified. When load drags even the enabled rate under the ceiling
# the effect cannot be demonstrated either way, and the test SKIPs
# loudly (suite skip ledger) instead of failing.
#
# Usage: netlink_latency_test.sh <core>
set -u
@@ -31,11 +36,13 @@ trap cleanup EXIT
trap 'cleanup; trap - EXIT; exit 130' INT TERM
# run_case <label> <core_port> <proxy_port> <wait>
# Prints the measured exchanges/sec on stdout; returns nonzero on error.
# Prints "<exchanges/sec> <frames/sec>" on stdout; returns nonzero on
# error. frames/sec is the probe's achieved pacing rate — the load
# witness (0.0 if the probe binary predates the telemetry line).
run_case()
{
local label="$1" core_port="$2" proxy_port="$3" wait_opt="$4"
local probe_log rate
local probe_log rate fps
probe_log="$(mktemp "${TMPDIR:-/tmp}/vj_nl_lat_XXXXXX.log")"
"$TOOLS/netlink_delay_proxy" --listen "$proxy_port" \
@@ -54,41 +61,104 @@ run_case()
kill "$PROXY_PID" 2>/dev/null
wait "$PROXY_PID" 2>/dev/null
PROXY_PID=""
grep -E "exchanges/sec|FAIL" "$probe_log" | sed "s/^/[$label] /" >&2
grep -E "exchanges/sec|frames/sec|FAIL" "$probe_log" | sed "s/^/[$label] /" >&2
rate="$(sed -n 's/.*\] \([0-9.]*\) exchanges\/sec.*/\1/p' "$probe_log" | head -1)"
fps="$(sed -n 's/.*\] \([0-9.]*\) frames\/sec.*/\1/p' "$probe_log" | head -1)"
rm -f "$probe_log"
[ "$rv" -ne 0 ] && return "$rv"
[ -z "$rate" ] && return 1
echo "$rate"
echo "$rate ${fps:-0.0}"
return 0
}
rate_off="$(run_case "control disabled" 42751 42761 disabled)" || {
echo "netlink_latency_test: FAIL (control case errored)"; exit 1; }
rate_on="$(run_case "fixed enabled" 42752 42762 enabled)" || {
echo "netlink_latency_test: FAIL (enabled case errored)"; exit 1; }
# skip <reason> — loud skip: prints in-context AND records in the suite
# skip ledger (scripts/test-skip.sh) when run from the repo, so an
# aborted-suite run can never masquerade as "nothing skipped".
ROOT="$(cd "$TOOLS/../.." && pwd)"
skip()
{
echo "netlink_latency_test: SKIP ($*)"
if [ -f "$ROOT/scripts/test-skip.sh" ]; then
bash "$ROOT/scripts/test-skip.sh" record \
"Netlink latency (frame quantization)" "$*"
fi
exit 0
}
# Frame quantization needs emulation to run much faster than real time:
# a frame's 68K spin then burns out in ~1 ms of wall clock and the
# delayed reply always slips to the next retro_run. On heavily
# instrumented builds (ASan, gcov) run_frame is SLOWER than real time,
# the spin itself spans the network delay, and the control never
# quantizes (seen: 110/s on the ASan runner). The bug physically
# cannot manifest there, so the test skips rather than asserting.
slow=$(awk -v off="$rate_off" 'BEGIN { print (off > 65) ? "yes" : "no" }')
out_off="$(run_case "control disabled" 42751 42761 disabled)" || {
echo "netlink_latency_test: FAIL (control case errored)"; exit 1; }
out_on="$(run_case "fixed enabled" 42752 42762 enabled)" || {
echo "netlink_latency_test: FAIL (enabled case errored)"; exit 1; }
read -r rate_off fps_off <<<"$out_off"
read -r rate_on fps_on <<<"$out_on"
# Chain sanity: something actually ping-ponged in the control case.
ok=$(awk -v off="$rate_off" 'BEGIN { print (off >= 5) ? "yes" : "no" }')
if [ "$ok" != "yes" ]; then
echo "netlink_latency_test: FAIL (control=$rate_off/s — exchange chain" \
"never ran; proxy/link problem, not a quantization verdict)"
exit 1
fi
# The verdict is anchored to the 60/s frame ceiling. The probe paces
# retro_run at 60 fps, and without the reply wait every delayed reply
# slips to the next frame — so a genuinely quantized control CANNOT
# exceed ~60 exchanges/sec no matter how fast the host is, and the
# fixed path proves itself by beating that ceiling. Both claims are
# absolute: proportional machine-load slowdown moves both rates DOWN,
# never across these thresholds in the failing direction.
FRAME_CEILING=60
# Skip 1: control never quantized. Frame quantization needs emulation
# to run much faster than real time: a frame's 68K spin then burns out
# in ~1 ms of wall clock and the delayed reply always slips to the next
# retro_run. On heavily instrumented builds (ASan, gcov) run_frame is
# SLOWER than real time, the spin itself spans the network delay, and
# the control never quantizes (seen: 110/s on the ASan runner). The
# bug physically cannot manifest there.
slow=$(awk -v off="$rate_off" -v c="$FRAME_CEILING" \
'BEGIN { print (off > c) ? "yes" : "no" }')
if [ "$slow" = "yes" ]; then
echo "netlink_latency_test: SKIP (control=$rate_off/s never quantized —" \
"runner emulates slower than real time, cannot exhibit the bug)"
skip "control=$rate_off/s never quantized — runner emulates slower" \
"than real time, cannot exhibit the bug"
fi
# Control was quantized (off <= 60). Fixed path must beat the ceiling.
fast=$(awk -v on="$rate_on" -v c="$FRAME_CEILING" \
'BEGIN { print (on > c) ? "yes" : "no" }')
if [ "$fast" = "yes" ]; then
echo "netlink_latency_test: PASS (disabled=$rate_off quantized <=" \
"$FRAME_CEILING/s, enabled=$rate_on beat the frame ceiling)"
exit 0
fi
# Control sanity: the chain ran, and the injected delay quantized it
# (paced 60 fps probe can't exceed ~60/s when every reply slips a frame).
ok=$(awk -v off="$rate_off" -v on="$rate_on" 'BEGIN {
print (off >= 5 && on >= 1.5 * off) ? "yes" : "no" }')
if [ "$ok" != "yes" ]; then
echo "netlink_latency_test: FAIL (disabled=$rate_off enabled=$rate_on;" \
"need disabled >= 5 and enabled >= 1.5x disabled)"
exit 1
# Enabled landed under the ceiling too. Two causes look alike in the
# exchange rates alone — a broken reply wait (enabled quantizes just
# like the control) or a runner too loaded for the uncapped path to
# clear 60/s — and the rates cannot disambiguate them: quantized-run
# variance (~40-57/s observed) swamps any lift threshold. So FAIL
# only on positive evidence of quantization in the enabled case,
# using the probe's pacing telemetry:
#
# quantized identity: a quantized run does <= 1 exchange per paced
# frame (the control shows this exactly: e.g. 105 exchanges in 105
# frames). rate_on <= 1.05 * fps_on means the wait bought nothing.
#
# pacing held: fps_on >= 45 means the probe kept a near-normal frame
# cadence (idle macOS paces ~52-55 due to usleep overhead), so the
# core HAD its frame slots and still never beat one exchange per
# frame — that is the bug. A load-crushed runner (CI flake: rates
# 21.9/29.5 imply ~30 fps) fails this and SKIPs loudly instead:
# it cannot demonstrate the effect in either direction.
quant=$(awk -v on="$rate_on" -v fps="$fps_on" 'BEGIN {
print (fps >= 45 && on <= 1.05 * fps) ? "yes" : "no" }')
if [ "$quant" != "yes" ]; then
skip "disabled=$rate_off enabled=$rate_on (paced $fps_on fps) —" \
"under the $FRAME_CEILING/s ceiling but not demonstrably" \
"quantized; runner too slow/loaded to demonstrate the effect"
fi
echo "netlink_latency_test: PASS (disabled=$rate_off enabled=$rate_on)"
echo "netlink_latency_test: FAIL (enabled=$rate_on <= 1 exchange per" \
"paced frame at $fps_on fps, same frame quantization as the" \
"control ($rate_off) — reply wait is not defeating receive-side" \
"frame quantization)"
exit 1
+403
View File
@@ -0,0 +1,403 @@
/*
* test/tools/overscan_probe.c -- overscan-band diagnostic (issue #266).
*
* Alien vs Predator presents a 326-column framebuffer while its picture is
* 320 pixels wide, so the rightmost columns show OP line-buffer content the
* game parked outside its own picture. This tool measures that band.
*
* 1. COLOUR CENSUS over x >= 320. A full census, not "first non-black
* pixel in row-major order" -- the latter under-reports whenever an
* earlier column in the band is also non-black.
*
* 2. POKE PERSISTENCE (--poke FRAME). Stamps the whole band with a
* sentinel colour after frame FRAME is presented and reports how many
* sentinel pixels survive. Survivors mean nothing rewrites the band
* (a stale-buffer bug); a wipe means the band is re-rendered every
* frame (a rendering / cropping bug).
*
* 3. RAM-SOURCE CHECK. For AvP the band is a 1-bpp object at XPOS=320
* whose DATA points at main RAM $000000, 32 bytes per display row,
* drawn through CLUT[1]. Verifies pixel-for-pixel that display
* columns 323/324/325 on row r equal bits 7/6/5 of mainRAM[r * 32].
*
* 4. TAILRISK line. Reports startPos (the number of border columns the
* scanline renderer prepends) and how much non-black content sits in
* the same number of columns at the right edge -- i.e. what a
* symmetric border-fill fix would erase for this title.
*
* Build:
* cc -O2 -Wall -std=c99 -I. -I./libretro-common/include \
* -o test/tools/overscan_probe test/tools/overscan_probe.c \
* test/harness/harness.c -ldl -lm
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include "../harness/harness.h"
#define BAND_X0 320
#define MAX_W 1024
#define MAX_H 512
#define GREEN 0xFF00FC38u
#define SENTINEL 0xFFFF00FFu
#define MAX_CENSUS 24
#define LEFT_HC 188 /* DEFAULT_LEFT_VISIBLE_HC, NTSC */
/* TOM register offsets (see src/tom/tom.c). */
#define R_VMODE 0x28
#define R_BORD1 0x2A
#define R_BORD2 0x2C
#define R_HP 0x2E
#define R_HBB 0x30
#define R_HBE 0x32
#define R_HDB1 0x38
#define R_HDE 0x3C
#define R_VP 0x3E
#define R_VDB 0x46
#define R_VDE 0x48
#define R_BG 0x58
typedef struct {
uint32_t colour;
uint32_t count;
} census_entry;
typedef struct {
unsigned width, height;
uint8_t *tomRam8;
uint32_t **videoBuffer;
uint8_t **mainRAM;
int *game_width;
int *game_height;
unsigned poke_frame;
unsigned dump_frame;
unsigned first_green_frame;
int poked;
unsigned frame;
unsigned green_count;
unsigned sentinel_count;
unsigned band_cols[MAX_W];
unsigned green_rows_min, green_rows_max;
census_entry census[MAX_CENSUS];
unsigned census_n;
int verbose_dump;
uint8_t green_map[MAX_H][8];
uint32_t *fb_last;
} probe;
static uint32_t px(const uint8_t *line, unsigned x)
{
uint32_t v;
memcpy(&v, line + (size_t)x * 4, 4);
return v;
}
static uint16_t reg(const probe *p, unsigned off)
{
if (!p->tomRam8)
return 0;
return (uint16_t)((p->tomRam8[off] << 8) | p->tomRam8[off + 1]);
}
static void census_add(probe *p, uint32_t colour)
{
unsigned i;
for (i = 0; i < p->census_n; i++)
{
if (p->census[i].colour == colour)
{
p->census[i].count++;
return;
}
}
if (p->census_n < MAX_CENSUS)
{
p->census[p->census_n].colour = colour;
p->census[p->census_n].count = 1;
p->census_n++;
}
}
static void on_video(void *ud, const void *data, unsigned width,
unsigned height, size_t pitch)
{
probe *p = (probe *)ud;
const uint8_t *base = (const uint8_t *)data;
unsigned y, x;
if (!data || width == 0 || height == 0)
return;
p->width = width;
p->height = height;
p->green_count = 0;
p->sentinel_count = 0;
p->census_n = 0;
p->green_rows_min = 0xFFFFFFFFu;
p->green_rows_max = 0;
memset(p->band_cols, 0, sizeof(p->band_cols));
memset(p->green_map, 0, sizeof(p->green_map));
if (!p->fb_last)
p->fb_last = (uint32_t *)malloc((size_t)MAX_W * MAX_H * 4);
if (p->fb_last && width <= MAX_W && height <= MAX_H)
{
for (y = 0; y < height; y++)
memcpy(p->fb_last + (size_t)y * width,
base + (size_t)y * pitch, (size_t)width * 4);
}
if (width <= BAND_X0)
return;
for (y = 0; y < height; y++)
{
const uint8_t *line = base + (size_t)y * pitch;
for (x = BAND_X0; x < width && x < MAX_W; x++)
{
uint32_t v = px(line, x);
census_add(p, v);
if (v == GREEN)
{
p->green_count++;
p->band_cols[x]++;
if (y < MAX_H && x - BAND_X0 < 8)
p->green_map[y][x - BAND_X0] = 1;
if (y < p->green_rows_min)
p->green_rows_min = y;
if (y > p->green_rows_max)
p->green_rows_max = y;
}
if (v == SENTINEL)
p->sentinel_count++;
}
}
}
/* Display columns 323/324/325 must equal bits 7/6/5 of mainRAM[row * 32]
* if the band really is the parked 1-bpp object reading low main RAM. */
static void verify_ram_source(const probe *p)
{
unsigned r, k, match = 0, mismatch = 0, predicted = 0, observed = 0;
const uint8_t *ram;
if (!p->mainRAM || !*p->mainRAM)
return;
ram = *p->mainRAM;
for (r = 0; r < p->height && r < MAX_H; r++)
{
for (k = 0; k < 3; k++)
{
unsigned bit = (ram[(r * 32) & 0x1FFFFF] >> (7 - k)) & 1;
unsigned got = p->green_map[r][3 + k];
predicted += bit;
observed += got;
if (bit == got)
match++;
else
mismatch++;
}
}
printf(" RAM-source check (mainRAM[row*32] bits 7,6,5 -> x=323,324,325):"
" match=%u mismatch=%u predicted=%u observed=%u\n",
match, mismatch, predicted, observed);
printf(" mainRAM[32*32=$400]=$%02X (bit6=%u -> x=324,y=32)\n",
ram[0x400], (unsigned)((ram[0x400] >> 6) & 1));
}
static void dump_regs(const probe *p, const char *tag)
{
uint16_t b1 = reg(p, R_BORD1), b2 = reg(p, R_BORD2);
printf(" [%s] VMODE=%04X BORD1=%04X BORD2=%04X BG=%04X "
"HP=%u HBB=%u HBE=%u HDB1=%u HDE=%u VP=%u VDB=%u VDE=%u\n",
tag, reg(p, R_VMODE), b1, b2, reg(p, R_BG), reg(p, R_HP),
reg(p, R_HBB), reg(p, R_HBE), reg(p, R_HDB1), reg(p, R_HDE),
reg(p, R_VP), reg(p, R_VDB), reg(p, R_VDE));
/* tom.c border fill: g = tomRam8[BORD1], r = tomRam8[BORD1+1],
* b = tomRam8[BORD2+1] */
printf(" [%s] border pixel = 0xFF%02X%02X%02X\n",
tag, b1 & 0xFF, (b1 >> 8) & 0xFF, b2 & 0xFF);
}
static void print_census(const probe *p)
{
unsigned i, x;
printf(" band colour census (x>=%d, %u cols x %u rows):\n",
BAND_X0, p->width > BAND_X0 ? p->width - BAND_X0 : 0, p->height);
for (i = 0; i < p->census_n; i++)
printf(" 0x%08X x%u\n", p->census[i].colour, p->census[i].count);
printf(" green per column:");
for (x = BAND_X0; x < p->width && x < MAX_W; x++)
printf(" [%u]=%u", x, p->band_cols[x]);
printf("\n");
if (p->green_count)
printf(" green rows %u..%u, total %u\n",
p->green_rows_min, p->green_rows_max, p->green_count);
}
static bool on_frame(void *ud, unsigned frame)
{
probe *p = (probe *)ud;
p->frame = frame;
/* Recorded here, not in on_video(): the video callback runs before this
* one, so the frame index it would see is the previous frame's. */
if (p->green_count && p->first_green_frame == 0xFFFFFFFFu)
p->first_green_frame = frame;
if (p->verbose_dump || frame == p->dump_frame
|| (p->green_count && p->first_green_frame == frame))
{
printf("frame %u: %ux%u green=%u sentinel=%u\n",
frame, p->width, p->height, p->green_count, p->sentinel_count);
print_census(p);
dump_regs(p, "regs");
if (p->tomRam8)
{
unsigned k;
printf(" CLUT[0..7]:");
for (k = 0; k < 8; k++)
printf(" %u=%04X", k,
(unsigned)((p->tomRam8[0x400 + k * 2] << 8)
| p->tomRam8[0x400 + k * 2 + 1]));
printf("\n");
}
verify_ram_source(p);
}
/* If the emulator rewrites the band every frame the sentinel is gone
* next frame; if it survives, nothing writes there. */
if (p->poke_frame != 0xFFFFFFFFu && frame == p->poke_frame
&& p->videoBuffer && *p->videoBuffer
&& p->game_width && p->game_height
&& *p->game_width > BAND_X0 && *p->game_width <= MAX_W
&& *p->game_height > 0 && *p->game_height <= MAX_H)
{
unsigned y, x;
unsigned w = (unsigned)*p->game_width;
unsigned h = (unsigned)*p->game_height;
uint32_t *vb = *p->videoBuffer;
unsigned pre_match = 0, pre_total = 0;
/* Prove the poke targets the buffer that was just presented: the
* band must currently hold exactly what the video callback saw.
* Without this, a poke into an unused buffer is indistinguishable
* from "the band is rewritten every frame". */
for (y = 0; y < h; y++)
for (x = BAND_X0; x < w; x++)
{
pre_total++;
if (p->fb_last
&& vb[(size_t)y * w + x] == p->fb_last[(size_t)y * w + x])
pre_match++;
}
for (y = 0; y < h; y++)
for (x = BAND_X0; x < w; x++)
vb[(size_t)y * w + x] = SENTINEL;
p->poked = 1;
printf("frame %u: POKE-TARGET CHECK videoBuffer==presented for %u/%u "
"band pixels\n", frame, pre_match, pre_total);
printf("frame %u: POKED band x=%u..%u y=0..%u with 0x%08X\n",
frame, BAND_X0, w - 1, h - 1, SENTINEL);
if (w > 324 && h > 32)
printf("frame %u: readback[y=32][x=324]=0x%08X\n",
frame, vb[(size_t)32 * w + 324]);
}
if (p->poked && frame > p->poke_frame && frame <= p->poke_frame + 5)
printf("frame %u: post-poke sentinel=%u green=%u\n",
frame, p->sentinel_count, p->green_count);
return true;
}
/* How many columns of line-buffer content sit past the game's picture, and
* how much of it is non-black -- the blast radius of a symmetric-border fix. */
static void report_tail_risk(const probe *p, const char *rom)
{
uint16_t vmode = reg(p, R_VMODE);
unsigned pwidth = ((vmode & 0x0E00) >> 9) + 1;
unsigned scale = (pwidth >= 8) ? (pwidth / 4) : 1;
int hdb1 = (int)reg(p, R_HDB1);
int sp = (hdb1 - LEFT_HC) / (int)pwidth;
unsigned spd = (sp > 0) ? (unsigned)sp * scale : 0;
unsigned tail_nonblack = 0, y, x;
if (p->fb_last && spd > 0 && p->width > spd)
{
for (y = 0; y < p->height; y++)
for (x = p->width - spd; x < p->width; x++)
if (p->fb_last[(size_t)y * p->width + x] & 0x00FFFFFF)
tail_nonblack++;
}
printf("TAILRISK rom=%s w=%u h=%u pwidth=%u hdb1=%d startPos=%d "
"tailcols=%u tail_nonblack=%u green=%u\n",
rom ? rom : "?", p->width, p->height, pwidth, hdb1, sp, spd,
tail_nonblack, p->green_count);
}
int main(int argc, char **argv)
{
harness_config cfg = HARNESS_CONFIG_DEFAULT;
probe p;
int i;
memset(&p, 0, sizeof(p));
p.poke_frame = 0xFFFFFFFFu;
p.dump_frame = 0xFFFFFFFFu;
p.first_green_frame = 0xFFFFFFFFu;
for (i = 1; i < argc; i++)
{
if (!strcmp(argv[i], "--poke") && i + 1 < argc)
p.poke_frame = (unsigned)strtoul(argv[++i], NULL, 10);
else if (!strcmp(argv[i], "--dump") && i + 1 < argc)
p.dump_frame = (unsigned)strtoul(argv[++i], NULL, 10);
else if (!strcmp(argv[i], "--verbose-dump"))
p.verbose_dump = 1;
}
cfg.frames = 400;
if (!harness_init_from_args(&cfg, argc, argv))
return 1;
cfg.video_callback = on_video;
cfg.video_callback_data = &p;
cfg.frame_callback = on_frame;
cfg.frame_callback_data = &p;
if (!harness_load_rom(&cfg))
return 1;
p.tomRam8 = (uint8_t *)harness_dlsym(&cfg, "tomRam8");
p.videoBuffer = (uint32_t **)harness_dlsym(&cfg, "videoBuffer");
p.mainRAM = (uint8_t **)harness_dlsym(&cfg, "jaguarMainRAM");
p.game_width = (int *)harness_dlsym(&cfg, "game_width");
p.game_height = (int *)harness_dlsym(&cfg, "game_height");
harness_run(&cfg);
report_tail_risk(&p, cfg.rom_path);
printf("\n=== summary ===\n");
printf("first green frame: %u\n", p.first_green_frame);
printf("final frame %u: green=%u sentinel=%u\n",
p.frame, p.green_count, p.sentinel_count);
print_census(&p);
dump_regs(&p, "final");
verify_ram_source(&p);
free(p.fb_last);
harness_shutdown(&cfg);
return 0;
}
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Expand test/fixtures/avp_reach_gameplay.press into cd_visual_verify --press
# args and enforce a mechanical gameplay gate.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
PRESS_FILE=${PRESS_FILE:-"$ROOT/test/fixtures/avp_reach_gameplay.press"}
VERIFY=${VERIFY:-"$ROOT/test/tools/cd_visual_verify"}
AVP=${AVP:-"$ROOT/test/roms/private/ROMS/Alien vs Predator (1994).jag"}
# cd_visual_verify defaults system_dir to the CWD-relative "test/roms/private",
# so pass an absolute one to keep this script runnable from anywhere. It is
# placed before "$@" and the harness takes the last --system-dir it sees, so a
# caller-supplied --system-dir still wins.
SYSTEM_DIR=${SYSTEM_DIR:-"$ROOT/test/roms/private"}
FRAMES=${FRAMES:-3000}
OUTDIR=${OUTDIR:-/tmp/avp_fixture_$$}
CORE=${1:?usage: run_avp_fixture.sh <core> [extra args...]}
shift || true
if [ ! -f "$AVP" ]; then
AVP=$(find -L "$ROOT/test/roms/private" -iname 'Alien vs Predator (1994).jag' | head -1 || true)
fi
if [ ! -f "$AVP" ]; then
echo "run_avp_fixture: AvP ROM missing" >&2
exit 1
fi
if [ ! -x "$VERIFY" ]; then
echo "run_avp_fixture: build cd_visual_verify first" >&2
exit 1
fi
if [ ! -r "$PRESS_FILE" ]; then
echo "run_avp_fixture: press fixture not readable: $PRESS_FILE" >&2
echo "run_avp_fixture: set PRESS_FILE=<path> to override the default" >&2
exit 1
fi
press_args=()
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|\#*) continue ;;
esac
press_args+=(--press "$line")
done < "$PRESS_FILE"
if [ ${#press_args[@]} -eq 0 ]; then
echo "run_avp_fixture: press fixture has no input lines: $PRESS_FILE" >&2
exit 1
fi
mkdir -p "$OUTDIR"
log="$OUTDIR/timeline.log"
set +e
"$VERIFY" "$CORE" "$AVP" \
--frames "$FRAMES" \
"${press_args[@]}" \
--outdir "$OUTDIR" \
--shot-every "${SHOT_EVERY:-0}" \
--system-dir "$SYSTEM_DIR" \
--quiet \
"$@" 2>&1 | tee "$log"
# PIPESTATUS is clobbered by the next command, so copy the whole array at once.
status=("${PIPESTATUS[@]}")
set -e
rc=${status[0]}
tee_rc=${status[1]}
if [ "$tee_rc" -ne 0 ]; then
echo "run_avp_fixture: failed to write log $log (tee exited $tee_rc)" >&2
exit 1
fi
# Gate: in windows covering the last 300 frames, require
# sum(moving_frames) >= 40 (AvP first-person tops out ~12/60 per window)
# max nonblack >= 5000
python3 - "$log" "$FRAMES" <<'PY'
import re, sys
log, frames = sys.argv[1], int(sys.argv[2])
start = frames - 300
wins = []
for line in open(log, encoding='utf-8', errors='replace'):
m = re.search(
r'\[win\s+(\d+)\] frames\s+(\d+)-\s*(\d+): motion\s+(\d+)/(\d+)'
r'.*max nonblack\s+(\d+)/',
line,
)
if m:
wins.append(tuple(map(int, m.groups())))
if not wins:
print('GATE FAIL: no timeline windows parsed', file=sys.stderr)
sys.exit(1)
mov_sum = 0
max_nb = 0
for wid, a, b, mov, tot, nb in wins:
if b < start:
continue
mov_sum += mov
if nb > max_nb:
max_nb = nb
print(f'late win {wid} {a}-{b} motion={mov}/{tot} nonblack={nb}')
print(f'late_moving_sum={mov_sum} max_nonblack={max_nb}')
if mov_sum >= 40 and max_nb >= 5000:
print('GATE PASS')
sys.exit(0)
print('GATE FAIL', file=sys.stderr)
sys.exit(1)
PY
gate=$?
exit $(( rc != 0 ? rc : gate ))
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Expand test/fixtures/dragons_lair_death_branch[_bios].press into
# fmv_seek_probe --press args and assert that a scene BRANCH actually fired.
#
# bash test/tools/run_dl_branch_fixture.sh <core> [hle|bios] [extra args...]
#
# The gate is the CD trace ring, not pixels: a branch is a CD read whose LBA
# delta from the previous read exceeds BRANCH_MIN_DELTA (default 100000)
# sectors. Steady-state streaming reads step 339-428 sectors and the
# attract-loop restart steps 5171, so the classes do not overlap.
#
# In HLE mode the event is HLE_READ; in BIOS mode it is SEEK_START. The
# `seeks`/`seekstarts` CSV columns are incremented only by the BUTCH $12xx
# register path (src/cd/cdrom.c) which HLE bypasses, so they are structurally
# 0 in HLE regardless of whether a branch happened. Do not gate HLE on them.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
CORE=${1:?usage: run_dl_branch_fixture.sh <core> [hle|bios] [extra args...]}
shift || true
MODE=hle
case "${1:-}" in
hle|bios) MODE=$1; shift ;;
esac
PROBE=${PROBE:-"$ROOT/test/tools/fmv_seek_probe"}
DISC=${DISC:-"$ROOT/test/roms/private/Jaguar CD/BinCue/Dragon's Lair (USA)/Dragon's Lair (USA).cue"}
SYSTEM_DIR=${SYSTEM_DIR:-"$ROOT/test/roms/private"}
BRANCH_MIN_DELTA=${BRANCH_MIN_DELTA:-100000}
OUTDIR=${OUTDIR:-/tmp/dl_branch_$$}
if [ "$MODE" = bios ]; then
PRESS_FILE=${PRESS_FILE:-"$ROOT/test/fixtures/dragons_lair_death_branch_bios.press"}
FRAMES=${FRAMES:-1400}
MODE_ARGS=(--option virtualjaguar_cd_boot_mode=bios)
EXPECT_MODE='mode=BIOS'
else
PRESS_FILE=${PRESS_FILE:-"$ROOT/test/fixtures/dragons_lair_death_branch.press"}
FRAMES=${FRAMES:-960}
MODE_ARGS=()
EXPECT_MODE='mode=HLE'
fi
if [ ! -x "$PROBE" ]; then
echo "run_dl_branch_fixture: build the probe first:" >&2
echo " cc -O2 -Wall -std=c99 -I. -I./test -I./libretro-common/include \\" >&2
echo " -o test/tools/fmv_seek_probe test/tools/fmv_seek_probe.c \\" >&2
echo " test/harness/harness.c -ldl -lm" >&2
exit 1
fi
if [ ! -r "$DISC" ]; then
echo "run_dl_branch_fixture: disc image missing: $DISC" >&2
exit 1
fi
if [ ! -r "$PRESS_FILE" ]; then
echo "run_dl_branch_fixture: press fixture not readable: $PRESS_FILE" >&2
exit 1
fi
press_args=()
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|\#*) continue ;;
esac
press_args+=(--press "$line")
done < "$PRESS_FILE"
mkdir -p "$OUTDIR"
log="$OUTDIR/trace.log"
set +e
VJ_CD_TRACE=1 VJ_CD_TRACE_LIVE=1 VJ_HARNESS_LOG_INFO=1 \
FMV_CSV="$OUTDIR/fields.csv" \
"$PROBE" "$CORE" "$DISC" \
--frames "$FRAMES" \
--system-dir "$SYSTEM_DIR" \
"${MODE_ARGS[@]}" \
"${press_args[@]}" \
"$@" > "$log" 2>&1
rc=$?
set -e
# Boot mode must be confirmed from the core's own log line, never the flag.
if ! awk -v want="$EXPECT_MODE" 'index($0, "CD game, ") && index($0, want) {f=1} END{exit !f}' "$log"; then
echo "run_dl_branch_fixture: FAIL -- core did not report $EXPECT_MODE" >&2
if awk '/CD game, mode=/{f=1} END{exit !f}' "$log"; then
awk '/CD game, mode=/' "$log" >&2
else
# No boot line at all: usually the VJ_EXPECT_BUILD guard rejecting a core
# built at a different git rev, or a load failure. Show the tail.
echo "run_dl_branch_fixture: no [BOOT] line -- tail of $log:" >&2
tail -12 "$log" >&2
fi
exit 1
fi
awk -v min="$BRANCH_MIN_DELTA" '
/kind=(SEEK_START|HLE_READ)/ {
split($2, a, "="); split($5, b, "=");
tick = a[2] + 0; lba = b[2] + 0;
if (have) {
d = lba - prev; if (d < 0) d = -d;
if (d >= min) {
n++;
printf " BRANCH field=%.1f LBA %d -> %d (delta %d)\n",
tick / 524.0, prev, lba, lba - prev;
}
}
prev = lba; have = 1;
}
END { print "branches=" n+0 > "/dev/stderr"; exit (n+0) ? 0 : 1 }
' "$log"
gate=$?
echo "run_dl_branch_fixture: mode=$MODE frames=$FRAMES log=$log"
if [ $gate -ne 0 ]; then
echo "run_dl_branch_fixture: FAIL -- no branch-magnitude CD read (>= $BRANCH_MIN_DELTA sectors)" >&2
exit 1
fi
if [ $rc -ne 0 ]; then
echo "run_dl_branch_fixture: FAIL -- probe exited $rc" >&2
exit 1
fi
echo "run_dl_branch_fixture: PASS"
+553
View File
@@ -0,0 +1,553 @@
/* test_runahead_determinism.c -- Verify save states are deterministic.
*
* This is the test behind the core's `savestate_features = 3`
* ("deterministic") claim in dist/info/virtualjaguar_libretro.info, and
* behind reporting zero serialization quirks from retro_load_game.
*
* Reporting level 3 tells the frontend that a state is a COMPLETE
* snapshot: restoring it and re-running must reproduce the same frames
* bit-for-bit. RetroArch's run-ahead depends on exactly that — its
* single-instance mode runs the emulator forward, presents a later
* frame, then rolls back with retro_unserialize. Any emulator state
* that lives outside the state blob (a static that never got
* serialized, a scheduled event, an audio resampler phase) shows up as
* divergence after the rollback, which run-ahead turns into visible
* glitching.
*
* The test mirrors that loop directly:
*
* 1. run WARMUP frames to get past boot into steady-state emulation
* 2. serialize -> state A
* 3. run WINDOW frames, digesting each frame's video + audio -> pass 1
* 4. serialize -> state B (the "after" state, for reference)
* 5. unserialize state A
* 6. run WINDOW frames again, same (empty) input -> pass 2
* 7. serialize -> state C
* 8. unserialize state A again, run WINDOW frames -> pass 3
*
* Assertions:
* - video_replay_identical pass 1 == pass 2, per-frame video digests
* - audio_replay_identical pass 1 == pass 2, per-frame audio digests
* - state_reconverges state C == state B, byte for byte
* - repeated_rollback_agrees pass 2 == pass 3, video and audio
*
* A state that round-trips but silently drops a field usually passes a
* naive "save, load, does it still run?" check and fails here, because
* the dropped field perturbs the next few frames.
*
* Input is held empty across the measured window on purpose: run-ahead
* replays frames with the SAME input it already used, so equal input is
* the condition under test. Divergence here is core state, not input.
*
* Why pass 3 exists, and why it is the load-bearing check for run-ahead:
* pass 1 is the only pass reached WITHOUT an intervening
* retro_unserialize, so anything the load path fails to restore shows up
* as a pass-1-vs-pass-2 difference and nowhere else. Run-ahead rolls
* back on every single frame, so it lives entirely in the post-rollback
* regime that pass 2 vs pass 3 measures.
*
* ROOT CAUSE THIS TEST FOUND (fixed; kept here as the map of the area):
* every one of these assertions passed EXCEPT audio_replay_identical,
* which failed on exactly one frame — the first after the first rollback —
* by ~0.05% RMS with an identical sample count, on three commercial
* titles. Instrumenting DACPrepareFrame and diffing the two passes field
* by field showed every DSP field identical (pc, control, flags, acc,
* pipeline pointers, scoreboard, registers) and only LTXD/RTXD differing.
*
* Those are the DAC output registers, and they live in jagMemSpace at
* $F1A148/$F1A14C — which no STATE_SAVE_BUF covered. retro_serialize
* saves jaguarMainRAM (the low 2 MB of jagMemSpace), tomRam8 and
* jerry_ram_8, and jerry_ram_8 is a SEPARATE array in jerry.c, not the
* $F10000 window of jagMemSpace. So the whole DAC register file survived
* a load only by accident, as whatever the previous run left behind.
* DACPrepareFrame seeds the resampler's interpolation endpoints from
* LTXD/RTXD, so the first frame after a rollback started from the wrong
* endpoints and then converged. Fixed by serializing the registers in
* DACStateSave/Load behind STATE_VERSION_DAC_REGISTERS.
*
* Things that looked guilty and were NOT the cause — do not re-spend the
* time:
*
* - The I2S ring buffer (i2sRingL/R), despite being absent from
* DACStateSave. DACPrepareFrame reseeds writePos/writeCount to 2 every
* frame and DSPSampleCallback clamps its read index to i2sWriteCount, so
* no sample written before this frame can ever be read.
* - dspFlagsRetireDelay / dspPreStoreBank, which DSPStateLoad explicitly
* retires rather than restoring — audio-path state, reset on load,
* producing exactly the observed shape. Implemented behind a state
* version gate and measured: NO CHANGE on any title. The retire window
* is DSP_FLAGS_RETIRE_DELAY == 2 instruction slots, so a frame boundary
* practically never lands inside it. Reverted.
* - The event queue. All nine scheduled callbacks are present in
* event.c's callback_registry, so none is dropped by the
* pointer->id->pointer round trip.
* - jaguar_prng_state (JaguarRand() runs only from reset/init paths),
* dspgo_poll_count (pinned to 0 while audio is non-silent), and
* m68kInLongWrite / m68kBusNoCharge / m68kScaleAccum (balanced nesting
* counters, or inert at stock 1x clock scale).
*
* Build (see the `runahead-determinism` Makefile target):
* cc -O2 -Wall -std=c99 -I./libretro-common/include \
* -o test/tools/test_runahead_determinism \
* test/tools/test_runahead_determinism.c test/harness/harness.c -ldl -lm
*
* Usage: ./test/tools/test_runahead_determinism <core> <rom> [--frames N]
* [--warmup N] [--json]
*
* Exit: 0 PASS, 1 FAIL, 2 SKIP (ROM missing)
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdbool.h>
#include <unistd.h>
#include "../harness/harness.h"
#define DEFAULT_WARMUP 240 /* 4s — past BIOS/boot into steady state */
#define DEFAULT_WINDOW 120 /* 2s of rollback-and-replay comparison */
#define MAX_WINDOW 600
/* FNV-1a: cheap, order-sensitive, and good enough to catch a single
* differing sample or pixel. We only ever compare digests against
* digests from the same binary, so portability of the hash is moot. */
#define FNV_OFFSET 1469598103934665603ULL
#define FNV_PRIME 1099511628211ULL
static uint64_t fnv1a(uint64_t h, const void *data, size_t len)
{
const uint8_t *p = (const uint8_t *)data;
size_t i;
for (i = 0; i < len; i++) {
h ^= p[i];
h *= FNV_PRIME;
}
return h;
}
/* ---------- per-frame capture ---------- */
typedef struct {
uint64_t video[MAX_WINDOW];
uint64_t audio[MAX_WINDOW];
/* Raw per-frame sample count and RMS, kept alongside the digests so a
* failure can say WHAT differed, not just that something did. */
size_t samples[MAX_WINDOW];
double rms[MAX_WINDOW];
unsigned count;
/* Accumulates this frame's video hash between video_cb and frame_cb. */
uint64_t pending_video;
int saw_video;
/* Index into cfg->audio.frames[] already consumed, and where this
* window started, so a window that saw no new audio entries at all
* can be reported instead of passing vacuously. */
unsigned audio_cursor;
unsigned audio_start;
} capture;
static capture cap;
static unsigned window_frames = DEFAULT_WINDOW;
static int capturing = 0;
static void video_cb(void *ud, const void *data,
unsigned width, unsigned height, size_t pitch)
{
unsigned y;
const uint8_t *row = (const uint8_t *)data;
(void)ud;
if (!capturing)
return;
/* A duped frame (data == NULL) is itself a meaningful signal: it must
* dupe in both passes at the same frame. Fold the NULL-ness in. */
if (!data) {
cap.pending_video = fnv1a(cap.pending_video, "DUPE", 4);
cap.saw_video = 1;
return;
}
cap.pending_video = fnv1a(cap.pending_video, &width, sizeof(width));
cap.pending_video = fnv1a(cap.pending_video, &height, sizeof(height));
/* Hash the live pixels only, not the inter-row padding, so a change
* in pitch alone doesn't masquerade as a pixel difference. */
for (y = 0; y < height; y++)
cap.pending_video = fnv1a(cap.pending_video,
row + (size_t)y * pitch,
(size_t)width * 4);
cap.saw_video = 1;
}
/* The harness owns the audio callbacks, so digest audio from the
* per-batch entries it appends to cfg->audio.frames[]. Each entry
* carries that batch's sample count, peak and RMS, all computed from
* the real samples — one differing sample moves the RMS.
*
* Only the entries appended since the previous frame are hashed, and
* the entry's `frame` field is deliberately excluded: it is an absolute
* frame counter that the harness keeps incrementing across the rollback,
* so it legitimately differs between passes and says nothing about
* whether the emulator diverged. */
static uint64_t audio_fingerprint(const harness_config *cfg, unsigned *cursor,
size_t *samples_out, double *rms_out)
{
uint64_t h = FNV_OFFSET;
unsigned i;
unsigned batches;
*samples_out = 0;
*rms_out = 0;
for (i = *cursor; i < cfg->audio.frame_count; i++) {
const harness_audio_frame *af = &cfg->audio.frames[i];
h = fnv1a(h, &af->samples, sizeof(af->samples));
h = fnv1a(h, &af->nonsilent, sizeof(af->nonsilent));
h = fnv1a(h, &af->peak_l, sizeof(af->peak_l));
h = fnv1a(h, &af->peak_r, sizeof(af->peak_r));
h = fnv1a(h, &af->rms_l, sizeof(af->rms_l));
h = fnv1a(h, &af->rms_r, sizeof(af->rms_r));
*samples_out += af->samples;
*rms_out = af->rms_l;
}
/* Fold in how many batches arrived, so "2 batches" and "1 batch with
* the same totals" don't collide. This must be the COUNT, not the
* absolute cursor: cfg->audio.frames[] keeps growing across the
* rollback, so hashing the index would make every frame differ
* between passes by construction and report a false divergence. */
batches = i - *cursor;
h = fnv1a(h, &batches, sizeof(batches));
*cursor = cfg->audio.frame_count;
return h;
}
/* Called by run_window after each harness_step().
*
* Note this is driven explicitly rather than through cfg->frame_callback:
* harness_run() invokes that hook, but harness_step() does not, and this
* test needs the manual stepping so it can serialize mid-run. */
static void capture_frame(harness_config *cfg)
{
if (cap.count < MAX_WINDOW) {
cap.video[cap.count] = cap.saw_video ? cap.pending_video : 0;
cap.audio[cap.count] = audio_fingerprint(cfg, &cap.audio_cursor,
&cap.samples[cap.count],
&cap.rms[cap.count]);
cap.count++;
}
cap.pending_video = FNV_OFFSET;
cap.saw_video = 0;
}
/* ---------- helpers ---------- */
static uint8_t *read_file(const char *path, size_t *len_out)
{
FILE *f = fopen(path, "rb");
long len;
uint8_t *buf;
if (!f)
return NULL;
fseek(f, 0, SEEK_END);
len = ftell(f);
fseek(f, 0, SEEK_SET);
if (len <= 0) {
fclose(f);
return NULL;
}
buf = (uint8_t *)malloc((size_t)len);
if (!buf) {
fclose(f);
return NULL;
}
if (fread(buf, 1, (size_t)len, f) != (size_t)len) {
free(buf);
fclose(f);
return NULL;
}
fclose(f);
*len_out = (size_t)len;
return buf;
}
/* Runs `n` frames with capture enabled, filling cap.
*
* The audio cursor starts at whatever the harness has already recorded,
* not at 0: cfg->audio.frames[] accumulates across the whole run and is
* never rewound by retro_unserialize, so pass 2 must only look at the
* entries its own frames appended. */
static void run_window(harness_config *cfg, unsigned n)
{
unsigned i;
memset(&cap, 0, sizeof(cap));
cap.pending_video = FNV_OFFSET;
cap.audio_cursor = cfg->audio.frame_count;
cap.audio_start = cfg->audio.frame_count;
capturing = 1;
for (i = 0; i < n; i++) {
harness_step(cfg);
capture_frame(cfg);
}
capturing = 0;
}
int main(int argc, char **argv)
{
harness_config cfg = HARNESS_CONFIG_DEFAULT;
harness_result results[4];
unsigned nres = 0;
unsigned warmup = DEFAULT_WARMUP;
unsigned i;
int first_video_diff = -1;
int first_audio_diff = -1;
unsigned audio_diff_count = 0;
size_t pass1_samples[MAX_WINDOW];
double pass1_rms[MAX_WINDOW];
uint64_t pass1_video[MAX_WINDOW];
uint64_t pass1_audio[MAX_WINDOW];
uint64_t pass2_video[MAX_WINDOW];
uint64_t pass2_audio[MAX_WINDOW];
int first_repeat_diff = -1;
unsigned repeat_diff_count = 0;
unsigned pass1_count;
unsigned pass1_audio_entries = 0;
unsigned pass2_audio_entries = 0;
int audio_measurable;
char state_a[] = "/tmp/vj_runahead_a.state";
char state_b[] = "/tmp/vj_runahead_b.state";
char state_c[] = "/tmp/vj_runahead_c.state";
uint8_t *blob_b = NULL, *blob_c = NULL;
size_t len_b = 0, len_c = 0;
int state_stable = 0;
char detail_v[256], detail_a[256], detail_s[256], detail_r[256];
int failed = 0;
for (i = 1; i < (unsigned)argc; i++) {
if (!strcmp(argv[i], "--warmup") && i + 1 < (unsigned)argc)
warmup = (unsigned)atoi(argv[++i]);
}
cfg.frames = DEFAULT_WINDOW;
if (!harness_init_from_args(&cfg, argc, argv)) {
fprintf(stderr, "usage: %s <core> <rom> [--frames N] [--warmup N] [--json]\n",
argv[0]);
return 1;
}
window_frames = cfg.frames;
if (window_frames > MAX_WINDOW)
window_frames = MAX_WINDOW;
if (!cfg.rom_path || access(cfg.rom_path, R_OK) != 0) {
printf("SKIP: ROM not available (%s)\n",
cfg.rom_path ? cfg.rom_path : "(none)");
return 2;
}
cfg.video_callback = video_cb;
cfg.video_callback_data = NULL;
if (!harness_load_core(&cfg))
return 1;
if (!harness_load_rom(&cfg)) {
harness_shutdown(&cfg);
return 1;
}
/* 1. Warm up past boot. */
for (i = 0; i < warmup; i++)
harness_step(&cfg);
/* 2. State A — the rollback point. */
if (!harness_save_state(&cfg, state_a)) {
fprintf(stderr, "FAIL: retro_serialize failed\n");
harness_shutdown(&cfg);
return 1;
}
/* 3. Pass 1. */
run_window(&cfg, window_frames);
memcpy(pass1_video, cap.video, sizeof(pass1_video));
memcpy(pass1_audio, cap.audio, sizeof(pass1_audio));
memcpy(pass1_samples, cap.samples, sizeof(pass1_samples));
memcpy(pass1_rms, cap.rms, sizeof(pass1_rms));
pass1_count = cap.count;
pass1_audio_entries = cap.audio_cursor - cap.audio_start;
/* 4. State B — where pass 1 ended up. */
if (!harness_save_state(&cfg, state_b)) {
fprintf(stderr, "FAIL: retro_serialize failed after pass 1\n");
harness_shutdown(&cfg);
return 1;
}
/* 5. Roll back. */
if (!harness_load_state(&cfg, state_a)) {
fprintf(stderr, "FAIL: retro_unserialize failed\n");
harness_shutdown(&cfg);
return 1;
}
/* 6. Pass 2 — identical input, so identical output is required. */
run_window(&cfg, window_frames);
pass2_audio_entries = cap.audio_cursor - cap.audio_start;
if (getenv("VJ_RUNAHEAD_DUMP")) {
unsigned d;
for (d = 0; d < pass1_count && d < 8; d++)
fprintf(stderr,
" frame %2u: pass1 samples=%zu rms=%.6f | "
"pass2 samples=%zu rms=%.6f %s\n",
d, pass1_samples[d], pass1_rms[d],
cap.samples[d], cap.rms[d],
(cap.audio[d] == pass1_audio[d]) ? "" : "<-- DIFF");
}
/* 7. State C. */
harness_save_state(&cfg, state_c);
/* 8. Pass 3 — roll back and replay a SECOND time.
*
* Run-ahead rolls back on every frame, not once, so the property it
* actually depends on is that repeated rollbacks agree with each
* other. Pass 1 is the odd one out by construction: it is the only
* pass reached without an intervening retro_unserialize, so anything
* the load path doesn't restore differs there and only there.
* Pass 2 vs pass 3 isolates that. */
memcpy(pass2_video, cap.video, sizeof(pass2_video));
memcpy(pass2_audio, cap.audio, sizeof(pass2_audio));
if (!harness_load_state(&cfg, state_a)) {
fprintf(stderr, "FAIL: retro_unserialize failed on second rollback\n");
harness_shutdown(&cfg);
return 1;
}
run_window(&cfg, window_frames);
for (i = 0; i < pass1_count && i < cap.count; i++) {
if (cap.video[i] != pass2_video[i] || cap.audio[i] != pass2_audio[i]) {
if (first_repeat_diff < 0) first_repeat_diff = (int)i;
repeat_diff_count++;
}
}
/* Compare pass 1 against pass 3's digests below; cap now holds pass 3,
* and pass 2 and pass 3 are asserted equal separately. */
memcpy(cap.video, pass2_video, sizeof(pass2_video));
memcpy(cap.audio, pass2_audio, sizeof(pass2_audio));
/* ---------- compare ---------- */
if (cap.count != pass1_count) {
first_video_diff = 0;
} else {
for (i = 0; i < pass1_count; i++) {
if (cap.video[i] != pass1_video[i] && first_video_diff < 0)
first_video_diff = (int)i;
if (cap.audio[i] != pass1_audio[i]) {
if (first_audio_diff < 0) first_audio_diff = (int)i;
audio_diff_count++;
}
}
}
blob_b = read_file(state_b, &len_b);
blob_c = read_file(state_c, &len_c);
state_stable = (blob_b && blob_c && len_b == len_c &&
memcmp(blob_b, blob_c, len_b) == 0);
if (first_video_diff < 0)
snprintf(detail_v, sizeof(detail_v),
"%u frames replayed after rollback, all video digests identical",
pass1_count);
else
snprintf(detail_v, sizeof(detail_v),
"video diverged at replay frame %d of %u "
"(pass1=%016llx pass2=%016llx)",
first_video_diff, pass1_count,
(unsigned long long)pass1_video[first_video_diff],
(unsigned long long)cap.video[first_video_diff]);
/* cfg->audio.frames[] is capped at HARNESS_MAX_AUDIO_FRAMES. Once it
* fills, both passes hash zero new entries and compare equal — a
* vacuous pass. Require that both passes actually saw audio. */
audio_measurable = (pass1_audio_entries > 0 && pass2_audio_entries > 0);
if (!audio_measurable)
snprintf(detail_a, sizeof(detail_a),
"no audio batches recorded in the compared windows "
"(pass1=%u pass2=%u entries; harness caps at %d, "
"reduce --warmup/--frames)",
pass1_audio_entries, pass2_audio_entries,
HARNESS_MAX_AUDIO_FRAMES);
else if (first_audio_diff < 0)
snprintf(detail_a, sizeof(detail_a),
"%u frames replayed after rollback, all audio digests "
"identical (%u audio batches compared)",
pass1_count, pass1_audio_entries);
else
snprintf(detail_a, sizeof(detail_a),
"audio diverged at replay frame %d of %u (%u frames differ; "
"first: pass1 %zu samples, pass2 %zu samples)",
first_audio_diff, pass1_count, audio_diff_count,
pass1_samples[first_audio_diff],
cap.samples[first_audio_diff]);
if (state_stable)
snprintf(detail_s, sizeof(detail_s),
"state after replay is byte-identical to state after "
"original run (%zu bytes)", len_b);
else
snprintf(detail_s, sizeof(detail_s),
"state after replay differs from state after original run "
"(%zu vs %zu bytes)", len_b, len_c);
results[nres].status = (first_video_diff < 0) ? "PASS" : "FAIL";
results[nres].name = "video_replay_identical";
results[nres].detail = detail_v;
if (first_video_diff >= 0) failed = 1;
nres++;
results[nres].status = !audio_measurable ? "FAIL"
: (first_audio_diff < 0) ? "PASS" : "FAIL";
results[nres].name = "audio_replay_identical";
results[nres].detail = detail_a;
if (!audio_measurable || first_audio_diff >= 0) failed = 1;
nres++;
results[nres].status = state_stable ? "PASS" : "FAIL";
results[nres].name = "state_reconverges";
results[nres].detail = detail_s;
if (!state_stable) failed = 1;
nres++;
if (first_repeat_diff < 0)
snprintf(detail_r, sizeof(detail_r),
"two successive rollbacks to the same state replayed "
"%u frames identically (video and audio)", pass1_count);
else
snprintf(detail_r, sizeof(detail_r),
"successive rollbacks disagree at replay frame %d "
"(%u frames differ)", first_repeat_diff, repeat_diff_count);
results[nres].status = (first_repeat_diff < 0) ? "PASS" : "FAIL";
results[nres].name = "repeated_rollback_agrees";
results[nres].detail = detail_r;
if (first_repeat_diff >= 0) failed = 1;
nres++;
harness_report(&cfg, results, nres);
free(blob_b);
free(blob_c);
remove(state_a);
remove(state_b);
remove(state_c);
harness_shutdown(&cfg);
return failed;
}
+116
View File
@@ -0,0 +1,116 @@
/*
* test/tools/vjss_info.c — print Virtual Jaguar savestate header fields.
*
* Headers are host-endian: STATE_SAVE_VAR is a plain memcpy of uint32_t
* (see src/core/state.h). On little-endian hosts the magic 0x564A5353
* ("VJSS") appears on disk as bytes 53 53 4A 56 — do NOT string-compare
* against ASCII "VJSS".
*
* Build:
* cc -O2 -Wall -std=c89 -o test/tools/vjss_info test/tools/vjss_info.c
*
* Usage:
* vjss_info <statefile>
*
* Exit: 0 = magic matched (any verdict), 1 = I/O or bad_magic, 2 = usage.
*
* Note on `loadable` for versions 1-3: those are the layouts releases
* v2.2.0 / v2.3.0 / v2.3.1 / v2.3.2 wrote. They used to report `too_old`
* (or load but silently mis-parse); issue #268 lowered VJSS_MIN_VERSION to
* 1 and taught the CDROM and DAC loaders to read the old chunk shapes, so
* `loadable` now means what it says. See docs/savestate-compat.md.
*/
#include <stdio.h>
#include <stdint.h>
/* Numeric copies of src/core/state.h — keep the tool standalone.
* VJSS_VERSION must track STATE_VERSION there: if it lags, this tool
* reports freshly written states as verdict=too_new, which is exactly
* the misdiagnosis #268 turned on. */
#define VJSS_MAGIC 0x564A5353u /* "VJSS" */
#define VJSS_VERSION 8u
#define VJSS_MIN_VERSION 1u
static uint32_t rd_le(const uint8_t *p)
{
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
static uint32_t rd_be(const uint8_t *p)
{
return ((uint32_t)p[0] << 24)
| ((uint32_t)p[1] << 16)
| ((uint32_t)p[2] << 8)
| (uint32_t)p[3];
}
int main(int argc, char **argv)
{
FILE *f;
uint8_t buf[16];
size_t nread;
uint32_t magic, version, flags, reserved;
const char *endian;
const char *verdict;
if (argc != 2)
{
fprintf(stderr, "usage: vjss_info <statefile>\n");
return 2;
}
f = fopen(argv[1], "rb");
if (!f)
{
perror(argv[1]);
return 1;
}
nread = fread(buf, 1, sizeof(buf), f);
fclose(f);
if (nread < sizeof(buf))
{
fprintf(stderr, "vjss_info: short read (%zu bytes)\n", nread);
return 1;
}
if (rd_le(buf) == VJSS_MAGIC)
{
endian = "le";
magic = rd_le(buf);
version = rd_le(buf + 4);
flags = rd_le(buf + 8);
reserved = rd_le(buf + 12);
}
else if (rd_be(buf) == VJSS_MAGIC)
{
endian = "be";
magic = rd_be(buf);
version = rd_be(buf + 4);
flags = rd_be(buf + 8);
reserved = rd_be(buf + 12);
}
else
{
printf("magic=0x%08X endian=? version=? flags=? reserved=? verdict=bad_magic\n",
(unsigned)rd_le(buf));
return 1;
}
if (version < VJSS_MIN_VERSION)
verdict = "too_old";
else if (version > VJSS_VERSION)
verdict = "too_new";
else
verdict = "loadable";
printf("magic=0x%08X endian=%s version=%u flags=0x%08X reserved=0x%08X verdict=%s\n",
(unsigned)magic, endian, (unsigned)version,
(unsigned)flags, (unsigned)reserved, verdict);
return 0;
}