mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-10 17:28:58 -10:00
80 files changed
+14561
-1025
No files matched your search
+88
-11
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](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
@@ -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."
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.**
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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).
|
||||
```
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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).
|
||||
@@ -62,3 +62,5 @@ _JoystickState*
|
||||
_MTState*
|
||||
_NVMBios*
|
||||
_NVM_*
|
||||
_JGD*
|
||||
_jgd*
|
||||
+35
-1
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -65,5 +65,7 @@
|
||||
MTState*;
|
||||
NVMBios*;
|
||||
NVM_*;
|
||||
JGD*;
|
||||
jgd*;
|
||||
local: *;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Executable
+75
@@ -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
|
||||
Executable
+87
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Vendored
+61
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+110
@@ -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 ))
|
||||
Executable
+124
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user