* feat(app): --shader PRESET|off for one run, and a [shader] result line per chain `--shader PRESET.slangp` runs this one run through that preset and `--shader off` through none; neither is written back to config.ini (like --theme). A missing or malformed preset is a usage error (exit 2) instead of a plain picture with a toast. shaders.cpp now logs one stderr line when a chain is built or cannot be: [shader] <preset> built: GLSL 410 on OpenGL 4.1 [shader] <preset> refused: ... (the GL_ARB_arrays_of_arrays limit, macOS GL 4.1) [shader] <preset> failed: ... (librashader's message) so a CI run has a record of what the chain did (#1155 GL qualification). Refs #1155 Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci: GL qualification workflow: windowed boot + every shader preset on Linux, Windows, macOS One reusable workflow, gl-qualification.yml, boots the desktop app windowed with a real OpenGL context on hosted runners and renders every bundled .slangp through librashader, keeping the screenshots. Legs: Linux (ubuntu-24.04, Xvfb + llvmpipe), Windows x64 (hosted, no vendor GL: the automatic bundled-Mesa relaunch of #1138/#1143 must fire), Windows arm64 (folds in windows-gl-test.yml, which is retired), macOS arm64 (Apple OpenGL 4.1; the 15 arrays-of-arrays presets are allowlisted as refused). Per leg: one build, cmake --install into stage/, vj_shader_tests gl run directly (exit 77 = skipped = failure here), then app/test/gl-qualification/gl_qualify.py launches --shader off and every preset with --screenshot and asserts exit 0, the last [gl] renderer line, a non-black PNG, a [shader] built line and a picture that differs from the plain one. Artifacts upload pass or fail; the job summary lists failures with the [gl]/[shader] log tail. Goldens are designed (app/test/golden/gl/<os>/<preset>.png, refresh_goldens input) but not gated. Triggers: pull_request on the GL/shader/packaging paths, workflow_call (release.yml requires it on tags and release/**), nightly schedule, workflow_dispatch. Closes #1155 Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): judge shaders_gl after the preset loop, fail fast, time the x64 fallback First run (41565368188) findings: shaders_gl fails on llvmpipe and on Apple's software renderer (crt-royale renders black in the 128x64 test picture) and the step stopped the leg before any screenshot existed; the x64 automatic-fallback boot hit `timeout 120` where arm64 took 3 s; the arm64 shaders_gl could not load the upstream prebuilt librashader.dll (eager D3DX9_43.dll import, #1153/#1161). - shaders_gl runs with continue-on-error and a "shaders_gl verdict" step fails the job after the preset loop, so the screenshots and logs upload either way. - gl_qualify.py --fail-fast N (default 8): the loop stops after N consecutive failed presets and the summary says how many were not run. - Windows fallback step: timeout 300 s, elapsed seconds logged (automatic-seconds.txt). Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): empty-array expansion safe for macOS bash 3.2 (set -u) Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): Windows leg records the boot, runs timing diagnostics, gates the loop Second run (41567744247): the x64 automatic-fallback boot did not return within 300 s although the screenshot was written (frame 20 reached); arm64 does the same in 2 s. - the loader probe (context create/draw/destroy through the bundled Mesa) runs before the app boot, timed; - the boot step records exit code, seconds and each side condition (automatic-seconds.txt) instead of aborting at the first failed check; a verdict step fails the leg later; - when the boot did not come back, three bounded experiments (the Mesa-side child run directly, --emu-thread off, video.vsync=0 seeded) go to timing.txt; - the preset loop runs only once the plain boot works (500 launches of a boot that does not return are hours of nothing); per-launch timeout 150 s. Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): known-broken allowlist for presets librashader rejects everywhere The second Linux run (41567744247) built and rendered 494 of the 500 bundled presets in ~13 minutes of wall time; the six nedi bilateral-variant presets (all but variant2) fail reflection with UnknownSemantics("play"): they chain vhs/shaders/vhs.slang, whose `play` sampler is an LUT the presets never declare. A preset bug, not a GL one, the same on every leg, so it gets the same treatment as the macOS refused set: an allowlist with the reason that may only shrink (a listed preset that builds is an error). Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): verdict steps run after an earlier failed step too (always()) Co-Authored-By: Claude Fable 5.1 <[email protected]> * test(app): shaders_gl renders the bundled presets at 640x480, not the 128x64 pixel-check picture On llvmpipe (Linux) and Apple's software renderer (hosted macOS) crt-royale came out black in shaders_gl: its mask passes scale by 0.0625 of the viewport and the preset says "Safe for >= 341.333 horizontal triads at viewport size", so at 128x64 they are 8x4 px. The app at 960x760 renders it (gl-qualification run 41567744247, 76% of pixels differ from the plain picture). The pixel checks keep their 128x64 (an exact 4x of the 32x16 source); the bundled loop and VJ_TEST_PRESETS render at a real-world 640x480. Refs #1155 Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): every Windows Mesa launch bounded; llvmpipe single-thread and softpipe probes Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): own librashader artifacts, macOS from source and sized down, release staging excludes qualification artifacts Review (Codex P1/P2 on #1160) and run 41571727219: - librashader.yml gains an `artifact_suffix` input; this workflow calls it with `-glq`, so a release run (app-package.yml + gl-qualification.yml) uploads no colliding artifact names. - release.yml's two staging sweeps exclude artifacts/gl-qualification-* (screenshots, logs and the arm64 test zip are not release assets). - macOS leg: the from-source aarch64 runtime the package ships, not upstream's prebuilt 0.12.0 (crt-black_crush-koko.slangp fails to parse on the prebuilt, parses on the pinned source); 3 launches at once in a 480x360 window (small-window.ini via gl_qualify.py --seed-config): Apple's software renderer managed 205 of 500 presets in 150 min at 960x760 and 2 jobs. - refused-<os>.txt also covers presets the leg's GL cannot COMPILE (macOS: fsr and smaa+fsr need packHalf2x16, GLSL 4.20); a listed preset that builds is an error, the set only shrinks. Co-Authored-By: Claude Fable 5.1 <[email protected]> * test(app): gl_context_integration prints unbuffered stage markers (where does the x64 Mesa probe stop?) Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): tracked known failures; the x64 exit hang counted, not masked; loop before shaders_gl app/test/gl-qualification/known-failures.txt lists one failure per line (<leg> <kind> <reference>); the workflow reads it and turns exactly that failure into a recorded pass with a note. Nothing else on the leg is forgiven, and removing the line makes the leg fail again. First entry: windows-x64 exit-hang (#1167), the app never exits on the bundled MSYS2 MINGW64 Mesa 26.2.3 llvmpipe (SDL_DestroyWindow/SDL_Quit hang after "context destroyed"; LP_NUM_THREADS=0 and softpipe exit at once, arm64 Mesa exits in 2 s). gl_qualify.py: --known-exit-hang <ref> polls the launch, kills its process tree 5 s after the screenshot is complete (IEND present) and counts the preset, noting the known failure in summary.md and results.json; a launch without a complete screenshot still times out and fails. The Windows boot step accepts exit 124 when the screenshot and the [gl] lines are there, the loaders step accepts it on PASS, and shaders_gl passes without its final OK line only when nothing failed or skipped and its last check (the broken shader) ran. Every leg runs the preset loop before shaders_gl: on the macOS software renderer a SIGALRM-killed GL process left the next window creation hanging. shaders_gl's bundled loop and VJ_TEST_PRESETS render at 384x288 (crt-royale's mask passes need >= 341 px; 640x480 exceeded a 600 s alarm on Apple's software renderer), the macOS alarm is 1200 s. gl_context_integration prints unbuffered stage markers so a log shows where a probe stops. docs/agent/testing.md describes the lists. Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): capture waits for core frames; unbuffered shaders_gl; macOS 640x480 + capture diagnostics Run 41727418224 (3eef3305) read per leg: - Windows x64: the preset loop passed under the tracked exit hang (494 built, 501 launches killed 5 s after their screenshot, #1167) but the shaders_gl allowance failed: the test's stdout verdict lines were lost when the hung process was killed (a pipe is fully buffered; only the stderr [shader] lines survived). vj_shader_tests writes stdout unbuffered from main() on. - Windows arm64: 493 built, CuNNy-8x32-nvl-2x-rgb timed out at 150 s on the 128-bit llvmpipe. The Windows per-launch timeout is 240 s like the other legs. - macOS: shaders_gl passes at 384x288 (10 min). In the seeded 480x360 window every capture, --shader off included, came back a uniform white picture at 1.9 s a launch; not the game (yarc is black at core frame 1-2 and shows its picture from frame 10 on), not the theme. The leg seeds 640x480 (fits the hosted display, keeps crt-royale above 341 px) and a "Capture diagnostics" step launches `off` once per window configuration (default, 480x360, +frame 120, +emu frames, no viewports, no vsync, 480x362, 640x480, 960x760) and prints each picture's statistics to the step summary, so the artifact names the variable. App: --screenshot-emu-frames M (dev): the capture also waits until the core has shown M frames, with a line and a capture anyway 600 UI frames later if it never does; gl_qualify.py --emu-frames passes it, every leg uses 60. VideoOut::emuFrames() exposes the counter. Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci(gl-qualification): even indentation in the x64 shaders_gl allowance (editorconfig-checker) Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(gl-qualification): macOS captures after 4 UI frames, 240 min budget; 480x360 white = ImGui viewports Run 41760278887 (6afaae1a): Linux, Windows x64 (loop and shaders_gl under #1167) and Windows arm64 green. macOS: the capture diagnostics named the white picture's variable. With ImGui's extra OS windows on (ui.viewports, the default) a 480x360 or 480x362 window captures uniform white; with them off, or at 640x480 and up, it captures the game. The 640x480 leg then built every chain it reached but ran out of time: 172 built, 57 timeouts at 240 s (49 of them had built the chain; Apple's software renderer spends ~12 s a frame on the heavy ones), fail-fast after 250 presets, and the job hit its 150 min limit right after the upload. - The macOS loop captures after 4 UI frames instead of 20; --emu-frames 60 already makes the capture the game's picture. The other legs keep 20. - macOS timeout-minutes 240 (build ~4 min, shaders_gl 10-18 min), shaders_gl alarm 1800 s. - The diagnostics step keeps four launches (default, 480x360 with and without viewports, 640x480). Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(gl-qualification): shard the macOS leg 3 ways, track presets over 120 s as macos-arm64 slow Apple's software OpenGL 4.1 renderer on hosted Macs cannot render ~500 presets in one job (runs 41760278887 and 41776934638 each ran out of time). The macOS job becomes a 3-shard matrix (gl_qualify.py --shard I/N: sorted preset index modulo N, every preset once, each shard with its own `off` baseline, 2 launches at once), the GL probe, capture diagnostics and shaders_gl in shard 0 only, and a macos-summary job joins the shards with --merge, failing when a shard is missing or the shards did not run every preset exactly once. Only the joined table goes to the run summary. known-failures.txt gains a `macos-arm64 slow <measured>@<run> <preset>` kind: 68 presets slow in every run that measured them. A listed preset is stopped at --slow-threshold (120 s) and counted as a known failure, not a checked picture; an unlisted one over the limit fails and the summary prints its line to paste; a listed one that renders in time is flagged for removal. A launch with no [gl] line is retried once and never counts as slow; POSIX kills send SIGTERM first (--graceful-kill) and report a surviving process group. Every run writes times.tsv and a "Slowest launches" table. macOS captures after SHOT_FRAME UI frames again. Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(gl-qualification): no exit allowance for app launches (#1172); test programs under #1179 #1172 ends the app's bundled-Mesa child without unloading Mesa, so the `windows-x64 exit-hang #1167` entry and every app-launch allowance go: the automatic-fallback boot step and the preset loop (--known-exit-hang) fail again if an app launch does not exit on its own. The two test programs, vj_gl_context_integration and `vj_shader_tests gl`, load the bundled Mesa in-process and still hang in its DllMain after SDL_Quit; their allowances stay, unchanged in what they require (the probe's `PASS GL loader` line; shaders_gl with no FAIL/SKIP and its last check run), now keyed to `windows-x64 test-exit-hang #1179`. The macOS summary states plainly how many presets are listed `macos-arm64 slow` and therefore not rendered, with their measured times (finished median and range, timeouts counted apart) and how many this run stopped. Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(gl-qualification): macOS slow list from the first sharded run; ntsc-blastem refused on GL 4.1 Run 41891214865 (3 shards, all 500 presets, every one run once): Linux, Windows x64 and Windows arm64 green; macOS 384 built, 57 listed presets stopped at 120 s, 36 failures. - 31 presets took over 120 s with the chain built (12 timed out at 240 s): listed as `macos-arm64 slow` with their measured times. 7 listed presets that rendered in under 100 s are dropped; 4 that took 102-114 s stay listed (a listed preset that renders in time is still checked). 92 presets are listed. - ntsc/ntsc-blastem fails to link on Apple's GL 4.1 ("Input of fragment shader 'LIBRA_VARYING_2' differs in type/qualifiers"); it builds on GL 4.6: refused-macos.txt. - 4 consecutive shard-0 launches (and their retries) never got a window for ~15 min, then the runner recovered, as in run 41760278887: on macOS a launch that times out with no [gl] line now leaves a `sample` stack of where the app was stuck. The summary sentence now says a listed preset stopped at the limit is not rendered or checked, and counts how many this run stopped and how many rendered in time. Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(gl-qualification): fail a slow macOS preset only if it never renders; reset Apple's GL compile service; no Windows exit allowances Gate change (option b): an unlisted preset now FAILS only if it does not render within the 240 s timeout. One that renders but takes over SLOW_LIMIT (120 s) is a ::notice:: with the line to paste into known-failures.txt and a count in the summary ("Unlisted, rendered but over 120 s"). Hosted-Mac times swing ~2x between runs (sabr-hybrid-deposterize: 131 s, 91 s, 229 s), so the hard 120 s line failed a few new presets every run. Listed presets keep the 120 s cap; the summary sentence states both rules. Run 41957577821 (fd76515e): Linux, Windows x64, Windows arm64 green; macOS 18 failures: - 10 rendered in 121-205 s: notices under the new gate, not listed. - 2 built and timed out at 240 s: listed (vectorscale-exact-energy-single-aa, xbr-lv2-sharp). 94 presets are listed; fd76515e's three lines (all rendered within 240 s) are not. - 6 never got a window, again in the vectorscale/xbr stretch (third run). The new stack samples all wait in CGLChoosePixelFormat -> cvms_connection_create on an XPC reply from CVMServer (com.apple.cvmsServ, Apple's GL compile service) that never came. --reset-cvms (macOS leg) kills CVMCompiler/CVMServer with sudo -n before the no-[gl] retry; launchd starts a fresh one; the summary counts the resets and how many retries then got a window. #1181 fixed #1179 (the GL test programs now exit without unloading the bundled Mesa): the `windows-x64 test-exit-hang #1179` entry, the Known failures step and both test-program allowances are gone, so any Windows process that does not exit on its own fails the leg. Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(gl-qualification): list the 2 macOS presets that timed out with a chain on run 42110580090 crt-yah/presets/pure-lcd (204.9 s on 41957577821, over 240 s now) and vectorscale (no window on 41957577821, the CVMServer stall; chain built but no picture in 240 s now) are added as macos-arm64 slow, 96 listed. The 15 unlisted presets that rendered in 121-205 s on that run are notices under the current rule and are not listed. Also corrects the SMOKE_PRESETS comment (Kimi review): PR runs render all presets; only workflow_dispatch's preset_set=smoke selects the subset. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Fable 5.1 <[email protected]>
39 KiB
Testing detail
Detail for AGENTS.md. Local-only RetroAchievements validation — no RA
account/API/server. test/tools/test_rcheevos_e2e.sh downloads pinned RCHEEVOS_REF and
verifies rc_libretro mapping (RC_CONSOLE_ATARI_JAGUAR) matches host RAM.
Shared test harness (test/harness/)
New tests should use test/harness/harness.h — shared lib eliminating dlopen/init/run
boilerplate (see header's AGENT QUICK-START). Features:
- Common CLI:
--json,--frames N,--bios,--option K=V,--quiet.--biossets only the CART BIOS option; CD boot needs--option virtualjaguar_cd_boot_mode=bios(check the[BOOT] CD game, mode=BIOSlog line).--input-bitmasksanswers GET_INPUT_BITMASKS like RetroArch does (default harness takes the per-button input path). - Scripted input:
--press FRAME:BUTTON[:HOLD](repeatable; buttonsup down left right a b c pause option 0-6) — enough to navigate menus into gameplay headlessly. Programmatic:harness_press()/harness_input_cb. - Log verbosity env:
VJ_HARNESS_LOG_INFO=1passes core INFO (CD trace dumps),VJ_HARNESS_LOG_DEBUG=1adds DEBUG (per-call CD HLE trace). - Automatic audio/video stats;
harness_dlsym()probes internal state;--jsonmachine output. - Probe modules:
dsp_probe.h(DSP registers, PC escape, LTXD ratio, RAM dumps),timing_probe.h(per-frame halflines, cycles, wall time, speed ratio).
Build: cc -O2 -Wall -std=c99 $(INCFLAGS) -o test_foo test_foo.c test/harness/harness.c [probe.c...] -ldl -lm. New probe: create test/harness/foo_probe.h + .c, resolve via
harness_dlsym().
Key harnesses
test/regression_test.sh— screenshot regression vstest/baselines/via miniretro (built from source first run;MINIRETRO_BINenv to skip). Baselines not committed — no-baseline ROM reportsNEWand prints thecpcommand; first run on fresh clone establishes them.test/tools/test_dsp_audio_diag.c— DSP audio diagnostic (make dsp-diag DSP_DIAG_ROM=path); detects PC escape, bank init failures, silent LTXD.test/tools/test_frame_timing.c— per-frame timing (make frame-timing FRAME_TIMING_ROM=path); halflines/cycles/VBlanks per frame, wall-clock speed ratio, anomaly detection.--csv,--json.test/test_audio_clipping.c— detects loud-broken audio (saturation density, run length, sustained loud RMS). Catches Skyhammer/IS2 "saturated square wave".test/test_audio_presence.c— counterpart: asserts audio present in a known-good envelope (RMS in[floor, ceiling], onset reached, no long zero runs). Required to catch the silencing-regression class where a "fix" drops RMS to zero (clipping passes, game silent). Iron Soldier 1 baseline: RMS ~1175 on develop.test/tools/hires_box_check.c— internal-res (hi-res Stage 1) inertness gate: every 2x frame must be exact 2×2 box replication of a 1x reference (frame_hash_abCSV). Frames adjacent to a presented-dimension change excluded.test/tools/hires_state_digest.c— savestate FNV digests at frames 300/600/900; diff a 1x run vsvirtualjaguar_internal_resolution=2xto prove the machine can't see the option.test/tools/test_memory_map.c— assertsSET_MEMORY_MAPS,SET_SUPPORT_ACHIEVEMENTS=true, descriptor layout.test/tools/blit_memo_verify+test/tools/blit_memo_sweep.sh— soundness gate for blit memoization (virtualjaguar_blit_memo, #411,docs/blit-memo.md). Verify mode never skips: runs every would-be skip live and compares write log + post-launch state. Run before addingvirtualjaguar_blit_memoto any titledb row. Exit 3 = "thin" (too few checks) — a title that never repeats a blit stream is unverified, NOT clean; drive into gameplay with--press. No public ROM repeats a stream → no CI gate; private-corpus sweep is the gate.test/tools/test_blitter_compare— fast vs accurate blitter diff. Not in defaultmake:cc -O2 -Wall -std=c99 -I./libretro-common/include -o test/tools/test_blitter_compare test/tools/test_blitter_compare.c -ldl. Usage:<core.so|.dylib> <rom> [frames] --load-state <file> [--frame-window F L] [--cmd-filter MASK VAL] [--verbose-dump](note--load-state, not--savestate).test/test_dsp_mac40.c— DSP 40-bit MAC accumulator (dsp_acc40.h).test/tools/dram_scale_sweep.sh— gate for any GPU/68K timing-model change (#406). SweepsVJ_DRAM_SCALE1..16 withdram_timing+gpu_pipeline_timingon; asserts a per-window liveness floor on Doom's demo (default 30 flips per 300 fields; healthy 90-150), not merely "flips != 0". Exit 77 = private ROM absent (skip, never silent pass). Timing wedges relocate rather than disappear — a single-scale run proves nothing (#406 wedged at scales 4,8 before Verilator constants, at 3 after). Env:VJ_SCALES VJ_WINDOW VJ_MIN_FLIPS VJ_WARMUP_W.test/tools/test_doom_pace_gate+test/tools/test_cf_canary— timing-campaign S0 gates (docs/timing-campaign-plan.md, #408/#401), run bymake TEST_EXPORTS=1 test, ROM-gated (exit 77 -> recorded SKIP). Tracking gates: expected values live intest/tools/timing_gate_baseline.txt= today's numbers beside the silicon target; a regression (moving AWAY from the target, liveness breach) fails, movement TOWARD it printsMOVED-TOWARD-TARGET(re-baseline in the same PR).- Doom: attract demo, fields 600..1500, prints
DOOM_PACE_RESULT(mean fields/flip, gap histogram, per-300-field flips, GPU busy-time/flip). Silicon bar 4.0 fields/flip (#434, band [3.5,4.5]); develop = 1.993 (gaps 1:4 2:446 3:1). Pins the FAST blitter (census config); echoes options. Busy-time = coregpu_busy_*census (src/tom/gpu.c, TEST_EXPORTS only): cycles outside Doom's three spin loops (windows in the tool, none insrc/), normalised by cycles/field (442,718 = GPU saturated). The only instrument that moves below the game's 2-field floor. Cross-checks:--option virtualjaguar_risc_idle_skip=disabledmust give identical numbers;--no-armleaves the census off (overhead timing).--measure-only --print-baselinere-measures. - Checkered Flag: loads
states/Checkered Flag (1994).state.auto(rzip, extracted by the Makefile), no scripted input. Reads the frame-cap budget$43B0and fields per game frame ($41ACis a 32-bit counter). The budget is a game-mode variable, not a load output (0 boot, 1 first menu, 2 race; reads 2 here), so the gate is fields/game-frame vs cap (budget+1): 3.000 today; FAIL > baseline+0.25 (all three timing options: 5.4; pipeline alone: 4.9) or if the budget rises above baseline; WARN on more overrun frames than baseline (plan 5.3: budget-1/overrun in heavy scenes needs reporter/BigPEmu corroboration).
- Doom: attract demo, fields 600..1500, prints
test/tools/bus_census_probe+test/tools/contention_model.py— timing-campaign S2 bus census (not inmake test; build withmake TEST_EXPORTS=1 BENCH_PROFILE=1 test/tools/bus_census_probe— BENCH_PROFILE only for blitter phrase counts, accurate blitter pinned by the tool). Core side:vjtrace_bus_*(src/core/vjtrace.c, VJ_TRACE only, release objects byte-identical): per-halfline 68K bus cycles/clocks, GPU external transactions, DSP 16-bit transactions, OP occupancy (model rule, UNGATED by dram_timing), RELEASE/BUSHI/DMAEN counts, split work/spin by--m68k-win/--dsp-win/ gpu_busy windows. Writes per-halfline CSV (--out) + per-field flips (--frames-csv) + hot PCs with code bytes;--disasm-gpu/--disasm-dsp ADDR:N. The model script multiplies that traffic by the fixed latency function (plan S2,gpu-timing-spec.md§15).VJ_S2_INJECT=1|2|3(low/central/high, VJ_TRACE builds) auto-arms the census and charges GPU external accesses that function so any tool (S0 gate, CF canary) sees the result — a one-shot cross-check, never a tuning knob. Doom 68K wait loops:9C80:9C8A 4662:4670(GPU done flag),E36A:E386 E30E:E336(dspfinished),12F74:12F80 4670:4688($279D4); DSP idleF1B092:F1B0A0.test/tools/corpus_ab_sweep.sh— A/B two cores across a ROM corpus, comparing the full framebuffer hash stream:corpus_ab_sweep.sh <base-core> <new-core> [rom-dir](FRAMES,TMO,VJ_ROMS). Use it before merging anything that touches rendering. Two guards, both earned: every run is under a timeout (Chroma-Luma Color Pick hangsretro_runforever, #659 — the first sweep of this kind wedged on it for 2h23m emitting nothing, which is how #641's regression reached develop), and it compares the hash stream rather than the transition count (Super Burnout reads 143 transitions on both sides of #632 while rendering different pixels, so a count comparison calls a real change "no change"). Exit 1 on any SKIP: an unchecked ROM must not look like a clean sweep.AUDIO=1also hashes each frame's audio (frame_hash_ab --audio-hash) and reports video/audio differing-frame counts -- use it for any DSP/audio change;ARGS="--bios --option ..."passes extra args to both arms. Only top-level ROMs in the dir are swept: for a parallel run, symlink chunks of the corpus into scratch dirs (never into the ROM tree).test/sram_test.sh— SRAM round-trip.test/tools/cd_boot_matrix.sh— per-title CD boot-stage matrix (HLE + BIOS) vsdocs/cd-boot-matrix.md. Env:CD_MATRIX_FRAMES CD_MATRIX_TIMEOUT CD_MATRIX_MAX_RUNS CD_MATRIX_LOGDIR CD_MATRIX_OUT CD_MATRIX_ROMS_ROOT; chunked/resumable. Rows stamped with build id (<!-- build:<rev> -->); resume skips only same-build rows, re-runs others. (Resuming from an older build used to resurrect ancient rows as "fresh" — the phantom Battle Morph bios? (pc_escape)final_pc=$8FBFB758was such a stale row.)test/tools/cart_boot_matrix.sh(cart counterpart; classifier intest/tools/cart_classify.sh, probecart_boot_probe.c): BIOS-mode rows are scored from the boot-ROM -> cart handoff (--post-handoff N, capCART_MATRIX_BIOS_BOOT_FRAMES, default +700 frames), classesGAME_CODE/BIOS_REJECT(boot ROM halted onBRA.S *, never handed off: red "Jaguar" screen, an authentic reject)/? (bios_trap)(cart ran, PC fell back into$E00000)/? (...)/LOAD_FAIL.bios_ran=0(headerless RAM-loaded.jag, e.g. Music Demo) has no boot phase: whole run scored. Every row ends in an<!-- id: size= crc32= hdr_byte0= filedb= ... -->identity stamp (#976: the probe'sjaguarMainROMCRC32, byte 0 of the image at$800000, the filedb row) that the site reads to say WHY the boot ROM rejected a file and to fold same-byte twins into one row;test/tools/cart_matrix_identify.py docs/cart-boot-matrix.mdbackfills or--checks the stamps from the files alone (no core),scripts/test_build_site_cart.pypins the labels. Crash-watchdog signatures are never handoff-relative (an early escape is a real bug, #853).test/tools/matrix_diff.py OLD.md NEW.md— never-backward gate (#749) between two copies of the same matrix (cart or CD): prints regressed / still-asymmetric / improved, exit 1 on any backward stage move or new crash-watchdog signature, 2 on parse error.BIOS_REJECTranks equal toGAME_CODE(GAME_CODE -> BIOS_REJECT = lateral scoring correction; HLE GAME_CODE / BIOS BIOS_REJECT is not an asymmetry). Release-time, corpus machine only (docs/release-process.md). Self-testtest/test_matrix_diff.shruns inmake test.- CD trace ring: option
virtualjaguar_cd_trace(or envVJ_CD_TRACE=1) recordsDSA_TX/DSA_RX/SEEK_START/SEEK_DONE/FIFO_FILL/FIFO_DRAIN/STOP/HLE_READ; dumped oncd_seek_wedgeor on request. test/tools/cd_wedge_probe.c— catches intermittent CD lockups: detects a frozen framebuffer (--arm N --freeze-frames N, script repro with--press), dumps 68K regs, pcQueue traceback ring, CD counters + trace ring, RAM hexdump around stuck PC; exits 42 when caught.--ram-dump BASEwrites full 2MB main-RAM + GPU-RAM at wedge;--snap FRAME(repeatable,--snap-prefix BASE) for clean-vs-wedged diffing. Found Hover Strike instant-CD_read code-stomp and B-skip stale-GPU-IRQ-latch corruption. Caveat: a static loading screen can false-positive short freeze windows — confirm with--freeze-frames 900.test/tools/cd_visual_verify.c— automated visual+audio verify for CD titles (make cd-visual CD_VISUAL_DISC=<image.cue>): per-second frame-motion timeline, non-black coverage, audio RMS, periodic PPM screenshots (sips -s format pngto view). Replaces most "boot it and look" checks. Headless read-path caveat still applies for final sign-off.test/tools/netlink_discover_probe.c(--selftest) — validates LAN link-discovery beacon (src/jerry/jlink_discover.c). Lives inside the core-adjacent binary on purpose: macOS grants Local Network permission per binary, so a probe underpython3's identity can pass while the shipped binary is denied (bit the #500 CI fix once).netlink_discover_pair.shdrives two instances, PID-spreading the discovery port (VJ_DISC_PORT,JLinkDiscPort()) so parallel CI runs don't collide onJLINK_DISC_PORT(42170).--selftestexits 77 when same-host broadcast isn't available in the sandbox (some macOS CI runners) — a skip, not a failure.netlink_rebuild_witness.cproves theSET_CORE_OPTIONS_V2rebuild path (option-visibility re-push on link-mode change) executes.test/tools/netlink_sim.c— deterministic two-instance RetroArch-netpacket simulator (virtual clock, not part ofmake test). Measures the netlink reply wait without host-load noise: a core built from a THROWAWAY worktree patched bynetlink_sim_clockpatch.py(clock/sleep + byte-event hooks, never applied tosrc/). Doom 2P deathmatch tics/s is the headline number: a tic is 12 one-way single-byte hops (6 per side) and must fit a whole number of video frames, so rates sit on 60/k plateaus (30/20/15/12 tics/s). Knobs:--delay-ms/--jitter-ms/--spike-pct/--spike-ms,--loss-pctwith--rto-ms(TCP) or--udp-rto-ms(UDP+retransmit model),--emu-ms,--pump-us(cost of onepoll_receive; results are sensitive to it, keep it identical across arms),--audio-block-ms(frontend cost AFTER the core's frame-end hook),--btrace(per-byte link events),--log(core log incl. the 5 slink statsline),--corrupt N:XX(flip one in-flight byte). Every arm of a comparison must use the same flags;--emu-ms 2is optimistic for Doom at 2x, also run 6.
vjtrace flight recorder (dev-build-only)
Event ring + memory watch (src/core/vjtrace.h/.c): make TEST_EXPORTS=1 defines
VJ_TRACE; plain make compiles every VJT_* to nothing and exports zero vjtrace symbols
(costs nothing shipped). Every harness inherits tracing via trace_probe_attach()
(test/harness/trace_probe.h; test/tools/vjtrace_smoke.c = generic driver). Flags:
--trace-out FILE (binary ring at exit), --watch A[:LEN][:r|w|rw] (memory watch, max 16),
--field-csv FILE (one row/frame: IRQ/GPU/OP/blitter counters, pad state, FB hash),
--snap FRAME (VJSN snapshot), --mark FRAME:TAG (host marker event).
Analyzer CLIs (offline, no core/harness dep): trace_dump (print/filter a ring by
--type/--who/--frame), trace_diff (structural diff of two rings, tolerant of frame offset),
trace_memdiff (diff two --snap VJSN files by section), field_diff (row/col diff of two
--field-csv). test/tools/vjtrace_selftest.sh is part of make test.
Ring sizing: default VJ_TRACE_RING = 1<<20 records; fills fast (OP_OBJECT/OP_LIST_START
emit unconditionally, ~2,578 events/frame on test/roms/yarc.j64, ROM-dependent). A 1800-frame
run needs ~4.6M records → set VJ_TRACE_RING=6000000. Both trace_dump/trace_diff print
WARNING: ring wrapped to stderr when a dump lost events — don't treat a clean-looking dump as
complete without checking.
Coverage caveats (authoritative in vjtrace.h WATCH COVERAGE block): GPU/DSP writes to their
own local RAM ($F03000-$F03FFF / $F1B000-$F1CFFF) never route through watched dispatch —
invisible to --watch; only accesses through JaguarRead/WriteByte/Word/Long and the 68K bus
fast path are covered. A 32-bit 68K access = ONE record for main RAM/cart ROM but TWO (at addr
and addr+2) for TOM/JERRY/CDROM/unknown → always round a watch's low bound down to a multiple
of 4.
Audio / DSP work — required tests
Any change to src/jerry/dac.c, src/jerry/dsp.c, the HLE BIOS DSP/audio path in
src/core/jaguar.c, or the DSP IRQ return-address logic MUST clear BOTH audio tests. A
clipping check alone is insufficient: PR #170 (closed) took Iron Soldier 2 from 17% saturated to
RMS=521 (silent) and the clipping test passed because silence has 0% saturation.
Required before declaring an audio change done:
make TEST_EXPORTS=1 testexits 0. Bothtest_audio_clippingandtest_audio_presenceare in the suite. Presence on IS1 uses develop's envelope (--rms-floor 200 --rms-ceiling 25000); if your change moves IS1's RMS outside that band you've changed audio behavior — verify intent.- Sanity-check that previously-clipping titles (Skyhammer, IS2) didn't go loud-broken → silent-
broken. Both fixed as of 2026-08-05 by the MMULT secondary-bank fix; both assert clean.
Skyhammer
(World).j64: 0.000% saturated, window RMS 3079.8, first audio frame 171. - Verify in RetroArch on a real game. Headless can't tell music from structured noise at the right RMS, nor catch BIOS-mode crashes (PR #170's BIOS crash + HLE silence were invisible to the suite).
Do not relax thresholds in test_audio_clipping.c / test_audio_presence.c to pass. A real fix
that legitimately quiets a known-broken title is a separate deliberate baseline update — call it
out in the commit.
Runtime crash watchdog (src/core/crash_detect.c)
Runs once/frame from retro_run, logs to RetroArch log on these signatures. Option
virtualjaguar_crash_detect = enabled (default) / disabled / verbose (adds a state
heartbeat every 600 frames). Cost enabled: one indirect call + ~256-px hash/frame.
gpu_pc_escape— GPU PC outside[$F03000,$F03FFF] ∪ [$0,$E3FFFF](matches JaguarReadX decoding: main RAM mirrors bottom 8MB, cart ROM, boot ROM).dsp_pc_escape— DSP PC outside[$F1B000,$F1CFFF] ∪ [$0,$E3FFFF], tested on the raw PC (no 24-bit mask, unlike the GPU): DSPExec's own bail-out refuses a high-byte PC, so it executes nothing. The mask hid Music Demo's HLE runaway to$C1903000(#853).inframe_hang— fired at blit dispatch (not per frame) whenB_COUNTasks for >2^24 pixels, counted the way the accurate (default) engine runs it: outer 0 = 65536 lines (JTRM), inner 0 = one step (≤64 px — INNER.NET's zero/underflow detect, whichBlitterMidsummer2matches; Williams/Telegames carts writeB_COUNT=0at boot and run fine). Also fires withnever_ends=1for a blit whose inner counter never decrements: phrase mode below 8bpp, where INNER.NET's decrement isdstxp[0]and phrase-aligned X keeps it 0 — real hardware never finishes it either (#800).approx=1marks a hang whose memory effect below is only a one-pass approximation (BlitterNeverEndsApprox). Policy: hang the emulated blitter, never the host -- the accurate engine runs the stuck step for one destination wrap period (exact when the step is idempotent on memory), thenB_CMDreads busy and further starts are ignored until reset (test/tools/test_blitter_hung; saved in the v16"BLH1"chunk). Known limit: on hardware aB_COUNTwrite while hung would un-stick it; not modelled. Absurd-size blits are still run synchronously and can keepretro_runfrom returning for hours; this line is logged before the blit runs. No real-ROM reproducer remains: Music Demo (ScatoLOGIC)--bios(#794,approx=1) was a misloaded raw binary (fixed by #818, nowtest_raw_binary_boot), and Native Demo (bin) HLE reached the blit only through a GPU runaway into data that the #611 NORMI fix reshuffled. The synthetic test covers both engines. Matrix runs killed at the wall-clock cap carry it in their evidence column.- Seen as garbage blits from a runaway 68K whose exception frames land on the blitter's
$F0A238mirror. Uploaded executables (raw BJL/.abs) that end in ILLEGAL used to do this in HLE; JaguarReset now parks vector 4 at$1000(test/tools/test_upload_illegal_park, #800). - A raw binary parked at
$1000early is often a wrong inferred load base, not a bad title:file.cpicks between overlapping candidate bases by entry-point consistency (JSR targets right after an RTS).test/tools/test_raw_binary_boot --expect-basepins it (#818).
- Seen as garbage blits from a runaway 68K whose exception frames land on the blitter's
test/tools/cart_boot_matrix.shruns the probe's default Fast blitter. Sweep the shipped default withCART_MATRIX_PROBE_ARGS="--option virtualjaguar_usefastblitter=disabled"plus an explicitCART_MATRIX_OUT(args are folded into the row cache id and default log dir).gpu_wedge/dsp_wedge— flagged running but zero opcodes for ≥180 / 600 frames (gpu_exec_opcode_count/dsp_exec_opcode_count). A stable sampled PC alone is NOT a wedge: deterministic slice budgets land the per-frame PC on the same instruction of a healthy wait/spin loop (Super Burnout spins ~446k GPU ops/frame at one sampled PC — the #378 false positive; regressiontest/tools/test_wedge_spin).video_stall— FB hash unchanged 300 frames while a processor runs.cd_seek_wedge— FIFO drain frozen 300 frames while a processor runs AND the drive still owes data: a seek outstanding, or a transfer still open (BUTCH low byte bits 0+1 = master + FIFO IRQ armed, AND I2CNTRL bit 2 = FIFO data enabled); dumps the CD trace ring. A game ends a transfer by clearing BUTCH bit 0 (Myst, Primal Rage, BrainDead 13 …) or I2CNTRL bit 2 (Philia); those idle drives used to fire it (#741). The line printsbutch_int=/i2s_ctrl=. Pinned bytest/test_crash_detect_cd_wedge(CI) +test/tools/cd_seek_wedge_regress.sh(private discs).test/test_cd_bios_bootFAILs a disc when the watchdog fired (CrashDetectCDSeekWedgeFires(), sticky,[CD-WEDGE]line) -- its PC checks alone pass on a dead transfer (#799).
Triaging "X crashes/hangs/black screen": the RetroArch log shows the signature — no save state
or input recording needed. Add new signatures here when you find a recurring failure mode not
already covered; don't sprinkle one-off LOG_ERR across subsystem files.
Acid suite CI gating
"Acid suite (linux x86_64)" can show conclusion: failure for two unrelated reasons — read the
summary first:
make -C test/acid testexits non-zero by design (returns FAIL count). Job usesset +eand gates oncheck-baseline.py. Real regression =Regressions: N(N>0) inacid-summary.txt.- Conclusion
failurewithOK: no regressionsin step output = the artifact-upload step timed out (Operation could not be completed within the specified time). Re-run the job; no code action.
Performance / profiling
make benchmark runs test/tools/test_benchmark headless vs a fixed ROM (default
test/roms/yarc.j64, 600 frames), prints FPS / ms-per-frame. Use as a same-host
commit-to-commit delta — don't compare across machines. Full guide: docs/profiling.md
(Instruments / perf / flame graphs, SIMD A/B knob).
Measurement hazards: concurrent make swaps the dylib mid-sweep; sequential A/B measures
host drift (an -O3 "+12.5%" flipped to -11.7% when interleaved) — always A/B/B/A, check uptime
first.
Frame-time distribution (stutter, not average speed): test/tools/frame_cost_probe gives the
per-frame ms CSV + p50/p95/p99/max and over-budget counts. An armed vjtrace gates the GPU/DSP idle
skip OFF (idleSkipActive), so the default run measures skip-OFF whatever risc_idle_skip says;
pass --no-arm to keep the skip live (event columns then read 0 — pair with an armed run of the same
config and join on frame, the emulation is deterministic and the skip bit-exact). Feed the CSV to
app/tests/pacing_trace.cpp to see what the app's pacer does with that cost trace (uneven image gaps,
vsync release/restore cycles); virtualjaguar --perf-frames N with VJ_PERF_CSV=f.csv records the
real thing (iter_ms,emu_frames,swap_sync,underruns,input_lat_ms,present_lat_ms; present = the swap that first shows a frame which polled the event; --perf-input injects a key every 30
iterations so the latency column has samples; --emu-thread on|off picks the loop, #967). --option k=v lists in an ssh command run under zsh must be built in bash (zsh does not
word-split $OPTS; the option is then silently not applied and the harness prints no option line).
App emulation thread tests (#967)
The CTest label emu_thread (app/cmake/VJEmuThreadTests.cmake, plus the RA twins and ach_event_unit in
VJAchTests.cmake) is what the ThreadSanitizer CI job runs. Locally the label runs on the normal build:
ctest --test-dir app/build -C Release -L emu_thread. The CI job (tsan in .github/workflows/app.yml) configures
app/build-tsan with -DVJ_APP_TSAN=ON -DVJ_APP_LTO=OFF (clang, RelWithDebInfo) and runs
TSAN_OPTIONS="halt_on_error=1 second_deadlock_stack=1 suppressions=app/tests/tsan.supp" ctest --test-dir app/build-tsan -C RelWithDebInfo -L emu_thread --output-on-failure
(Linux only; sudo sysctl vm.mmap_rnd_bits=28 first on a new runner kernel). Phase 2 added, on top of the phase 1 units and smoke_*_threaded:
- Units:
core_events_unit(the core-event queue: order, bounded log lines, content switch, two threads) andach_event_unit(AchEvent, an rc_client event copied across threads; inapp/cmake/VJAchTests.cmake). - RetroAchievements twins: every RA test is registered through
vj_ra_test()inVJAchTests.cmake, which adds<name>(inline) and<name>_threaded(same run with--emu-thread on, labelemu_thread; fixture names and the saved-cheat data dir get a_threadedsuffix so the chains never share state). A test withPASSis a control: its twin fails only onowner violationor a TSan report.ra_reply_owner_threadedis threaded-only: login/game-load replies must run on the core owner, serialized with frames. It requires the mock login/load/session requests and rejects off-thread address reads.ra_ui_client_accessand its threaded twin overlap owner-side hardcore/reset/evaluation commands with copied dashboard/profile/Discord snapshots, achievement rows, and image URLs (--ra-step FRAME:ui:stress). They also exercise unofficial/enabled toggles and password sign-in, and retain copied UI data across signout. Without the shared client lock, TSan reports a race between the hardcore getter and setter. UI code must never retain rcheevos pointers: useclientSnapshot()or the copied rows/URLs. - Phase 3a (the Tools windows on the emulation thread):
debug_views_unit(the request table, the buffers and the epoch; concurrent set/fill/read under TSan), the lane cases inemu_commands_unit/emu_thread_unit,debug_views_threaded(the REAL core on a threadedEmuThread, the session wired asAppwires it: views equal adebugCallpeek, a paused request fills with no frame run, the first read after a reload is the new ROM, inline == threaded for the Dram view, hardcore detaches),debug_on_threaded/gdb_enabled(_threaded)(nothing is switched off while threaded; the silent gdb host attaches at load) andgdb_hardcore_locked_*/gdb_hardcore_flip_*(hardcore locks the stub; a mid-game flip reloads). Each window's smoke test draws from view buffers only and pins zero core calls from the UI side (app/tests/debug_ui_fixture.h). - Phase 3c (Neuro on the emulation thread): Neuro runs threaded, pinned by
neuro_on_threaded(vj_neuro_test app ... on: a fake server, a 30-frame press,30 frames of input playedandemulation thread)),neuro_watch_threaded(a profile watch read through theDebugViewsrequest, the cheat byte in the state line) andneuro_loopback_threaded(the loopback run with the input poll on its own thread; its frame-numbered press vector must equal the inline run's).neuro_core_e2epins the replica against the real core inline, in lock-step and free-running. - Phase 3b (the halt and the GDB stub on the emulation thread):
debug_session_unit(now labelemu_thread: the verdict serial,frameEnded/stepping_, detach and quit mid-halt, Detach and Attach ordered through the lane),emu_thread_unittestCallGivesUp/testAdvanceGivesUp/testServeDebugLane,emu_commands_unittestDebugLaneWait,debug_e2e_threaded(the REAL core on a threadedEmuThread, the rig wired asApp: break / step / continue / detach / hardcore / quit mid-halt from a UI thread that never blocks over 50 ms; it opens a free TCP port for the stub),gdb_threaded_halt(Python gdb client: the threaded headless app halted at boot,S05, memory read, continue; SIGTERM mid-halt exits 0; needs Python3;gdb_inline_halt/gdb_inline_halt_hardcoreare the inline runs),debug_on_threaded,gdb_enabled(_threaded),gdb_hardcore_locked_*andgdb_hardcore_flip_*.emu_thread_start_fallbackno longer expects anything to be "back on" (nothing is switched off while threaded) and fails on "are back on" / "are off while". - Phase 3d / Gate G1:
no_threaded_limits(static,app/tests/no_threaded_limits.sh: the six checks -- removed gates, "off while threaded" texts, the old limit tests and their replacements, the GDB-stub lock keyed on hardcore only, thethreaded_branch_allowlist.txtcounts,tsan.suppsize; raise a count only together with the new line's reason),emu_thread_default_unit,emu_thread_default_smoke_{unset,false,true,flag}(four because a headless run is inline, so the stored-choice line pins the rule;_flagis the threaded one; expectations derive from the configured channel), theLinux x86_64 (nightly channel, Neuro off)row in app.yml, andemu_thread_gate_check.sh(Studio only,VJ_WINDOWED_OK=1,--show-window tools,--perf-halt, ports from the OS). Runs: A every tool open + F9 halt (rows 130-410 parked, the window presents; Continue; a Neuro client answers presses), B a gdb halt at boot held 6 s (a parked run >= 200 rows, window presents, frames resume), C the plain path (>= 250 of 600 UI frames show an emulated frame). Each run wakes the display first; a still-asleep display skips the run ("display asleep, run skipped", exit 3), which is the environment, not a hang.studio.sh -D...args persist in app/build's CMakeCache: follow a nightly/release configure with-DVJ_BUILD_CHANNEL=dev. - Audio scope:
debug_blitter_unitalso has theemu_threadlabel. Its concurrent producer/reader/clear test covers the sample ring under TSan; the UI open flag is atomic. Windowed TSan hardcore toggles are still required: they exercise the scope close callback and a freely running RA client while the UI pumps replies. - Netplay:
netplay_pair_threaded,netplay_pair_voicemodem_threaded,netplay_pair_mixed(one threaded copy linked to one single-thread copy) andnetplay_no_partner_threaded(a copy quitting with no peer exits 1, not 3). They areRUN_SERIALand real-time: a "no link within 30s" under heavy host load is load, so re-run them alone before reading anything into it. - Busy spinner:
smoke_busy_threadedruns withVJ_TEST_SLOW_CALL_MS=400(the loadcall()sleeps that long on the emulation thread, max 5000) and expects[emu] busy: a call has taken over 250 ms. - Failed start:
emu_thread_start_fallbackruns withVJ_TEST_EMU_START_FAIL=1, which takes the real failure branch ofApp::startEmuThread: the run falls back inline.
GL qualification (desktop app, windowed, every shader preset)
.github/workflows/gl-qualification.yml (#1155) boots app/ windowed with a real OpenGL
context on hosted runners, once per bundled .slangp, and keeps the screenshots. Legs: Linux
(ubuntu-24.04, Xvfb + Mesa llvmpipe), Windows x64 (hosted, no vendor GL: the app must relaunch
beside the bundled MSYS2 Mesa, #1138/#1143), Windows arm64 (same, plus the portable test build
Store testers used), macOS arm64 (Apple OpenGL 4.1). Runs on PRs touching the GL/shader path,
nightly, on dispatch, and release.yml requires it on tags and release/**.
Per leg: one build, cmake --install into stage/, the preset loop (below), then
vj_shader_tests gl run directly after it (exit 77 = skipped = failure here; everywhere else
it is -E shaders_gl; the step is continue-on-error and a "shaders_gl verdict" step fails the
job, so the screenshots exist even when the test is red, and a test that has to be killed cannot
wedge the window server before the loop). The loop:
app/test/gl-qualification/gl_qualify.py launches stage/bin/virtualjaguar --shader <preset> --screenshot <png> --screenshot-frame 20 --screenshot-emu-frames 60 test/roms/yarc.j64 for --shader off
and every preset (the capture waits for 20 UI frames and 60 core frames, so it is the game's picture
however fast or crowded the host is).
Per (leg, preset) it asserts: exit 0 before the timeout; the [gl] version=...; renderer= line
matches the leg (llvmpipe / llvmpipe|D3D12 after automatically retrying with bundled Mesa OpenGL / Apple); the PNG decodes and is not black; a [shader] <preset> built line
(shaders.cpp); the picture differs from the plain capture unless the preset is in
identical-to-plain.txt. After 8 consecutive failed presets the loop stops (--fail-fast; the
summary says how many were not run), so a leg with no working runtime fails in minutes. A
[shader] ... refused line (macOS OpenGL 4.1 has no
GL_ARB_arrays_of_arrays), or a ... failed line for a preset the leg's GL cannot compile
(packHalf2x16 on macOS), passes only for presets in refused-<os>.txt, so that set can shrink but
never grow. The macOS leg runs in a 640x480 window (small-window.ini, --seed-config): the hosted
display is too small for 960x760 (the window came back 960x642, ~45 s a launch), and a seeded 480x360
window captures uniform white while ImGui's extra OS windows are on (the default; off, or at 640x480
and up, it captures the game), which its "Capture diagnostics" step keeps showing.
The macOS leg is sharded: Apple's software renderer (hosted runners have no GPU) takes minutes
over a heavy chain's first frame, so one runner cannot render ~500 presets in a job. Three macos
jobs each take the presets whose sorted index modulo 3 is their shard (--shard I/3, each with its
own off baseline, 2 launches at once); the GL probe, the capture diagnostics and shaders_gl run in
shard 0 only; macos-summary joins them (gl_qualify.py --merge ... --expect-shards 3), failing if
a shard is missing or the shards did not run every preset exactly once, and uploads the one
gl-qualification-macos-arm64 summary (screenshots and logs stay in the
gl-qualification-macos-arm64-shard-<n> artifacts). A preset that alone takes over SLOW_LIMIT
(120 s) there is a macos-arm64 slow <measured>@<run> <preset> line in known-failures.txt: it is
stopped at the limit and listed as a known failure ("chain built, not rendered" / "no chain"), not
a checked picture. An unlisted preset fails only if it does not render within the 240 s timeout; one
that renders but takes over 120 s is a ::notice:: with its line to paste and a count in the summary
(hosted-Mac times swing ~2x between runs, so a hard 120 s gate failed new presets every run); a
listed one that renders in time is checked like any other (the summary counts both; drop entries
that stay well under the limit). A launch with no [gl] line is retried once and never counts as
slow. On macOS such a timeout leaves a stack sample (logs/<preset>.sample.txt, sample(1)), and
the retry first restarts Apple's GL compile service (--reset-cvms: sudo -n killall -9 CVMCompiler CVMServer; launchd starts a fresh one). Three runs (41760278887, 41891214865, 41957577821) had every
launch stall for ~15 minutes in the vectorscale/xbr stretch of the list; the samples all end in
CGLChoosePixelFormat -> cvms_connection_create, an XPC call to CVMServer that never answered. Each folder has times.tsv (seconds per preset) and the summary a "Slowest
launches" table. Goldens are a second pass: app/test/golden/gl/<os>/<preset>.png is
compared and reported (not gated) when present; the refresh_goldens dispatch input collects
candidates into the artifact.
Reading a failure. The job summary lists failed presets with the reason and the last [gl] /
[shader] lines. Download gl-qualification-<leg> (always uploaded): shots/<preset>.png
(/ in the preset path becomes __), logs/<preset>.log (the full stderr of that launch),
results.json, summary.md, plus the leg's probes (glxinfo.txt, gl-context-integration.txt,
shaders_gl.txt; Windows adds native-driver*.txt, automatic-stderr.txt, legacy-loader.txt).
no \[gl] version=...`line → the app never got a context: read the log head (SDL error) and the probe files; on Windows checknative-driver*.txt` and whether the retry line is there.renderer ... does not match→ the leg landed on the wrong driver (e.g. Windows picked WARP instead of llvmpipe/d3d12; Linux lostLIBGL_ALWAYS_SOFTWARE).shader failed: ...→ librashader's compile/link message; the preset or the runtime pin broke. One preset = that preset; all presets = the chain/loader (look atshaders_gl.txttoo). Presets the bundled librashader rejects on every GL live inknown-broken.txtwith the reason (they must keep failing: one that starts building is reported so the list shrinks).refused and not in the allowlist→ a preset newly needs a GL feature the leg lacks: fix it or, if genuinely platform-limited, add it torefused-<os>.txtwith the reason.no \[shader] ... built` line→ the chain never ran: the game had not drawn by frame 20 or the preset was not enabled; compareshots/off.pngand the log's[emu]` lines.identical to the --shader off capture→ the preset changed nothing; either the chain fell back to the plain presenter (check for a[shader]line) or it is a true no-op at this scale (list it).screenshot is black→ the window drew nothing (lost context, hidden window, swap failure).known-failures.txtholds tracked failures, one line each; nothing else on that leg is forgiven.macos-arm64 slow: presets Apple's software renderer cannot render within 120 s (above). The Windows exit hangs are fixed and no longer listed: the app's (#1167, by #1172) and the two GL test programs' (#1179, by #1181); a Windows process that does not exit on its own fails the leg.- Windows "Automatic Mesa fallback verdict" red → the stock-Windows path (#1138) regressed or the
boot did not come back:
automatic-seconds.txthas the exit code, the seconds and which of the checks failed (the retry line, therenderer=line,config.iniin the parent's data dir, nosaves/instance-2);timing.txtthen has three bounded experiments (the Mesa-side child run directly, the same with--emu-thread off, the same withvideo.vsync=0) that split "the parent never returns" from "the window never finishes a swap". The preset loop is skipped on that leg until the plain boot works. Ifnative-driver-gl33.txtends in exit 0 the runner image has grown a real GL and the fallback is not exercised there (skipped with a warning); arm64 is then the proof.
Headless framebuffer caveat
The miniretro harness in test/regression_test.sh doesn't expose the same composited
framebuffer RetroArch reads. Symptom: jag_240p_test_suite main menu shows ~1k non-black pixels
via miniretro vs tens of thousands via RetroArch. Treat as a headless read-path / presentation
bug (OP+blitter output vs what the host reads), not a 240p timing or __muldi3 perf bug.
Verify against RetroArch before treating a regression as real.