* test(app): no_threaded_limits keeps every feature on while the emulation thread runs (#967 G1a)
Fails when a threaded-only gate, its user-facing text or its limit test comes back, when the GDB stub is locked for any
reason but hardcore, when a file starts asking "am I threaded?" beyond its allowlisted count, or when tsan.supp grows.
Verified red on the pre-3a tree (abc9054e) and green on develop.
Refs #1079
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(app): Studio gate for the emulation thread with every feature on (#967 G1b)
--show-window tools opens all eight Tools windows, --perf-halt does F9's break and continue at fixed UI iterations, and
emu_thread_gate_check.sh asserts no owner violation, a window presenting at display rate while halted by F9 and by gdb
(ports from the OS at run time), a connected Neuro client answering presses, all with --emu-thread on.
Refs #1079
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(app): no_threaded_limits counts bare threaded() and fails on under-counts (#967 G1a fixup)
Check 5 now also counts the bare threaded() calls inside App and CoreHost, counts occurrences instead of lines, and fails on
an allowlist that is too loose; the over-broad "Off while" text pattern is anchored to the old Settings string; commented-out
add_test lines and ==/!= comparisons no longer confuse checks 3 and 4.
Refs #1079
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(app): build channel and the emulation-thread default rule (#967 3d)
VJ_BUILD_CHANNEL (release | nightly | dev, default dev) sets VJ_EMU_THREAD_DEFAULT (1 for nightly), next to VJ_TV_DEFAULT.
The default applies only while ui.emu_thread is unwritten: an explicit Off stays Off in nightlies and an explicit On stays
On in releases (emu_thread_default_unit). Nothing is built with the nightly channel yet.
Refs #1079
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(app): show the build channel and the emulation-thread default (#967 3d)
Settings > Interface says which way the default goes in this build, About names the channel, and the start-up log reports
channel, default, the stored choice and this run. The smokes derive their expected output from the configured channel, so
a nightly configure checks the On default and an explicit Off still winning; the inline hashes are untouched.
Refs #1079
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(app): the Studio gate wakes the display before each run and stays quiet after a kill (#967 3d)
A window created while the Studio display sleeps blocks forever in the GL swap, threaded or inline (soak-results.md), so the
G1c attempt-1 "hang" was the display. Before each of runs A, B and C the gate now wakes it (caffeinate -u -t 3), holds it
awake for the run (caffeinate -u -d -i, ended after the run) and re-checks system_profiler. Still asleep: the run is skipped and
reported "display asleep, run skipped" as an environment failure (exit 3 when nothing else failed), distinct from a hang.
A killed or skipped run no longer prints python tracebacks or "[: : integer expression expected": the CSV helpers answer
"no data" for a missing file, empty reads become 0, the per-run asserts that need logs are skipped after a watchdog kill,
and the shell's "Terminated" job notices are silenced.
Refs #1079
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(app): testServeDebugLane waits for the lane service to finish, not for a fixed sleep (#967 3b flake)
emu_thread_unit failed on GitHub's hosted macOS runner (run 39929091926) at "it waited about maxWait despite the frame post
(0.0 ms)". The test waited for the fake core to take its serveNow count, then slept 10 ms and read `waited`, which the
emulation thread stores only after the 4 ms lane wait returns; a thread descheduled in between left `waited` at its initial
0. Not the stale debug wake: nothing sets wakeDbg_ before that first wait (reopen() clears it, the frame post only sets wake_),
and a stale wake would make a wait of microseconds, not leave the value unset.
The test now counts finished rounds (stored after `served` and `waited`) and waits for the count, with a 5 s bound, and the
fake core sleeps 30 ms before serving so the old reading order fails every time. The assertion is kept; its upper bound is 2 s
instead of 0.5 s for a slow VM.
Refs #1079
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* ci(app): nightly builds default the emulation thread on (#967 3d)
app-package.yml derives CHANNEL from release_tag (nightly; v* -> release; else dev) beside TV_DEFAULT and passes
-DVJ_BUILD_CHANNEL to the macOS, Windows and Linux configures; the flatpak job rewrites the committed manifest's
-DVJ_BUILD_CHANNEL=release to the lane's channel. app.yml configures one Linux row with the nightly channel so the On
default is built and tested on every PR. Docs updated, including the design text the G1d review found out of date
(closed windows and Neuro's watch copier, frame advance while halted, debugCall waits, the unstated bounds).
Refs #1079
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
31 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. 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)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.
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.