231 Commits
Author SHA1 Message Date
Joe MattielloandClaude Opus 5.5 db90f8e3ac perf(risc): idle skip no longer switches off under blit memoization (#952) (#1118)
The GPU and DSP idle-loop fast-forward was off whenever blit
memoization was enabled (or in verify mode).  Only blitMemoRecording
needs to gate it: while a blit is being recorded, the JaguarRead
family notes every read, and skipped loads would change what the memo
records.  Recording is set only around BlitMemoLaunch's synchronous
engine run (a GPU store to B_CMD can launch one with GPUExec on the
stack, but no GPU or DSP instruction executes until it returns), so
the check is belt-and-braces.

blitMemoMode on its own leaves the read hook off, and the write hook
(main-RAM page generations) is never reached by an admitted loop body:
the DSP admits no store, and the GPU admits only long stores into GPU
local RAM, which GPUWriteLong serves without any memo hook.  The
skipped iterations leave the memo exactly as interpreting them would.

The [perf] suppression warning, the option sublabel's "Inactive"
note, the blit_memo option text and the settings guide no longer list
blit memoization as an idle-skip suppressor.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-28 19:00:39 -04:00
Joseph Mattiello f634be7a50 Merge release/v3.8.1 into feat/debug-wp1-iface 2026-10-13 20:34:50 -04:00
Joseph MattielloandClaude Sonnet 5.5 e873883e33 feat(debug): core side of the app debug interface (vjdebug.c) with socket-less halt pump
src/debug/vjdebug.c implements struct vj_debug_iface: side-effect-free peek
(JaguarReadByte DEBUGGER), policy-checked poke, both RISC register banks, RISC
disassembly, attach/halt-pump/breakpoints on the existing GDB halt machinery
(a host can attach without a socket; GDBHalt pumps it and never blocks when
nothing could resume it), a one-walk OP display-list snapshot taken before the
OP rewrites HEIGHT/DATA, a blitter launch log ring, TexReplaceReload, and the
frame counter.

The interface is handed over once via VJ_ENV_SET_DEBUG_IFACE, only to a
frontend that set VJ_CAP_DEBUG (RetroArch never does).  Detached cost: one
global test per OPProcessList call and one per blitter launch.  Statics reset
on unload and deinit.

test/test_debug_iface.c drives it on yarc.j64: who gets the interface, peek ==
jaguarMainRAM, the poke policy, halt/step/continue/detach through a scripted
host, breakpoint re-hit, OP snapshot, blit log, and an identical frame + RAM
hash with everything switched on.

Refs #652 Refs #833

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 20:23:06 -04:00
Joe Mattiello ec82de6b12 Merge pull request #908 from libretro/test/v3.8.1-timing
Timing campaign S0/S1/S2 into v3.8.1 (S1 experimental, default off)
2026-10-13 20:03:58 -04:00
Joseph MattielloandClaude Sonnet 5.5 d5adfdee61 feat(core): frontend BIOS queries, BIOS_STAGED events and a cart-ROM no-content boot
Adds VJ_ENV_GET_BIOS_PATH / VJ_ENV_GET_BOOT_TARGET behind a new VJ_CAP_BIOS
capability on the private frontend channel (ABI unchanged), so the standalone
app can name the exact CD BIOS / cart boot ROM file to run and choose which
BIOS a no-content boot enters (CD BIOS, or the cart boot ROM against an empty
slot).  The core still validates every file and falls back to its own search
and the built-in images; RetroArch never answers HELLO, so nothing changes
there.  VJ_EVT_BIOS_STAGED / VJ_EVT_BIOS_REJECTED say which image was staged.

Refs #838 Refs #833

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 18:37:37 -04:00
Joseph Mattiello 5063515947 Merge remote-tracking branch 'libretro/feat/timing-s1-dsp-scoreboard' into test/v3.8.1-timing
# Conflicts:
#	libretro_core_options.h
2026-10-13 18:06:53 -04:00
Joseph MattielloandClaude Sonnet 5.5 8b9065d86e feat(core): free-form clock scale, gdb timeout clamp, private frontend event channel
Parser-only emulation change: virtualjaguar_{m68k,risc}_clock_scale also accept
a percentage ("175" or "175%", clamped 25..400) after the listed values
(0.5x/1.5x/2x/3x), which map exactly as before; anything else the old ladder
did not know still resolves to stock. virtualjaguar_gdb_halt_timeout is clamped
to 3600 s.

New src/core/vj_frontend_ext.{h,c}: a RETRO_ENVIRONMENT_PRIVATE HELLO/EVENT
channel (ABI 1). retro_init asks once; a frontend that does not answer (RetroArch)
latches it off so no event is ever sent; retro_deinit resets the latch. Events
(LINK_UP/DOWN/STATS, WIRE_SPEED, CRASH_WATCHDOG) are emitted from retro_run next
to the existing log lines, which are unchanged. JLinkStatsLast() exposes the
numbers behind the stats line.

A typed link host that was never a picker row no longer draws the 'dropped off
the LAN' warning.

Refs #833 Refs #888

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 16:42:39 -04:00
Joseph MattielloandClaude Sonnet 5.5 86136035a4 perf(risc): run the idle-loop fast-forward under any RISC clock scale
riscClockScalePct != 100 switched virtualjaguar_risc_idle_skip off in
DSPExec/GPUExec (and was reported as a suppressor by libretro.c). It was a
precaution from the #569 design, not a found hazard: SCALE_RISC_CYCLES is
applied where the budget is handed out (JaguarExecuteNew), and the probe
measures cost/opcost from live budget deltas, so the skip already works in
the scaled domain. The one scale-dependent per-instruction term
(gpu_stall_scale_accum) only moves on a nonzero gpu_bus_stall, which is
structurally 0 in an admitted loop body.

Drop the gate, the IDLE_SUP_CLOCK suppressor/sublabel/log text, and the
user-facing 'overclock switches idle-skip off' claims. Add
test/tools/idle_skip_scale_ab (skip on vs off per-frame state digest at
0.5x/1.5x/2x, wired into make test, --require-fires so it cannot pass
vacuously) and repoint test_option_explain cases 3/5/9 at dram_timing.

Refs #569 #378

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 01:58:41 -04:00
Joseph MattielloandClaude Opus 5.5 05b83a4f4e feat(dsp): pipeline scoreboard + external-bus timing model (#313)
Mirror the GPU pipeline/gateway model (virtualjaguar_gpu_pipeline_timing)
onto the DSP, under the same option, and give the DSP the external-access
charge hook it never had (bus_arbiter.h's documented asymmetry, campaign
hypothesis H-C).

The DSP is "a variant of the GPU" with the same pipe-lining (JTRM v8
p.97), and the netlists agree at gate level: jag_sim jerry/SBOARD.NET and
EXECON.NET are byte-identical to the tom/ files, DSP_MEM.NET's local
arbitration matches GPU_MEM.NET, and DSP_GATE.NET is the GPU gateway
minus the phrase path.  The GPU's netlist/Verilator-pinned rules (R2 RAW,
R3 flags, R5 local load, R9 external load L=7+D, R15 divide, R16 MOVEI,
R6/R7 indexed, R12/R13 jump refill, the section-14 write-back port
conflict) therefore carry over by netlist identity.  DSP differences:

- local space is $F18000-$F1FFFF (DSP_MEM.NET), so GPU RAM is external;
- external memory sits behind JERRY's 16-bit interface (JTRM v8 p.97,
  pin note p.124): a long transfer is two word cycles (JMEM.NET), priced
  by the new bus_arbiter_dsp_access() as word + 1-clock pause + page-hit
  word for DRAM, two full cycles for ROM/I/O;
- indexed-store data is not score-boarded (TOM/JERRY bug 2, v8 p.134),
  consistent with the existing dsp_div_store_fixups data effect.

Inferred, not simulated (no JERRY Verilator rig): the JERRY<->TOM bus
handshake is costed as the GPU's idle-bus grant (2), and the split
pause as 1 clock.

With the model on, every DSP opcode's base cost is 1 (R1) instead of the
legacy dsp_opcode_cycles[] table, whose load/div/mmult entries are the
hand-tuned stand-ins the rules now replace.  Bus waits are wall time and
are rescaled under risc_clock_scale (bus_arbiter.h contract); interlocks
are core time.  With dram_timing on and the model off, DSP external
accesses pay the same immediate self-cost the GPU does.  Both options
off: the DSP path is unchanged.

Model state is transient (one transfer + one divide), zeroed on reset,
interrupt dispatch and option toggle -- the GPU model's convention.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 23:26:49 -04:00
Joe Mattiello e32e5ac587 Merge pull request #871 from libretro/perf/netlink-hop-cost
perf(netlink): idle-side reply wait + frame-slot margin floor + on-device hop stats (#860)
2026-10-11 23:45:40 -04:00
Joseph MattielloandClaude Opus 5.5 4841d260f7 perf(netlink): wait for unprompted link bytes, add on-device hop diagnostic (#860)
A Doom link tic is 12 one-way single-byte hops (6 pairs, ping-pong).  Two
parts of the old behaviour made the tic rate depend on the vsync phase
between the two devices, collapsing to 5 tics/s at bad phases even on a
fast link: JLinkAwaitReply only engaged after we transmitted, so a side that
only ANSWERS spun on ASISTAT with no wait, finished its frame in ~2 ms and
sat in the frontend while the partner's byte arrived; every such hop then
cost a whole video frame.

uart.c now counts consecutive ASISTAT reads with nothing to send or receive
and, past 24 in a frame, calls JLinkAwaitIdle(), which blocks like the reply
wait (same frame budget and timeout) so the byte is seen the moment it
arrives.  A silent partner backs off exponentially (1..32 frames without idle
waits); any received byte resets it.  Real transports on the JagLink device
only (not loopback, not the voice modem).

Simulator, Doom 2P, tics/s averaged over 4 vsync phases, before -> after
(emu 2 ms, 5 us poll_receive):
  2 ms one-way 27.5 -> 27.5   2.5 ms 19.8 -> 26.0   3 ms 16.3 -> 20.8
  4 ms 7.5 -> 19.2   Wi-Fi A (2 ms+3 jitter+2% spikes) 8.9 -> 16.0
  Wi-Fi B (3 ms+8 jitter+3% spikes) 5.4 -> 9.0   3 ms with emu 6 ms 10 -> 15

Also: a once-per-5-s INFO log line while a link carries traffic (hops/s, mean
hop time, wait per hop, slipped-hop share, timeouts, budget cuts, emulation
cost, idle-wait hits) so the numbers can be read from the RetroArch log on
iOS; netlink_sim gains --btrace/--log/--loss-pct/--rto-ms/--udp-rto-ms/
--corrupt and the clock patch gains a byte-event hook; netlink_btrace_report.py
breaks hop time down by cause and analyses the Doom tic structure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 23:01:35 -04:00
Joseph MattielloandClaude Opus 5.5 8c22f67b10 fix(blitter): charge only the 68000 for WMCJ's blits so gameplay stops tearing
White Men Can't Jump flickered heavily in gameplay at default settings, and
almost not at all with the 68K at 3x and the RISC chips at 2x.

Mechanism: the game triple-buffers. Each field it clears a back buffer at
about halfline 67, the GPU draws it with about 40 blits, and the 68000's VI
handler swaps the OP object pointer at halfline 507. The titledb row from
#827 enables blitter bus timing, and that model also made the GPU wait out
every one of its own blits at the worst-case cost (a DRAM row change on
every access). With that wait the draw started late (median halfline 244)
and in 86 of 125 fields was still running at the swap. The OP then showed
half-built frames: horizontal tears, and sprites missing for a field.

Disabling only the GPU-side charge produces output identical to having
blitter timing off entirely (median start at halfline 67, draw finished by
halfline 200, no spill past the swap). The intro logo fix still holds: boot
frames 0-300 hash identically and the logo is 43.8% lit. That fix needs
only the 68000 to be frozen during its own fade copies (JTRM v8 p.8 and
p.69).

The change adds blitter_timing value "m68k" ("68000 only"). GPU accesses
neither wait nor close the busy window, and GPU-kicked blits do not open
one. "enabled" keeps its old meaning, so no other title changes by default.
The WMCJ titledb row now selects "m68k".

Flicker metric over the attract-demo match, two saved start points of 600
frames each (frames where a third of the picture reverts to its state two
frames earlier / single-tear spliced frames):
  stock row (enabled)   68/12  95/15
  fixed row (m68k)      21/2   23/4
  m68k 3x + RISC 2x     12/2   16/1

Test: test/tools/fb_flicker_probe gates frames 1250-1850 from boot (limits
54 big, 6 splice; fixed 40/2). A control run with blitter_timing=enabled
must exceed the limits (68/12). test_titledb pins the row value.

Closes #736

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 23:01:24 -04:00
Joseph MattielloandClaude Opus 5.5 da241c9406 fix(libretro): latch the BIOS and PAL options at content load (#858)
check_variables() re-runs whenever ANY core option changes, and it
re-applied 'BIOS (Cartridges)' and 'PAL' every time. retro_load_game()
resolves vjs.useJaguarBIOS after its option read (a GPU-only/jagcrypt cart
forces the boot ROM on; CD boot mode sets it from bootConfig.showBootROM),
and the DSP reads that flag at runtime. So on such a cart, touching an
unrelated option silently turned the boot-ROM flag back off mid-session.
PAL rewrote vjs.hardwareTypeNTSC live, so the clock macros switched
standards while the AV timing (negotiated only at load) did not.

Both are now applied only before content is loaded, the same way
internal_resolution is. After load a change only logs a one-shot "takes
effect on restart" notice. 'BIOS (Cartridges)' gains the "Restart
required" sublabel marker, and moves from the live list to the load-only
list in test_option_explain case 8; settings guide section 7 lists it.

Test: test_cart_needs_bios gains a mid-session arm. It loads a GPU-only cart
with bios=HLE and pal=disabled, changes an unrelated option, then PAL
itself, and checks that useJaguarBIOS stays 1 and the standard stays NTSC.
develop fails 3 of its 4 checks (useJaguarBIOS=0 after the unrelated
change, NTSC flipped); with the fix all pass.

Closes #858

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 01:58:27 -04:00
Joseph MattielloandClaude Opus 5.5 73e957db01 fix(netlink): fit the reply wait into one video frame instead of overrunning the vsync
Doom (2P) over RetroArch netplay stuttered, and toggling Network Link
Latency Hiding changed nothing.  Measured in a deterministic two-instance
netpacket simulation (test/tools/netlink_sim.c: virtual clock, RetroArch
netpacket semantics read from netplay_frontend.c): Doom's tic exchange is a
chain of single-byte hops, each side's byte depending on the other's, so the
link only advances while the core is blocked in JLinkAwaitReply.  The
per-frame wait budget was 2*EWMA+2 ms clamped to [4,15] ms and ignored what
the rest of the frame costs: with a few ms of emulation the frame finished
past its vsync, the frontend skipped the next one, and the core ran at 30 Hz
(audio at half rate) with ~13 ms of dead time per frame.  Every frame overran
at 3 ms/way (30 overruns/s); a slower device overran by more.

The budget is now what is left of ONE frame after the measured emulation cost
(EWMA of frame time minus time spent waiting, taken in retro_run just before
the frontend calls: audio_batch_cb blocks on a full audio buffer and video_cb
on vsync, neither is emulation) and a 3 ms margin for those calls.  The margin
widens by 1 ms (max 8) whenever a frame that waited is still followed by a
late frame start, and decays after 60 clean frames, so an unmodelled frontend
cost (hi-res present on iOS) corrects itself.  The per-reply timeout stays
adaptive, so a silent peer still costs little.

Sim, Doom 2P deathmatch, wait enabled, old -> new image updates/s
(emu = virtual emulation ms per frame):
  3 ms/way, emu 2:      14.5 -> 22.2, overruns 30/s -> 0
  3 ms/way, emu 6:      11.1 -> 22.2, overruns 30/s -> 0
  3 ms/way, emu 10:     11.2 ->  9.6, overruns 30/s -> 0 (waits only ~4 ms)
  Wi-Fi jitter+spikes:  8.6/8.2/8.1 -> 9.6/10.7/9.8 over 3 seeds, overruns
                        28/s -> 0
  4 ms of unmodelled post-hook cost: overruns 30/s -> 0.5/s via the margin
Wait disabled is ~9 image updates/s on every link, so on a jittery link the
old wait was no better than off: that is why the toggle appeared to do
nothing.  Network spikes themselves stay (a byte-synchronous protocol cannot
hide them).

Also: netlink_delay_proxy gains --jitter-ms/--spike-pct/--spike-ms/--seed
(Wi-Fi model), netlink_game gains --trace, and test/tools/netlink_sim.c +
netlink_sim_clockpatch.py record the simulator (measurement-only clock hook,
never applied to src/).  No regression since 693f42a (#250): identical results
in the sim, and nothing in the arm/pump/flush path changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 00:44:05 -04:00
Joseph MattielloandClaude Opus 5.5 be2185875e feat(libretro): restart-required sublabels, per-content option hiding, demotion OSD test (#850)
Follow-ups to #841 (#847).

- Restart marker: the info (sublabel) of every option that is read only at
  content load now starts "Restart required: applied when content is
  loaded." Static strings in libretro_core_options.h, so no re-push and no
  amendment slot is spent. Verified per option against where libretro.c
  consumes it: internal_resolution (latched in retro_load_game),
  enhancement_hooks (latched; check_variables only compares and logs),
  gdb_stub/bind/port/wait/halt_timeout (read only inside retro_load_game),
  bios_type (vjs.biosType used only by cart boot-ROM staging), cd_bios_type,
  cd_boot_mode and memory_track (consumed in open_disc_and_resolve_boot /
  CD BIOS staging), jgd (JGDLoadROM, only from the cart ROM loader) and pal
  (documented restart-only; see below). Redundant "requires a restart"
  sentences dropped. `bios` is deliberately NOT marked: check_variables()
  rewrites vjs.useJaguarBIOS live.
- Option hiding: with content loaded, the cartridge-only options (BIOS,
  Cart BIOS Type, GameDrive + SD card, enhancement hooks, blit memo) are
  hidden for CD content, alongside the existing CD-only hiding for carts.
  They share the one show_cart_options gate, so the visibility_force_push
  after a definitions re-push re-hides them. CD-only (not RAM-loaded
  executables). All SET_CORE_OPTIONS_DISPLAY calls go through
  option_display_push(), which reads the return value (libretro.h: "call
  is available"), latches a refusal and stops asking; reset in retro_deinit.
- Harness: records SET_CORE_OPTIONS_DISPLAY (a definitions push resets
  every row to visible, as RetroArch's rebuilt manager does), option
  descs, and can refuse the call. test_option_visibility now runs its disc
  half against the in-tree synth_jagcd.chd, so CI covers the CD side.
- test_option_explain cases 7-10, all on in-tree yarc.j64: runtime
  demotion OSD notice (synthetic enhancement row + underrun feed: one
  notice with the demotion reason, one re-push, nothing repeated, no
  SET_VARIABLE), the restart marker swept over every registered option,
  visibility surviving a re-push, and a refusing frontend.

Not changed: pal and bios still have live writes in check_variables()
(vjs.hardwareTypeNTSC / vjs.useJaguarBIOS), so a mid-session toggle
half-applies; out of scope here.

Closes #850

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 00:26:46 -04:00
Joseph MattielloandClaude Opus 5.5 9b450935d6 style(libretro): compare strcmp result explicitly in explain_list_equal
clang-tidy (bugprone-suspicious-string-compare, warnings-as-errors)
rejects the bare strcmp() added in #841. It surfaces on any PR that
touches libretro.c because the CI lint covers whole changed files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:29:55 -04:00
Joseph MattielloandClaude Opus 5.5 972339444d fix(core): mirror 1, 2 and 4 MB cartridge images across the cart window (#851)
Flip Out (2 MB) executes `cmpa.l $A02000.l,a2 / blt / illegal` at $11EEAA.
A cart only drives the address lines it has, so a 2 MB image repeats at
$A00000 and the read returns the cart's own word at $2000 and the branch is
taken.  We returned 0 (jagMemSpace past the loaded image is zero), so the
ILLEGAL fired: HLE looped ILLEGAL/RTE at $11EEB4 from about frame 320, and
BIOS mode trapped to the boot ROM's $E005DC vector at about frame 810.

Ground truth: the MiSTer Jaguar core (Jaguar.sv) ANDs a cart_mask into
abus_out[22:20], armed only for an image of exactly 1, 2 or 4 MiB (mask
000/001/011); every other size is left unmirrored ("not exact=don't
modify").  The JTRM is silent, and jag_sim's cart model has no size-based
decode.  So only 1/2/4 MiB images (measured after a copier header is
stripped) are replicated, at load time, so the read path is unchanged:
1 MB repeats every MB, 2 MB at $A00000/$C00000, 4 MB once at $C00000.
Copies stop at $DFFF00 (the CDROM overlay), MemTrack's claimed window still
wins because MTClaimsRead is tested ahead of the ROM read, and a GameDrive
banked image is untouched.

jagMemSpace is static, so the cart window past the image is now cleared at
load and again in JaguarDone: a previous title's mirror (or the CD BIOS)
used to survive a reload in the same process.  JaguarMirrorCart() is
re-run after TitleHookApplyROM patches so the repeats carry the patch.

Tests: test_cart_format gains 7 mirror cases (distinct markers per
megabyte, 1/2/4 MB mirror, 3 MB and 64 KB do not, header-stripped size,
no leak across reloads); test_flipout_851 steps Flip Out for 1000 frames in
HLE and BIOS and fails if the 68K lands on the ILLEGAL or a trap landing pad
(skipped, and recorded in the skip ledger, without the private ROM).
Corpus A/B (160 carts, 600 frames, HLE, both blitters): only Flip Out
changes, plus a 1-pixel difference in Fight For Your Life (4 MB) where the
blitter reads past the end of the image and now sees the wrapped ROM bytes
instead of zero.

Closes #851

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:17:09 -04:00
Joe MattielloandClaude Opus 5.5 8ed4fa939c feat(libretro): explain per-title presets and overrides in the frontend (#841) (#847)
- OSD (SET_MESSAGE_EXT via netlink_osd) at load naming applied per-title
  presets and their class ("White Men Can't Jump: Blitter Bus Timing on
  (compatibility preset)"), and when the enhancement profile withholds or
  demotes presets. Latched per load.
- Self-describing sublabels: a static copy of the options v2 definitions
  with amended info strings (preset applied / preset withheld + reason /
  idle-skip "Inactive: suppressed by <option>"), re-pushed via
  SET_CORE_OPTIONS_V2 only when the amendment set changes (load end, or
  check_variables after load), never from the update-display callback;
  originals restored on unload. Never calls SET_VARIABLE.
- RetroArch source check: a re-push re-detects per-game/per-folder option
  files (runloop_init_core_options_path) and re-reads values, so user
  overrides survive.
- Harness captures OSD texts / option info / SET_VARIABLE; new
  test/tools/test_option_explain (28 checks; WMCJ, AvP, yarc control,
  mid-session).

Closes #841

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:52:12 -04:00
Joseph MattielloandClaude Opus 5.5 dff13c798e fix(jaggd): CI after #844 -- gnu99 for test_jaggd_fs, bounded SD-root join
#844 merged with three red Linux jobs (a merge-when-green script raced
CI registration):

- build-Linux x86_64 (Clang) and ASan/UBSan: the new test_jaggd_fs rule
  compiled with -std=c99, under which glibc hides lstat/strdup/fileno/
  ftruncate used by jaggd_fs.c and the linked libretro-common sources;
  Clang rejects the implicit declarations. Build it with -std=gnu99.
  macOS headers expose them regardless, which is why it passed locally.
- clang-tidy: the <save dir>/jaggd-sd join used strcpy/strcat. Use
  strlcpy/strlcat with truncation checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 00:55:09 -04:00
Joseph MattielloandClaude Opus 5.5 dc34531117 feat(jaggd): GameDrive SD-card file API against a host folder (#783)
The GameDrive's file/dir functions were stubs returning -1, so software
that keeps its data on the SD card (JagOS 'Spot', streamed assets,
screenshots) could not run. Blob functions 9-16, 20-23 and 26 now work
against <save dir>/jaggd-sd (system dir fallback), per the new core
option virtualjaguar_jgd_sd (Read/write default, Read-only, Disabled).

- Call path: each of those blob slots is one backdoor write, $C000 |
  function. The core services the call during that write: it reads the
  68K's d0/d1/a0/a1 and leaves the result in d0 via m68k_set_reg. Safe
  because the 68K's JERRY writes are synchronous and the stub's
  move.w #imm,abs.l never touches d0; only a 68K writer triggers it.
  Paths and buffers move through JaguarRead/WriteByte, bounds-checked.
- src/core/jaggd_fs.c: FatFs semantics, matching RetroHQ's published
  gdbios.h (facts only, none of their files vendored): FA_* open
  dispositions, write/create needs a writable card, short reads succeed,
  FILINFO records big-endian (22/278 bytes). End of directory returns -1
  with a zeroed record. Sandbox: .., drive letters, FAT-invalid chars
  and symlinks refused; components matched case-insensitively.
- Savestate v17 (first bump of the v3.8.0 cycle): trailing "JGF1" chunk
  with card-relative path, access and position per handle. Restore
  reopens with the original access only (never create/truncate/append),
  re-seeks a handle already open on the same file, closes deleted ones.
- Blob grows 208 -> 338 bytes (limit 510).

Tests: test_jgd's probe now drives the file API through the real blob
(size 7 and the read buffer are values only the host can produce;
sandbox escape returns -1); new host test test_jaggd_fs covers every
disposition, read-only cards, sandbox refusals, FILINFO layout,
directories, the handle limit and savestate restore.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 00:39:47 -04:00
Joseph MattielloandClaude Opus 5.5 429e3b2c2e feat(titledb): key Jaguar CD titles by boot-stub CRC (#747)
CD titles could carry no per-title row: the DB was cartridge-CRC only.
Disc rows now live in their own table, keyed by the CRC32 of the boot
stub CDIntfExtractBootStub() returns. The issue proposed the
sessions/tracks/sectors layout instead; measured across all 53 corpus
images it is neither stable (CDI rips report +150 sectors against the
CUE/CHD of the same pressing in 11 of 12 pairs) nor unique (eight
homebrew discs share 2/6/18044). The stub CRC is identical across CUE,
CHD and CDI for every title whose stub extracts, and separates Vid Grid
from Vid Grid Rev 1. Its limits are documented: seven homebrew discs
share one loader, Songbird re-releases share the original's stub, and
discs with no extractable stub have no key.

- Separate table, so a cart CRC can never match a disc row or the
  reverse; 0 is never a key in either lookup. TitleDBContentCRC() stays
  cartridge-only, so texture dump/replace paths are unchanged for CD.
- The disc is probed (open, extract, close) before the option reads on
  the load path, so a disc row has a cart row's full reach, including
  cd_boot_mode and load-latched options. A disk-control insert re-keys
  and re-reads options before resolving the boot; a failed insert
  restores the previous disc's key.
- Every CD load logs its boot-stub CRC so row authors can read the key
  off the RetroArch log. The table ships empty.

Tests: test_titledb disc-table integrity and namespace isolation;
test_pertitle_db 15/16 (a synthetic disc row on Baldies' key resolves
the real-BIOS strategy; a non-matching key leaves HLE) and 17 (CUE, CHD
and CDI rips of Baldies all log $82B88060); test_disk_control 9/10 (the
same row applies on an insert after a no-content boot; control without
it). Case 15 fails with the probe removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 23:29:03 -04:00
Joseph MattielloandClaude Opus 5.5 b92cb1a827 feat(titledb): compatibility row class with mandatory citation (#748)
Per-title pairs now carry a class. ENHANCEMENT (the zero-fill default,
so every existing two-field row is unchanged) stays governed by the
enhancement profile. COMPATIBILITY is what a title needs to work --
boot mode, a timing model -- and is never dropped by the profile.

A compatibility pair must carry a cite string naming its measurement
and the mechanism ticket it stands in for as "#N"; test_titledb fails
the table without one, so the row's retirement is findable when the
mechanism lands.

White Men Can't Jump's blitter_timing row (#736, PR #827) becomes the
first compatibility row, standing in for blitter timing on by default
(#408). test_pertitle_db cases 13/14 pin the contract end to end with a
synthetic row: the performance profile does not drop a compatibility
pair, and an explicit user value still beats one. Docs: the hooks
"is my idea a hook?" table and the settings guide, whose "every preset
is presentation-only" rule stopped being true with the WMCJ row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 22:02:58 -04:00
Joseph MattielloandClaude Opus 5.5 d3bc33e001 fix(blitter): a blit hardware never finishes hangs the blitter, not the host
Phrase-mode blits below 8bpp never end on hardware.  INNER.NET builds the
inner-counter decrement (Inc0-Inc3) from the 8/16/32bpp decodes, so below
8bpp it is dstxp[0].  Phrase-mode X add truncates to a phrase boundary
(JTRM v8 p.71), so after the first write the counter never moves and
inner0 is never reached.  The synchronous accurate engine reproduced that
faithfully and never returned from retro_run (Music Demo --bios, #794;
Native Demo (bin) HLE).

Policy: hang the emulated blitter, never the host.

- BlitterNeverEnds() (blitter_mmio.c) is the single exact predicate.  The
  accurate engine, the dispatch path and the crash watchdog
  (CrashDetectBlitNeverEnds now delegates to it) all use it.
- The accurate engine runs the stuck step for exactly one destination
  wrap period, then returns.  That is 65536/ppp phrase writes (1024, 2048
  or 4096 at 1/2/4bpp), 65536 with the Y-add bit, +1 when the start X is
  not phrase-aligned.  Bounded at 65,536 writes.  It sets a stop flag only
  after the inner0/mask logic, so no write mask changes.  The collapsed
  pattern-fill and copy paths are bypassed for these blits.
  - The result is EXACT when repeating the step is idempotent on memory.
    Every observed homebrew hang has that shape: DEMO1B $2208, Chroma
    $2704, Native Demo $2718 (LFU clear, no accumulators).
  - Otherwise it is a defined one-pass approximation:
    BlitterNeverEndsApprox() flags Gouraud/Z/shade accumulators,
    comparator masks, ADDDSEL, LFU feeding back D, or LFU fed by a
    separately stepping source (Music Demo, JagMania).  The watchdog line
    now says never_ends=1 approx=1 for those.
- Dispatch then sets a sticky "blitter hung" flag, under both engines.
  - B_CMD status reads busy (JTRM v8 p.75): IDLE clear, outer-INNER and
    inner-DWRITE set, and the stuck inner count in bits 16-31.
  - Further B_CMD starts are ignored, because OUTER.NET leaves idle only
    on go (innert[0] = idle AND go).
  - The flag clears only in BlitterReset (reset / power-on).
  - Known limit: hardware still latches B_COUNT/B_CMD/pointer writes
    mid-blit (FDSYNC on countld/cmdld), so a B_COUNT write with inner 0
    would un-stick it.  JTRM p.70 calls that undefined; this model does
    not resume a hung blit.

Savestate: v16 extended in place, no bump.  A "BLH1" chunk (hung flag and
stuck count, 7 bytes) is appended strictly after #814's CDX1, with a room
check before writing.  It is read only for version >= 16.  A missing or
wrong magic, or an older state, loads as not hung.

Tests:
- test/tools/test_blitter_hung, ROM-free, accurate and fast engines.
  - The synthetic never-ending blit returns.
  - Exactly the 8 KB wrap period is written and the next phrase is
    untouched.
  - Status reads busy with count $DC, and a second start writes nothing.
  - serialize -> retro_reset clears the flag -> unserialize restores it.
- ROM-gated runs of Music Demo --bios and Native Demo (bin): 600 frames
  each, and the blitter ends hung.
- test_crash_detect_inframe gains 10 approx rows.

Closes #794
Refs #800

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 06:30:17 -04:00
Joe Mattiello d6cac67b8b Merge pull request #815 from libretro/fix/810-memtrack-after-disk-insert
fix(libretro): expose the Memory Track as SAVE_RAM on no-content boot (#810)
2026-10-07 02:27:29 -04:00
Joseph MattielloandClaude Sonnet 5.5 75eb401755 fix(libretro): expose the Memory Track as SAVE_RAM on no-content boot (#810)
A no-content session never exposed SAVE_RAM (no_game_active), so Memory
Track writes made through a disc inserted via disk control were never
handed to the frontend. Two causes:

1. SAVE_RAM was gated on no_game_active, a #646-era rule ("no cartridge,
   no EEPROM"). #726 made no-content boot a CD session with the Memory
   Track plugged in from frame 0, so the gate is stale. It now follows
   jaguarMemTrackInserted. It is deliberately not keyed on a mounted
   disc: frontends size and load SAVE_RAM once after retro_load_game and
   libretro has no change notification, so exposing it first after an
   insert would write a file that is never read back.
2. The insert path never reinstalled the NVM BIOS module. The boot resets
   RAM, wiping the '_NVM' cookie; retro_load_game and retro_reset
   reinstall it, disk_set_eject_state did not. Found by the new test.

Tests: test_disk_control cases 6/7/8; test_boot_config's #646 size-0
assertion becomes the new contract. mtMem joins the test export lists.
No savestate change.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 01:21:30 -04:00
Joseph MattielloandClaude Sonnet 5.5 97dcd79fc0 fix(cd): serialize real-BIOS boot-stub flag and CD refill phase (v16)
Run-ahead diverged mid-load on the real-BIOS CD path (Hover Strike,
warmup 400): video replay differed 44 frames after the rollback and the
state never reconverged.  Two statics sat outside the savestate blob:

- jagcd_bios.c cdBootStubInjected gates the 68K boot-stub injection at
  $050176.  A rollback to a pre-injection state kept the flag set by the
  replay that had already injected, so the replay never injected the
  stub and the game never started.
- cdrom.c fifoRefillAccum is the error-diffusion remainder that picks
  the next FIFO refill interval (2 or 3 ticks); stale, it shifted every
  later refill by one tick (fifoFillDelay 1 vs 2 in the state diff).
  cdPrevShouldIRQ, the CD IRQ edge detector, is saved with it.

Appended as two magic-tagged chunks ("CDB1", "CDX1") strictly after the
HLE chunk; STATE_SIZE unchanged.  This is the v3.7.1 savestate bump,
v15 -> v16: v15 and older states still load and leave the live values
alone (v3.7.0 behaviour; forcing "not injected" would re-arm the BIOS
hooks over a mid-game state's RAM).  Verified with a genuine v15 file
written by a v3.7.0 build.

Adds test_state_compat rows (v16 chunk restore, v15 load, damaged
accumulator), a ROM-gated real-BIOS Hover Strike run-ahead check next to
the HLE one, and VJ_RUNAHEAD_KEEP=1 to keep the three state files for
offline diffing.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 00:59:31 -04:00
Joe MattielloandClaude Opus 5.5 0e3e504c8a fix(cd): serialize the HLE CD streaming state (#803)
* fix(cd): serialize the HLE CD streaming state (#787)

The HLE BIOS streams CD_read data into RAM at drive rate from statics in
jagcd_hle.c (hleStream, the CD_read/CD_poll bookkeeping, the #766 align
phase) that were never in the state blob. Measured with
test_runahead_determinism: Hover Strike (HLE) failed all four checks at
warmup 200/400/700/1000; Myst (HLE) failed while its reads were in
flight (200/400) and passed after. Cart (yarc) and Hover Strike with the
real BIOS at 1000 pass, so the leak was the HLE path.

Append an HLE chunk strictly last, behind a magic word ("HLE1"),
extending v15 in place (v15 is still develop-only). Field by field, never
the raw struct (padding, sizeof(bool)). hle_active is boot-strategy
configuration and is not saved. A damaged bufOff/written drops the
transfer rather than reading past the sector buffer.

Backward compatibility, measured by saving at frame 400 (mid-stream) and
loading into this build:
- v14 states from v3.6.1 load and run byte-identically (per-frame hash
  CSV) to v3.6.1 loading its own state, for Hover Strike and Myst.
- v15 states written by develop before this change (zero-filled tail)
  load with the in-flight transfer dropped, as before.
- New states resume the transfer (Hover Strike: 200 distinct frames
  after load vs a frozen black screen without the chunk).
A pre-#787 state saved mid-read still cannot resume that read on any
build -- that data was never saved.

After: all eight runs above pass all four checks. make test gains a
ROM-gated Hover Strike HLE run-ahead check at warmup 400 (passes here,
exits 1 on the pre-#787 build).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(cd): validate a restored HLE stream; synthetic mid-stream round trip

Review on #803:
- Copilot: a damaged or crafted state could set dest near $FFFFFFFF,
  wrapping HLEStreamFinish's dest+total bound check into an out-of-bounds
  write, or point the GPU data base where CD_poll's +0/+8 reads land
  outside RAM. Load now requires dest/total/reqTotal/written and the
  CD_poll range to fit main RAM, and both data bases to be 0, main RAM or
  GPU local RAM; otherwise it drops the transfer and those pointers.
- Copilot: CI has no private discs, so nothing exercised an active
  stream through save/load. test_cd_synth_read gains
  inflight_stream_survives_savestate_round_trip on its synthetic disc:
  save mid-stream, finish, restore, and the same transfer must be in
  flight and complete byte-exact. Red with the chunk load disabled.
- Kimi: check room for the ~2.4 KB chunk before writing it, not after;
  JaguarCDHLEStateSize() is pinned to what Save writes by the same test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 23:40:42 -04:00
Joe MattielloandClaude Opus 5 1823d628a1 fix(boot): no-content boot comes up in the CD BIOS, not an empty cart slot (#731)
* fix(boot): no-content boot comes up in the CD BIOS, not an empty cart slot

Booting the core with no content resolved to cd_boot_strategy_none: the
boot ROM running against a zeroed cart window, which is the empty-slot
logo and nothing else. That is authentic for a Jaguar with no CD unit
attached, but it is a dead end for the feature the path exists to serve.
The CD BIOS is what offers "insert a disc", and it is mapped as a
cartridge at $800000, so nothing reachable from the empty-slot screen
could ever get to it -- which made "launch bare, insert a disc, reach the
VLM" (#646 + #651, the headline of v3.6.0) unreachable by construction.

Resolve to the real CD BIOS instead, mirroring ResolveBootConfig()'s
CDBOOT_BIOS arm by hand (it is still not usable here -- it only knows
about cart/CD content). Neither call can fail for want of a file:
stage_cd_bios() prefers an external ROM from the system directory and
otherwise copies an embedded image, and bios_boot() needs no disc, so
this works with zero files present.

Measured, not assumed -- test_disk_control case 5, 600 headless frames of
no-content boot watching the real framebuffer:

    before:  frames=600 nonblack=564 changes=35     (static logo)
    after:   frames=600 nonblack=549 changes=528    (BIOS animating)

and the captured frames are the CD BIOS cube intro followed by its
insert-disc attract screen, which is the screen a disc is inserted from.

Also flip supports_no_game in dist/info, which was left "false" when
SET_SUPPORT_NO_GAME shipped -- RetroArch reads the info database, not the
runtime env call, to decide whether to offer a contentless launch, so the
feature was not advertised to the frontend at all.

Two tests asserted the old strategy and so encoded the bug:

- test_boot_config's no_game_boot_selects_none_strategy asserted IS_NONE.
  Renamed to no_game_boot_selects_cd_bios_strategy and now pins the BIOS,
  plus cdBiosAvailable.
- test_disk_control case 1 compared strategy names either side of an
  insert. Since no-content is now "bios" and a real-BIOS disc also
  resolves to "bios", that comparison would have gone vacuous; it pairs
  the name with isCDGame instead, which is what actually moves (false for
  the bare BIOS on its insert screen, true once a disc is mounted).

Full suite green (exit 0, zero failures); C89 lint clean.

Refs #726

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(boot): no-content boot is CD mode with an empty tray, not cart mode

Follow-up to the CD BIOS switch in this branch, from Copilot's review:
the new path staged and booted the CD BIOS but left jaguar_cd_mode and
jaguarMemTrackInserted at their cartridge-path defaults.

The CD unit is attached -- that is why the BIOS is running at all -- so
the machine is in CD mode with nothing in the tray. Two consequences of
getting this wrong:

- update_option_visibility() keys both gates off jaguar_cd_mode, and
  content_loaded is true by then (set unconditionally at the end of
  retro_load_game, no-content path included). So a machine sitting in
  the CD BIOS had CD Boot Mode / CD BIOS Type HIDDEN and the
  cartridge-BIOS option SHOWN -- exactly inverted.
- The Memory Track cart plugs into the CD unit and is present whether or
  not a disc is, but it stayed absent until the first insert.

Both flags now mirror open_disc_and_resolve_boot(); only the disc itself
is missing. Safe with an empty tray: the three disc-identity accessors
return zero when nothing is mounted (documented at the savestate chunk),
so a state taken on the bare BIOS records 0/0/0 and is correctly refused
against a mounted disc, and the Memory Track save-buffer paths only ever
touch mtMem.

Full suite still green (exit 0, zero failures); C89 lint clean.

Refs #726

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-05 07:04:35 -04:00
Joe MattielloandClaude Opus 5 0598768d18 feat(debug): opt-in LAN binding for the GDB stub, with a peer check (#652) (#725)
* feat(debug): opt-in LAN binding for the GDB stub, with a peer check (#652)

The stub bound INADDR_LOOPBACK unconditionally, which is right as a
default but makes one real case impossible: debugging a game running on a
phone, tablet or TV. SSH port-forwarding covers desktop, but a stock
iOS/tvOS device has no shell to forward from, so there was no path at all.

New core option virtualjaguar_gdb_bind (loopback | lan, default
loopback). The security posture is the design; the plumbing is four
lines.

- FAILS CLOSED. Absent, unset or unrecognised values resolve to loopback
  inside GDBSockSetBindMode(), and the module's initial value is loopback
  too -- so a caller that never sets it (a test, or a frontend that has
  never heard of the option) still gets the safe bind.
- READ RAW via environ_cb, never get_variable_pertitle(), for the same
  reason the enhancement-hooks gate is: a per-title DB row must not be
  able to open a debug socket on the user's behalf.
- LATCHED ONCE at content load, so changing the setting mid-session
  cannot widen a socket that is already open.
- PEER CHECK at accept(): in lan mode, only RFC1918, CGNAT (100.64/10),
  link-local and loopback peers are accepted; anything routable is closed
  and logged with its address. This does NOT make a hostile LAN safe --
  it stops the SILENT accidental case (carrier NAT, a misconfigured
  hotspot), which is the failure a user cannot notice. It will also
  refuse a legitimate VPN peer on a public range, visibly.
- LOUD. LOG_WRN (not INF) naming the bound address and port, plus the
  on-screen banner, whenever lan is active. The banner now reports the
  ACTUAL bound address rather than a hardcoded 127.0.0.1, which would
  have become a lie.

The GDB remote protocol has no authentication of any kind, and nothing
here adds any. That is stated plainly in the option text, the user guide
and the header comment rather than left for a user to discover.

Also corrects the gdb_stub option's own description, which asserted the
server "only ever listens on 127.0.0.1 -- it is not reachable from
another machine". That is no longer unconditionally true.

Four fail-closed assertions in test_gdbstub_proto (22 total, was 18):
default is loopback with no setter call, explicit LAN is honoured, an
unrecognised value falls back to loopback rather than LAN, and LAN can be
turned back off. Those are what stop the default drifting in a later
refactor. Linking gdbsock.c into that test needed -DINLINE=inline (log.h)
and the vj_log_cb stub test_blitter_mmio.c and test_titlehook.c already
use.

NOT VERIFIED: whether iOS/tvOS permits RetroArch to accept inbound LAN
connections without a local-network entitlement prompt. Listening is
generally allowed, but I have not tested it on a device and this commit
does not claim it works there.

Verified: make TEST_EXPORTS=1 test exit 0, zero FAILs; c89-lint clean.

Refs #652

Co-Authored-By: Claude Opus 5 <[email protected]>

* docs(debug): header summary of the peer filter was missing CGNAT (#652)

From the Kimi review on PR #725. gdb_peer_allowed() accepts CGNAT
100.64/10 and the user guide says so, but gdbstub.h's summary listed only
RFC1918 / link-local / loopback. The header is what a future reader
trusts, so it now names all six ranges and points at gdb_peer_allowed()
as the authority rather than restating the list a third time.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(debug): address three review findings on the LAN bind option (#652)

All three from the Copilot review on PR #725, all real.

1. libretro.c -- the LAN banner dropped the "halts freeze this frontend"
   warning. Binding scope changes WHO can reach the stub, not what a halt
   does to the frontend, so the warning applies in both modes; my ternary
   had made the banner say less exactly when more is at stake. Both
   warnings now appear in LAN mode, and the buffer grew 96 -> 160 to fit
   them.

2. Makefile -- the comment above test/tools/test_gdbstub_proto still said
   "no sockets ... links alone here", which stopped being true when the
   rule gained src/debug/gdbsock.c. It now says gdbsock.c is linked, that
   none of its socket code runs (the tests only touch
   GDBSockSetBindMode/GetBindMode, which move one static), and why the
   real file is linked rather than a stub: the shipped default is what
   has to be proven safe, not a copy of it.

3. gdbsock.c -- the refused-peer warning used a lowercase "[gdb]" tag
   where every other stub log uses "[GDB]", and named the setting
   "gdb_bind" rather than its real key "virtualjaguar_gdb_bind". Both
   made it harder to grep and easier to misread. Fixed.

Verified: make TEST_EXPORTS=1 test exit 0, zero FAILs; test_gdbstub_proto
22 passed; c89-lint clean.

Refs #652

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-03 00:05:52 -04:00
Joe MattielloandClaude Opus 5 5656c1516f feat(hooks): user-supplied hook file + one patching router doc (#637) (#716)
* feat(hooks): user-supplied hook file + one patching router doc (#637)

We shipped three overlapping ways to modify a game with no single door,
and the question that prompted this ("can users put their own patch files
in?") came from someone who works on this core -- so discoverability was
the bug. Answer: yes, two ways already (cheats, soft patching); the one
thing a user could NOT author was a hook. Both halves fixed.

docs/patching-a-game.md routes the four cases -- cheat code, IPS/BPS/UPS,
byte patch, "different emulator settings for one game" (which is a
per-title default, not a patch at all). Cross-linked from README and the
top of rom-patches.md / enhancement-hooks.md so any entry point reaches
the router.

src/core/hookfile.c parses <system_dir>/vj_hooks.txt:

    crc=DC187F82
    hook=my-patch 0x0012A4 4E71 4E75

Line-based, NOT JSON: libretro-common vendors no JSON parser, and
hand-rolling one adds a large parsing surface to a feature whose whole
job is writing bytes into a ROM. Same shape as vj_netlink.txt, which this
core already reads. Field splitting is hand-rolled rather than strtok_r --
that is POSIX, and msvc05/msvc10 on the GitLab buildbot is exactly where
our C89 lint is structurally blind.

Every fence from docs/enhancement-hooks.md is inherited, none relaxed:
- Same gate, read raw via environ_cb BEFORE the file is parsed, so a file
  can never switch on its own gate.
- expect[] mandatory; all-or-nothing per title in the applier.
- Additionally all-or-nothing per FILE: any malformed line discards every
  hook parsed. A half-understood patch set is worse than none, and the
  applier's own all-or-nothing rule would otherwise be enforced over
  hooks we only partly parsed.
- Applied once at content load, so nothing enters the savestate blob --
  no new field, no version bump.
- User file wins over a built-in row for the same CRC, with a LOG_WRN
  naming both, matching the DB's "user-set values always win" rule.
- Nothing allocates. Every bound is a compile-time constant, so a file
  cannot drive allocation or overrun by declaring a count.
- Malformed or absent file never refuses to load the game.

13 new assertions in test/test_titlehook, including the ones that matter:
a malformed line discards the whole file, expect/patch length mismatch is
refused, over-long hex does not overrun the arena, and >TITLEDB_MAX_HOOKS
refuses the file. The parser is pure, so they drive it directly and then
feed the parsed set through the real applier against a real buffer.

Verified: make TEST_EXPORTS=1 test exit 0, zero FAILs; c89-lint clean on
all four touched .c files.

Refs #637

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(build): add hookfile.c to Package.swift; guard test fopen results (#637)

Three CI failures on the first push, all mine:

- Package.swift repeats Makefile.common's source list (SPM cannot read a
  Makefile), and I updated only one side -- which is exactly what the
  "duplicated lists in sync" guard exists to catch, and what broke the
  SwiftPM build job too. One line, both green.
- clang-tidy flagged four clang-analyzer-nullability warnings in the new
  test block: fopen results used without a NULL check. Every fixture
  write is now guarded, and the first one bails out rather than
  pretending to test a file it could not create.

Verified: check-package-sources.sh OK (70 core sources agree), clang-tidy
clean on all four changed files, make TEST_EXPORTS=1 test exit 0.

Refs #637

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(hooks): bounded name copy, not strcpy (#637)

clang-analyzer-security.insecureAPI.strcpy on the CI runner. The length
was already validated one branch earlier, so this was safe in fact -- but
the check is right that strcpy is unbounded at the call site regardless of
what the caller checked, and a parser reading an attacker-editable file is
the last place to argue with that.

Local clang-tidy (llvm 21) did not raise it; the CI runner's did. Worth
knowing the two disagree.

Verified: test_titlehook OK (0 failures), make TEST_EXPORTS=1 test exit 0,
c89-lint clean.

Refs #637

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-29 01:13:25 -04:00
Joe MattielloandClaude Opus 5 2f210485d5 feat(cd): libretro disk control interface — insert a disc after launch (#651) (#715)
* refactor(libretro): extract open_disc_and_resolve_boot() (#651)

Pure refactor, no behaviour change. retro_reset() is a bare JaguarReset()
and re-resolves nothing, so the disk-control insert path (#651) needs a
re-enterable disc-open -> session-count -> effective boot mode ->
ResolveBootConfig sequence.

ResolveBootConfig() ran for cart content as well as CD content, outside
the is_cd_content block, so this restructures retro_load_game's branch
rather than lifting a contiguous block: the extracted function owns the
CD branch end to end, and the cart and no-content branches keep their own
calls.

The extracted function allocates nothing and frees nothing. The two
callers have opposite cleanup policies -- load tears the session down and
returns false, insert must leave a live machine intact -- so cleanup
stays with them.

Verified:
- 600-frame CD boot digest (Myst) byte-identical before/after, both
  files non-empty at 601 rows. frame_hash_ab needs --csv; redirecting
  stdout would have compared two empty files as equal.
- make TEST_EXPORTS=1 test exit 0, zero FAILs.
- scripts/c89-lint.sh libretro.c clean.

Refs #651

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(harness): no-content load + disk-control capture (#651)

Neither half of the #651 insert path was reachable from a test:
harness_load_rom() refuses a NULL rom_path, and cb_environment() did not
answer SET_DISK_CONTROL_EXT_INTERFACE.

- accept_disk_control_cb (default 0, refusing both calls) follows the
  accept_audio_buf_cb convention exactly, so every existing tool is
  unchanged.
- GET_DISK_CONTROL_INTERFACE_VERSION answers 1 under the same flag.
  Answering 0 or refusing would push the core onto the legacy interface,
  which has no get_image_path/get_image_label, so a test could not read
  back what it inserted.
- harness_load_no_content() mirrors harness_load_rom's init ordering --
  set_environment before retro_init, then a/v and input, then the load --
  because the core registers its environment-driven interfaces during
  that sequence.
- harness.h keeps its deliberate no-libretro.h policy: the entry points
  are spelled out and retro_game_info is forward-declared, the same
  reasoning the audio_buf_cb comment records.

Verified: make TEST_EXPORTS=1 test exit 0, zero FAILs; c89-lint clean.

Refs #651

Co-Authored-By: Claude Opus 5 <[email protected]>

* feat(cd): register the libretro disk control interface (#651)

The core implemented neither SET_DISK_CONTROL_INTERFACE nor its _EXT
form, which is the shared blocker behind no-content CD boot (#646 can
launch bare, but nothing could then hand the core a disc) and the
audio-CD/VLM path.

Adds the image list, the ten ext callbacks, and registration from
retro_load_game -- unconditionally, INCLUDING the info == NULL
no-content path, which is the case the feature exists for. Falls back to
the legacy interface when the frontend reports no ext support, and warns
when it accepts neither.

Insert is deliberately inert here: set_eject_state() only flips the flag.
The resolve-and-boot behaviour lands separately so it can be reviewed on
its own.

Eject is a frontend-level FLAG, not an emulated tray: src/cd/ models no
disc-present line, so the mounted image stays readable until an insert
replaces it. Recorded in a comment as a limitation.

Statics are cleared in retro_unload_game AND retro_deinit -- iOS cannot
dlclose the core, so a stale image list would otherwise survive into the
next load and offer a disc that is no longer there.

Verified: make TEST_EXPORTS=1 test exit 0, zero FAILs; c89-lint clean.

Refs #651

Co-Authored-By: Claude Opus 5 <[email protected]>

* feat(cd): insert re-runs boot resolution and boots the disc (#651)

set_eject_state(false) re-runs open_disc_and_resolve_boot() against the
selected image, then dispatches the resolved strategy.

Dispatches strategy->boot(NULL), NOT JaguarReset(): bios_boot() copies the
CD BIOS to $800000 and sets jaguarRunAddress from $800404 before
resetting, so a bare reset would restart the PREVIOUS program. NULL is
safe -- both CD strategies ignore info (hle_strategy_boot does
`(void)info;`); only cart_boot reads it, and cart is never resolved here.

A failed insert is inert: bootConfig, jaguar_cd_mode and cd_image_path are
snapshotted and restored, and the previous image is reopened best-effort.
That restore is load-bearing -- CDIntfOpenImage() closes the current image
before parsing the new one (cdintf.c:1797), so by the time an open fails
the old disc is already out of the drive, and the audio-disc failure path
returns AFTER ResolveBootConfig() has already written bootConfig.

set_image_index routes through the eject/insert pair so there is exactly
one way to mount a disc.

test_disk_control asserts the RESOLVED STRATEGY, not the return value: a
reset against the previous disc's boot config restarts the machine and
returns true, so it looks identical from outside. Confirmed red first --
against the inert stub, case 1 failed exactly on
case1_boot_resolution_reran while the other three passed.

Case 2 (audio disc must reach the real BIOS) is NOT written: no
one-session image exists in the corpus -- every CUE carries two REM
SESSION markers and CDI headers declare numSessions=2 -- so it would pass
against a data disc for the wrong reason. Ledgered via test-skip.sh
instead of faked.

Verified: make TEST_EXPORTS=1 test exit 0, zero FAILs; c89-lint clean.

Refs #651

Co-Authored-By: Claude Opus 5 <[email protected]>

* feat(cd): savestates carry disc identity and refuse a mismatch (#651)

Disk control lets a frontend swap the disc mid-session, so a state and the
mounted disc can now disagree -- which they never could before, when the
disc was fixed at load. Loading a state taken on disc A while disc B is
mounted would resume a machine whose CD state points at content that is
not there.

STATE_VERSION 13 -> 14. 13 shipped in v3.5.1 (checked with
`git merge-base --is-ancestor`), so this needs its own bump; 14 is now the
single shared bump for this release.

Identity is sessions / tracks / total sectors, all three from accessors
that already existed in cdintf.h -- no CDIntf work. Hashing the image is
not an option at hundreds of MB per disc with states written constantly.

The chunk is STRICTLY LAST, after the wire-speedup chunk, for the reason
Team Tap's comment records: appending keeps every v12/v13 blob loadable,
interleaving would silently desync all of them. Pre-v14 states carry no
identity and load unchanged.

Also fixes a partial-mount gap the test exposed: a DAMAGED RIP fails at
strategy->boot(), not at resolve, and that path returned false without
restoring the snapshot -- leaving exactly the half-configured machine the
design says cannot happen. Both failures now share one restore. Found by
mounting vidgrid.cdi, one of the known-broken CDI V2 rips.

case4_own_disc_roundtrips is the control: without it, a serializer that
refused EVERY state would satisfy the mismatch assertion on its own.
Case 4 exits 77 (ledgered skip) when either disc is an unbootable rip --
a corpus property, not a code failure.

Verified: make TEST_EXPORTS=1 test exit 0, zero FAILs; c89-lint clean.
regression_test.sh could NOT be run here -- clang++ is a ccache shim
pointing at a stale Swift 5.3.3 toolchain whose libc++ headers break the
C++ miniretro at its first build step, before the core is touched.
Pre-existing host issue, unrelated to this branch (miniretro.cc is not in
the diff); CI runs it.

Refs #651

Co-Authored-By: Claude Opus 5 <[email protected]>

* docs(cd): document disc swapping, its limits, and state binding (#651)

States plainly the two things a user would otherwise discover the hard
way: inserting a disc RESTARTS the console (the boot strategy is derived
from the disc, so the core must re-read and boot it -- real hardware does
not need to, and #651's own framing implies a continuous swap this does
not provide), and eject is a frontend-level flag rather than an emulated
tray, since the CD unit models no disc-present line.

Also documents that savestates are now bound to the disc they were taken
on, and that older states are unaffected.

Refs #651

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-28 02:45:43 -04:00
Joe Mattiello 0704a6412a feat(libretro): enhancement profile option (auto/quality/performance) (#706)
P9 (docs/perf-audit-2026-08.md): the titledb's enhancement defaults
(internal resolution 2x, true color) cost ~30% of frame time on titles
like AvP/Doom/Missile Command 3D, pushing slow hosts under 60fps.  New
speed-category option virtualjaguar_enhancement_profile decides whether
those DB DEFAULTS may apply: 'quality' always (pre-profile behaviour),
'performance' never, 'auto' (default) suppresses them on 32-bit ARM
builds (__arm__ && !__aarch64__ -- no platform define reaches C for the
rpi/classic targets) or demotes them once, early in a session, when the
frontend's audio-buffer status callback (#684 plumbing) reports underrun
danger for 180 consecutive frames inside the first 600.  The demotion is
a shrink-only live geometry switch (2x -> 1x stays inside the advertised
maximum) applied at a frame boundary with a [perf] warning naming the
reason.  Explicit user-set options are untouched by construction: the
suppression sits before the at-default substitution in
get_variable_pertitle(), the only place DB defaults apply.

Tests: test_pertitle_db cases 9-12 (performance suppresses, explicit
user option beats the profile, quality applies, runtime demotion via
synthetic underrun reports through a new opt-in harness capture of
SET_AUDIO_BUFFER_STATUS_CALLBACK).  frame_hash_ab on Missile Command 3D:
quality and auto arms byte-identical to develop over 1800 frames;
performance arm renders 326x240 (1x) vs 652x480.
2026-08-27 19:17:39 -04:00
Joe Mattiello b53ee80bf1 perf(core): tier-2 micro-optimizations (keyboard poll gate, netlink clock skip) (#704)
* perf(libretro): gate keyboard fallback polls on a keyboard-presence latch

update_input()'s default path made 24 input_state_cb(RETRO_DEVICE_KEYBOARD)
round trips per frame that all return 0 forever on keyboard-less devices
(audit #569/P8). Register the frontend keyboard callback as a presence
signal and only start polling after the first key event; frontends that
decline SET_KEYBOARD_CALLBACK keep the old unconditional polls.

* perf(jerry): skip the per-frame netlink clock query when nothing reads it

JLinkFrameTick() sampled JLinkNowMs() (a clock_gettime) every video frame
even with netlink fully disabled (audit #569/P8). Move the sample inside
the discovery gate and into JLinkNegTick's active-negotiation tail, the
only two consumers.
2026-08-27 18:58:02 -04:00
Joe Mattiello 826764cb0b perf(gpu): port idle-loop fast-forward to GPU interpreter (#699)
Port the DSP idle-loop fast-forward (#569) to GPUExec, ranked candidate
#1 in docs/perf-audit/mc3d-stall-attribution.md: MC3D burns 66.8% of
its interpreted GPU instructions in one 3-instruction semaphore poll,
jagniccc and yarc ~100%.  Same three-snapshot fixed-point probe, same
admission rules (plain-RAM/local-RAM loads + ALU + the loop-closing jr;
register space excluded), same option (virtualjaguar_risc_idle_skip,
still default-off) and the same suppression list (blit memo, non-stock
RISC clock scale, dram timing, gpu pipeline timing, armed vjtrace/gdb).

GPU-specific deltas, documented in the gpu.c header block: the
executed-path identity is opcost == idleBodyCount (GPU delay slots do
not increment gpu_exec_opcode_count, unlike the DSP's +1); no
IMASKCleared/retire-delay probe reset (the GPU dispatches IMASK-clear
IRQs synchronously inside the G_FLAGS store, which no admitted body
can contain); load EA classification follows the GPU opcodes' own
masking (raw base for indexed/ri forms, local-only loadb/loadw).

Measured (test/tools/gpu_idle_ab, 1200 frames, framebuffer + audio +
savestate hashes byte-identical off vs on for all three):
  MC3D      442,703 -> 73,925 interpreted GPU ops/frame  (-83.3%)
  yarc      442,798 -> 13,785                             (-96.9%)
  jagniccc  442,712 -> 190,162                            (-57.0%)

test/tools/gpu_idle_probe_falsify (wired into make test) pins both
directions of the GPU's opcost identity: a genuine loop must fire and
the compound-period undecoded-store exploit must stay rejected.
2026-08-27 05:26:11 -04:00
Joe Mattiello 53d8ff177d feat(libretro): auto frameskip driven by audio buffer status (#690)
On slower ARM devices (Apple TV A12, Raspberry Pi), frames that miss the
16.67 ms budget drain the frontend audio buffer into an audible under-run.
Add the standard libretro mitigation (gpsp/snes9x pattern):
RETRO_ENVIRONMENT_SET_AUDIO_BUFFER_STATUS_CALLBACK feeds per-frame buffer
health, and when it reports draining the core skips VIDEO PRESENTATION
for a frame -- riding the existing tomSkipVideoPresent path that
GET_AUDIO_VIDEO_ENABLE already drives, so emulation runs unabridged and
determinism/savestates/run-ahead/netplay are untouched by construction.

New options:
- virtualjaguar_frameskip: disabled (default) / auto (skip on the
  frontend's underrun_likely flag) / auto_threshold_15|30|45 (skip when
  buffer occupancy % falls below the threshold).
- virtualjaguar_frameskip_max: 1-4 (default 3), cap on consecutive
  skipped frames so the screen never freezes.

Skipped frames go to video_cb as NULL (frame dupe); the
GET_AUDIO_VIDEO_ENABLE discard path keeps handing videoBuffer to the
frontend exactly as before. When frameskip is enabled the core also
requests ~6 frames of minimum audio latency (SET_MINIMUM_AUDIO_LATENCY,
deferred to retro_run) so occupancy reports have headroom to be
meaningful. Frontends without the buffer-status callback leave every
auto mode behaving as disabled -- no crash, no blind skipping. Nothing
here enters retro_serialize; frameskip state is presentation-only.

Closes #684
2026-08-27 04:17:38 -04:00
Joe Mattiello 6264f554e6 feat(debug): GDB remote stub Phase 2/3/4 -- breakpoints, GPU/DSP threads, docs (#685)
Phase 2: RSP ack/nak byte handling (the gap that stalled every real
m68k-elf-gdb on qSupported), Z0-Z4 breakpoints/watchpoints, c/s/vCont,
register and memory writes, the blocking halt loop (GDBHalt() freezes
retro_run() in place until continue/step/disconnect), a client
disconnect that disarms everything and resumes, and the
virtualjaguar_gdb_wait / virtualjaguar_gdb_halt_timeout core options.

Per-target armed counters (gdbArmed68K/GPU/DSP) plus a 256-entry
direct-mapped PC cache gate the three per-instruction hooks
(M68KInstructionHook, GPUExec, DSPExec) -- one load and one
never-taken branch when nothing is armed, never patching emulated
memory. Memory watchpoints ride jaguar.c's pre-existing (but
previously dead) bpmActive/bpmAddress1 pair via the new
GDBMemWatchHit(), single-slot by design.

Phase 3: GPU and DSP as GDB threads 2/3 with a custom
org.atari.jaguar.risc target description (R0-R31=0-31, PC=32,
FLAGS=33, fixed forever) served over qXfer:features:read, thread
enumeration/selection (qfThreadInfo/Hg/Hc/qC), and monitor commands
(disasm/regs/halt/trace/watch) via qRcmd. The RISC disassembler is
shared, not duplicated: src/debug/gdbdisasm.h is used by both
gdbtarget.c (live) and the refactored test/tools/gpu_disasm_dump.c
(offline).

Phase 4: docs/gdb-stub-guide.md, a user-facing guide covering attach,
the frontend-freeze halt model, the RISC disassembly gap, and the
symbol-level (not source-level) debugging ceiling.

New accessors added or un-gated from VJ_TRACE-only in gpu.c/dsp.c
(GPUGetControl, GPUSetReg/PC/Flags, DSPGetPC/Control,
DSPSetReg/PC/Flags, DSPGetReg/GPUGetFlags un-gated) so the stub can
read and write GPU/DSP state in every build.

Verification: make -j4 clean; make TEST_EXPORTS=1 test green (0
failures, 75 suites, including 45/45 gdbstub protocol tests);
c89-lint clean on every touched src/ file; savestate digests
byte-identical across stub-disabled / enabled-unattached /
enabled-attached-zero-breakpoints (test/tools/gdb_determinism_probe);
a live RSP client verifies the ack byte and no-ack transition end to
end (test/tools/gdb_attach_probe.py) and a real Z0 breakpoint halting
real (synthetic, injected) 68K execution at the exact armed PC
(test/tools/gdb_breakpoint_probe.py).

Perf A/B NOT measured this pass: the host stayed under sustained
unrelated load (other processes, load average 10-26 over repeated
checks) for the whole session. VJ_GDB_STUB_DISABLE_HOOKS is wired at
all three hook sites so the A/B can be run later on an idle machine --
see the PR body.

Refs #652
2026-08-27 04:02:25 -04:00
Joe MattielloandClaude Opus 5 6845f89324 fix(cd): route audio-only CDs through the real CD BIOS (#683)
HLE cannot boot a Red Book audio CD: it synthesizes its boot stub from
session-2 game data, and an audio-only disc has no such session by
construction, so retro_load_game() returned false for the shipped
default (cd_boot_mode=hle) -- RetroArch reported unsupported content.

The retail CD BIOS's own player front-end (Virtual Light Machine,
#291/#300/#325) already handles audio discs. Detect the disc's session
layout (numSessions()==1 vs. the 2 sessions every Jaguar CD data disc
has -- track type can't be used, since all Jaguar CD tracks are typed
AUDIO in CUE sheets regardless of content) and force the real-BIOS
path for that disc only. The global cd_boot_mode option and its
HLE-only cd_read_speed knob are untouched for every other disc,
including the 4 known-damaged CDI rips (their CDI container header
still reports numSessions=2, so they are unaffected and keep failing
the same clear way).

Sequencing: ResolveBootConfig() used to run before CDIntfOpenImage(),
so the session count wasn't known at decision time. Moved
CDIntfOpenImage() earlier instead of adding a second pre-scan --
CDIntfOpenImage() already parses the full CUE/TOC, so a separate scan
would just duplicate it. It still runs before JaguarInit(), only
earlier than before. A defensive post-ResolveBootConfig check fails
loudly (rather than silently attempting an HLE boot that cannot
succeed) if the audio-only override could not stage a BIOS, though
stage_cd_bios() always has an embedded fallback so this should be
unreachable in practice.

Also refreshes the cd_boot_mode option text and settings guide: real
BIOS is no longer "experimental" wording -- docs/fmv-bios-verify-notes.md
records all 5 FMV titles clean across 15,000-frame probes.

No global default changes: virtualjaguar_bios and
virtualjaguar_cd_boot_mode keep their current defaults.

Refs #657

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-27 03:23:54 -04:00
Joe Mattiello fef12fcb14 perf(libretro): hot-path hygiene (Memory Track pack, serialize slack, netlink clock, LOG_DBG) (#674)
* perf(libretro): pack Memory Track save buffer once per frame

mt_dirty_cb was memcpying 128 KB on every AT29C010 byte write. Latch a dirty flag instead and pack at most once per frame (and on SRAM reads).

* perf(libretro): skip savestate slack zero-fill for rollback contexts

Query GET_SAVESTATE_CONTEXT per serialize and skip the 350-450 KB padding memset for rewind, run-ahead, and rollback-netplay. Keep it for on-disk states.

* perf(libretro): gate netlink clock query on netlink enabled

Skip the peer-picker JLinkNowMs() call when netlink is disabled and no rebuild is latched. Enabled-netlink behavior is unchanged.

* perf(log): compile out LOG_DBG in release builds

Under NDEBUG, LOG_DBG is a no-op unless VJ_DEBUG_LOG is set. INF/WRN/ERR stay live so crash-detect signatures are unaffected.

* fix(libretro): keep serialize slack zero-fill for rollback netplay

Rollback-netplay states cross the network and peers may CRC-compare the blob. Skip slack memset only for same-session run-ahead.
2026-08-27 02:46:00 -04:00
Joe Mattiello 0d451c4e03 feat(debug): GDB remote stub Phase 1 — protocol engine, 68K target, loopback transport (#662)
* feat(debug): RSP packet framing for the GDB stub

Refs #652

* feat(debug): RSP run-length decode and command dispatch

Refs #652

* feat(debug): bounds-checked m packet for the GDB stub

Every guest address is validated by the target op before any read; the
engine turns refusal into E01 and never guesses a range.

Refs #652

* feat(debug): 68000 register and memory target ops

Refs #652

* feat(debug): loopback-only TCP transport for the GDB stub

Binds 127.0.0.1 only, never INADDR_ANY. Verified by observation:
netstat shows 127.0.0.1.<port> LISTEN, never *.<port>.

Refs #652

* feat(debug): core options and lifecycle for the GDB stub

Two Restart-gated core options (virtualjaguar_gdb_stub, virtualjaguar_gdb_port)
read raw via environ_cb in retro_load_game, matching the enhancement-hook
gate's reasoning: a developer-facing option must not be settable by a
per-title DB row. On enable, opens the loopback listener, inits the RSP
session against the Jaguar target ops, and shows a one-shot SET_MESSAGE_EXT
banner. Serviced once per frame from the top of retro_run() -- one poll()
and two comparisons when disabled or idle. All new statics (including the
packet accumulation buffer) are reset in both retro_unload_game() and
retro_deinit(), since iOS cannot dlclose the core.

Verified: savestate digests are byte-identical across stub-disabled and
stub-enabled-idle runs of test/tools/hires_state_digest (three checkpoints,
SHA-identical output). netstat confirms the live core binds
127.0.0.1.<port>, never *.<port>.

Also fixes two build-system gaps this change exposed: scripts/c89-lint.sh
was missing -Isrc/debug (libretro.c now includes gdbstub.h), and
Package.swift's coreSources/headerSearchPath arrays had drifted from
Makefile.common per the exact class of bug scripts/check-package-sources.sh
exists to catch -- it now reports clean. `swift build` itself still fails
in this environment on an unrelated, pre-existing manifest error
(defaultLocalization not set) reproducible on the unmodified manifest, so
full SPM compilation could not be verified end-to-end here.

Refs #652

* test(debug): end-to-end GDB attach probe

Scripted RSP client (test/tools/gdb_attach_probe.py) since the #581
toolchain does not ship a GDB. Run live against the actual core (not
just the fake-target unit tests): attach, qSupported, halt reason, 144-hex
register read, memory read, and bounds refusal on a wild address -- all
passed against ./virtualjaguar_libretro.dylib with the stub enabled.

Answers both of docs/gdb-stub-design.md's open questions, recorded there
with evidence:
- #581 toolchain ships no m68k-elf-gdb (checked the built tree after
  `make jaguar-toolchain-build`; PIN lists five tools, none a debugger).
- rln emits no DWARF: its output is classic mc68k COFF (not ELF), `-g`
  produced a byte-identical link to the same build without it, and no
  toolchain on this host can even parse the format as an object file.
  Every "source-level debugging" claim in the design doc is corrected to
  symbol-level (rln -m's load map) accordingly.

Refs #652

* fix(debug): correct overclaims and harden the GDB stub's Phase 1 edges

Post-review accuracy pass, three fixes:

- gdbtarget.c's non-perturbing comment overclaimed: it verified every
  who-gated side effect but said nothing about unconditional MMIO read
  side effects (e.g. CD BUTCH+2's DSCNTRL ack, reached only via the
  16/32-bit path this file's byte-granular reads never take). Narrowed
  to state exactly what was checked and what a debugger read through
  MMIO can still legitimately perturb, same as any processor touching it.
- docs/gdb-stub-design.md: the scripted RSP probe passes without ever
  sending the low-level +/- ack byte real GDB requires before
  QStartNoAckMode is negotiated -- a real m68k-elf-gdb would stall on its
  first qSupported. Documented as a named Phase 2 gap rather than left
  implicit in a probe that doesn't check for it.
- gdbsock.c: added the same #pragma comment(lib, "ws2_32.lib") under
  _MSC_VER that jlink_tcp.c (the file this one deliberately mirrors)
  already carries, for parity on a raw MSVC compile outside the
  Makefile's own -lws2_32 link line.
- libretro.c: a hand-edited config with a non-numeric gdb_port value
  used to silently ask the OS for an ephemeral port while the startup
  banner kept reporting the bogus requested number; now rejected in
  favour of the documented default.

Also confirmed jni/Android.mk and the MSVC buildbot targets both build
through Makefile.common's own SOURCES_C/INCFLAGS rather than a third
hand-maintained list, so neither needed the src/debug update Package.swift
did in the previous commit.

No behavior change to the wire protocol or the packet-framing engine.
Determinism re-verified byte-identical after these edits.

Refs #652

* ci(msvc): compile src/debug in the MSVC check

libretro.c now includes gdbstub.h, but the MSVC job's hand-maintained
source list and include flags had no src\\debug -- so cl.exe failed to
open the header on both x64 and x86 while every other target built fine.

Same class of drift as the SIMD blitter include-path miss: Makefile.common
carries the real source list, and these by-hand CI compiles silently fall
behind it. Only the first cl.exe invocation needs it; the second compiles
just the blitter for SSE2.

Refs #652
2026-08-27 01:40:06 -04:00
Joseph MattielloandClaude Opus 5 bc5b849fe1 feat(libretro): support RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME
retro_load_game(NULL) now boots the bare console: real boot ROM, no
cartridge inserted, matching what real hardware shows with an empty
cart slot (the boot animation, then it sits waiting).

Adds a dedicated cd_boot_strategy_none (src/cd/jagcd_cart.c) selected
directly in retro_load_game() when info == NULL, since ResolveBootConfig()
only resolves cart/CD content and there is no 68K program anywhere for
HLE to jump into -- the real boot ROM is the only sane strategy. The
strategy clears the flat cart window so a previous title's image can't
survive a same-process reload (iOS never dlcloses the core) and forces
useJaguarBIOS regardless of the HLE option.

Threads the NULL through every info-dependent branch in retro_load_game
(titledb CRC feed, CD detection, cart BIOS autodetect, the RAM-loaded-
executable reload path) instead of the previous unconditional early
return. RETRO_MEMORY_SAVE_RAM now reports zero size / NULL data for a
no-content session, since there is no cartridge EEPROM chip to expose.

Verified headlessly: boots without crashing, the boot animation renders
and settles into a stable spin (30+ seconds, no crash-watchdog trips),
retro_reset() and a savestate round-trip both work. CD boot (HLE or real
CD BIOS) stays out of scope: this core has no
RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE(_EXT) implementation, so a
frontend has no way to hand the core a disc image after a no-game
launch -- that is a separate, larger feature.

New test suite in test/test_boot_config.c (Part 3, no ROM required):
env declaration, strategy selection, SAVE_RAM gating, and a run/reset
smoke test. Full make TEST_EXPORTS=1 test suite passes.

Closes #646

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 23:29:02 -04:00
Joseph Mattiello ba40bb1dda perf(libretro): honor GET_AUDIO_VIDEO_ENABLE for presentation only
Implement RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE's video-skip hint,
but scope it to host presentation only -- never emulation. The Jaguar's
blitter and OP composite in main RAM and games read back what they
drew, so libretro.h's own warning about display-capture platforms
applies here: skipping OP/GPU/DSP/blitter execution would desync the
machine and break run-ahead determinism (the exact bug class fixed in
#400).

Boundary: TOMExecHalfline() (src/tom/tom.c) writes the emulated
picture in two places. OPProcessList()/the line-buffer BG clear read
and write tomRam8 -- real addressable Jaguar state (the OP line buffer
at $F01800-$F01D9E) -- and always run unconditionally. Separately, the
scanline_render[]/tom_render_scanline_hires call and the border-colour
fill take that already-resolved line-buffer content and store host
XRGB8888 pixels into screenBuffer/videoBuffer. screenBuffer is a
host-only presentation target no emulated processor can read back, so
those stores are the only safe thing to gate.

New tomSkipVideoPresent (tom.h/tom.c) gates exactly that store; it is
set once per retro_run() in libretro.c from the environment call
(video bit set = render as normal, matching every existing frontend
that doesn't implement the call). The audio bit is intentionally
ignored -- this is the presentation-skip half only. The retro_run()
tail-row blanking fixup is gated the same way (it only ever touched
videoBuffer), and CrashDetectFrameTick() is passed fb=NULL while
skipping so a long skip run can't misread its frozen framebuffer as a
video_stall false positive; GPU/DSP PC-escape and wedge detection keep
running unconditionally.

Verified via test/tools/hires_state_digest: savestate FNV digests at
frames 300/600/900 are byte-identical with the skip active vs inactive
on both yarc.j64 and jagniccc.j64, proving the machine cannot see it.
frame_hash_ab confirms the skip actually engages (nonblack pixel count
drops to 0 and the frame hash freezes) while the digest identity
holds. Added an opt-in --av-skip-video test hook to test/harness and
test_benchmark so these tools can answer the environment call; default
behavior (call left unanswered) is unchanged for every existing
harness-based test.

Refs: no tracked issue for this task; labeled no-issue.
2026-08-26 23:28:15 -04:00
Joseph MattielloandClaude Opus 5 96d0b1852f build: committed version.h fallback for build systems that can't run a script
src/core/version.h is generated by scripts/gen-version-h.sh and gitignored:
it carries the short git rev, so committing it would dirty the tree on every
build. The Makefile and jni/Android.mk both run the generator at parse time,
so for them the header always exists.

Build systems with no hook to run a shell script before compiling have no
version.h at all and failed on libretro.c's #include outright. The
Provenance SwiftPM manifest is the case this exists for; it is the last of
the four SPM-compat fixes tracked in #614.

libretro.c now prefers the generated header and falls back to a committed
src/core/version_fallback.h. The __has_include test is nested rather than
`#if defined(__has_include) && __has_include(...)`: the standard says #if
short-circuits, but older preprocessors that lack __has_include can still
choke parsing the call in the right-hand operand. Every compiler without it
here is Makefile-driven and takes the #else, where the generated header
always exists.

CORE_BASE_VERSION is a deliberate copy; the Makefile stays the source of
truth. Nothing rewrites the fallback at build time, on purpose: the
generator runs at Makefile *parse* time, on every build including
`make lint`, so a generator that edited a tracked file would dirty the
working tree on every build -- feeding a false "-dirty" into
scripts/build-id.sh and the VJ_EXPECT_BUILD guard, and recreating the
silent-overwrite footgun this replaces.

The copy is checked instead of fixed, the same shape as the
docs/cd-boot-matrix.md drift guard: a new c-cpp.yml job fails loudly if the
two diverge, and version-bump.yml now edits and commits both on a release
bump (it previously did `git add Makefile` alone, which would have dropped
the new file).

GIT_VERSION falls back to " unknown-rev", spelled so it cannot be mistaken
for a real rev in a bug report. It cannot reach the VJ_EXPECT_BUILD guard,
which only runs against Makefile builds.

Verified:
  libretro.c -fsyntax-only with version.h absent   0 errors (the SPM case)
  fallback expands to                              "v3.5.1" " unknown-rev"
  built dylib reports                              v3.5.1 d86659d-dirty
                                                   (generated wins, not the fallback)
  drift guard, fallback forced to v9.9.9           correctly reports DIVERGED
  make -j                                          0 errors
  make TEST_EXPORTS=1 test                         0 test(s) failed
  scripts/c89-lint.sh libretro.c                   passed

scripts/c89-lint.sh keeps generating the header before linting -- that is now
a deliberate choice, not a workaround, since the Makefile build is what the
lint models. Its comment says so, because deleting those two lines would
silently switch the lint to the fallback path.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 02:15:43 -04:00
Joseph Mattiello 7c1c6fad1d fix(libretro): reset widescreen statics per load, drop a redundant SET_GEOMETRY (#605)
Two defects in the #530 widescreen option, both mine.

1. widescreen_enabled / widescreen_geometry_pending were never reset in
   retro_unload_game() or retro_deinit(), unlike every sibling
   title-scoped static added alongside them.  iOS never dlcloses the
   core, so a 16:9 title could hand its aspect ratio to the next one:
   retro_get_system_av_info() can be called before the new session's
   first check_variables(), and that is the negotiation that sizes the
   frontend's first render target.  Added widescreen_reset() and called
   it from all three sites the titledb latches use.  The load-path call
   sits BEFORE check_variables(), so this load still latches its own
   value rather than being clobbered.

2. With widescreen pre-set before load, check_variables() latched the
   right state during retro_load_game() but the pending flag stayed
   armed, so the first retro_run() fired a value-identical SET_GEOMETRY
   on the very frame that submits the first video_cb -- the
   frontend-reallocates-and-drops-a-frame case the geometry-change
   comment in retro_run() already warns about, triggered by a no-op.
   The frontend queries av_info after retro_load_game() returns and
   already has the correct geometry, so the flag is now cleared at the
   end of load; only a genuine mid-session toggle notifies.

make TEST_EXPORTS=1 test exits 0 with zero failures; c89-lint clean.
2026-08-25 19:44:33 -04:00
Joseph MattielloandClaude Opus 5 6a8d3a6f45 feat(options): warn when a setting silently suppresses DSP idle-skip
The DSP idle-loop fast-forward (virtualjaguar_risc_idle_skip) is the
largest speed lever the core has -- 66-87% less DSP interpretation on the
titles measured (#569) -- and DSPExec() gates it off entirely when certain
other options are on.  A user who raises the RISC overclock on a
borderline-slow title therefore pays the overclock's own cost AND silently
forfeits the bigger win: it reads as "overclocking made it much slower"
with nothing anywhere to explain it.

check_variables() now logs a [perf]-tagged warning naming every active
suppressor, reusing get_variable_pertitle()'s known-bad warning pattern
from #464 including its once-per-load latch (check_variables() re-reads
every option on every frontend variable-update notification, so an
unlatched warning would fire once per option-menu visit for a session).
The latch is reset in retro_load_game(), retro_unload_game() and
retro_deinit(), next to the titledb latch -- iOS cannot dlclose a core.

Nothing is overridden: user-set values always win, so this only reports.

The suppressor list mirrors the gate EXACTLY: risc_clock_scale != 1x,
dram_timing, gpu_pipeline_timing, blit_memo.  m68k_clock_scale and
blitter_timing are deliberately NOT in it -- neither appears in the gate
(m68kClockScalePct is only in the DSP's budget arithmetic; vjs.blitterTiming
never reaches busArbiter.enabled, which only dram_timing sets), and warning
on them would false-positive on the most likely overclock combination of
all.  Confirmed by A/B on Alien vs Predator, 120 frames:

  idle_skip only                 fires=112398, 419948 DSP opcodes/frame
  idle_skip + m68k 3x            fires=127525  (NOT suppressed)
  idle_skip + blitter_timing     fires=112316  (NOT suppressed)
  idle_skip + risc 2x            fires=0,      917592 opcodes/frame, warns
  idle_skip + dram_timing        fires=0,                            warns
  idle_skip + risc 1.5x + pipe   fires=0, one line naming both

The trace/watchpoint suppressor is omitted (dev-facing, and can be armed
after check_variables() runs).  The load-path call is made from
retro_load_game() after BlitMemoSetMode(), since check_variables() runs
before ResolveBootConfig() and cannot yet see the resolved memo mode.

Refs #595

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 17:48:07 -04:00
Joseph MattielloandCursor 46aa4d850e fix(netlink): make netplay voice chat actually capture and transmit (#585)
Voice never activated over RetroArch netplay because four independent
gates each blocked it on their own:

- read_mic is all-or-nothing: RetroArch returns a buffer of silence
  unless it can satisfy the whole request.  We asked for a fixed 320
  samples (20 ms at 8 kHz) per retro_run, but one 60.054 Hz field only
  accumulates ~133, so every read came back silent forever.  Pull one
  field's worth with a fractional accumulator instead.
- Hello negotiation latched into data-only after 5 s, which is exactly
  what a host that opens a session and waits for a peer does.  Keep
  re-offering on a slow interval.
- The mic interface was queried once in retro_set_environment, before
  RetroArch's mic driver is necessarily up, latching "no mic" for the
  run.  Re-ask (rate-limited) until it answers, and open the mic as soon
  as the session is live so the device is warm before the first word.
- TX was gated on JLinkMode() == NETPACKET, wrong whenever Network Link
  is not "auto".  Use the registered netpacket sink instead.

Every failure mode was also silent; the log now names which one applies,
and reports positively once frames are going out.

Adds test_mic_pull_paces_to_field_rate, whose fake mic reproduces the
all-or-nothing behaviour and asserts we never over-request, never starve
the FIFO, and that each captured frame reaches the sink.

Co-authored-by: Cursor <[email protected]>
2026-08-25 07:04:00 -04:00
Joseph Mattiello 3d786f9023 feat(video): widescreen aspect-ratio toggle, default off (#530)
Adds virtualjaguar_widescreen (disabled/enabled, default disabled):
when enabled, reports 16:9 to the frontend instead of the Jaguar's
native 4:3 for a cosmetic horizontal stretch. The Jaguar has no
wider-viewport hardware mode (docs/enhancement-hooks.md's verdict on
this exact ambiguity), so this is presentation metadata only -- the
emulated framebuffer is bit-identical either way, only how the
frontend letterboxes/scales it changes.

Fires SET_GEOMETRY exactly once per actual option change (tracked via
a pending flag set in check_variables(), consumed in retro_run()),
same discipline test_frontend_pacing.c's geometry_stability assertion
already requires for the existing tomWidth/tomHeight geometry-change
path -- this option's change never trips that path since pixel
dimensions never move, so it needed its own trigger.

Global option only, no titledb pairs[] yet: no corpus evidence exists
for which titles look acceptable stretched. Per-title gating is a
follow-up once that evidence exists (docs/enhancement-hooks.md,
docs/hires-stage0-census.md methodology).

Verified: c89-lint clean; make TEST_EXPORTS=1 test has the same 2
pre-existing failures as unmodified develop (case4/case7 in
test_pertitle_db, unrelated stale fixture from #551), no new
failures; standalone dlopen check confirms aspect_ratio reads 4/3
disabled -> 1.333333, 16/9 enabled -> 1.777778.
2026-08-25 04:16:34 -04:00
Joseph MattielloandCursor 56ae9c7f1d feat(netlink): voice chat over RetroArch netplay (#585)
Frame vjag-netlink-2 with NP_UART / NP_VOICE / NP_VOICE_HELLO, auto-negotiate
voice capability (data-only fallback), and mix up to 3 far-end speakers.
TCP discovery-UDP voice from #584 is unchanged.

Co-authored-by: Cursor <[email protected]>
2026-08-25 03:28:59 -04:00
Joseph MattielloandCursor 5f42065d64 fix(voice-chat): address Kimi review on keepalive seq and mic gating
Keepalives no longer consume voice sequence numbers (and are suppressed
during active speech), so the jitter gap-filler cannot insert a 20 ms
silence hole twice a second. SenderId uses a local xorshift instead of
srand/rand. Mic open is gated on TCP+discovery (or local monitor) and
re-evaluated each frame. Design doc corrected to zero-order hold;
inertness make-test invocation ledgers exit 77.

Co-authored-by: Cursor <[email protected]>
2026-08-25 01:28:33 -04:00
Joseph MattielloandCursor a49156fded feat(netlink): host-side voice chat over the discovery socket (#485)
Opt-in out-of-band voice channel for the Jaguar Voice Modem's real
selling point — simultaneous voice + data — without touching the
emulated UART or savestate. Mic capture via RETRO_ENVIRONMENT_GET_MICROPHONE_INTERFACE,
G.711 µ-law at 8 kHz over VJVC datagrams on the existing discovery UDP
port, VAD or keyboard PTT gating, and saturating mix into sampleBuffer.
Includes design doc, unit/pair/inertness tests, and harness --mic-tone.

Co-authored-by: Cursor <[email protected]>
2026-08-25 01:10:28 -04:00
Joseph MattielloandClaude Fable 5 42757687d5 perf(dsp): close the executed-path gap in the idle-loop probe
The probe is hooked only on a taken backward `jr`, so an arrival at the
loop head said nothing about how the previous arrival got there: a
not-taken `jr`, a `jump`, or plain fall-through all reach the head
without the probe ever seeing them.  Everything the admission rule
checked therefore described the DECODED body, never the path the machine
actually walked -- so a compound period with the same net register and
flag effect could hide instructions that were never decoded, including a
store.  Eliding copies of a store to I2S LTXD/RTXD, a blitter command
register or a latch clear is exactly the silent, hardware-visible
divergence this design exists to prevent.

dsp_exec_opcode_count is incremented in precisely three places -- the
main DSPExec loop and the inlined delay slots in dsp_opcode_jump and
dsp_opcode_jr -- so one traversal of a branch-free admitted body charges
idleBodyCount + 1 opcodes, deterministically.  Any other route from one
arrival to the next must run the whole body plus at least one further
instruction, so it costs strictly more.  Requiring exact equality admits
the straight-line traversal and nothing else.

test/tools/dsp_idle_probe_falsify hand-assembles both shapes into DSP RAM
and drives DSPExec directly: a genuinely idle loop must still fire (so a
silently disabled feature cannot pass the test), and a compound period
hiding an undecoded store must not.  Verified in both directions --
without this check the exploit shape fires; with it, it is rejected while
the idle loop still fires.  Wired into `make test`.

test/tools/dsp_idle_ab is the A/B comparator used for the corpus sweep,
committed so the evidence is reproducible.

The option now ships `disabled` for a release cycle.  The corpus A/B is
byte-identical, but the failure mode if an untested title slips through
is a silent divergence nobody can attribute, against a speed win a user
can opt into with one toggle -- an asymmetry nine titles do not settle
for a ~200-title library.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 14:32:33 -04:00