41 Commits
Author SHA1 Message Date
Joe Mattiello a5593e55d9 test(tom): preserve Atari Karts HC regression coverage and doc correction (#1096)
* test(tom): guard Atari Karts racing line after HC fix (#1085)

* ci: allow scoped temporary Linux runners for Karts validation
2026-10-26 10:06:58 -04:00
Joe MattielloandClaude Opus 5.5 1dec1cb2fd feat(app): nightly builds default the emulation thread On, with the phase 3 gate (#967 3d) (#1082)
* test(app): no_threaded_limits keeps every feature on while the emulation thread runs (#967 G1a)

Fails when a threaded-only gate, its user-facing text or its limit test comes back, when the GDB stub is locked for any
reason but hardcore, when a file starts asking "am I threaded?" beyond its allowlisted count, or when tsan.supp grows.
Verified red on the pre-3a tree (abc9054e) and green on develop.

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

* test(app): Studio gate for the emulation thread with every feature on (#967 G1b)

--show-window tools opens all eight Tools windows, --perf-halt does F9's break and continue at fixed UI iterations, and
emu_thread_gate_check.sh asserts no owner violation, a window presenting at display rate while halted by F9 and by gdb
(ports from the OS at run time), a connected Neuro client answering presses, all with --emu-thread on.

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

* test(app): no_threaded_limits counts bare threaded() and fails on under-counts (#967 G1a fixup)

Check 5 now also counts the bare threaded() calls inside App and CoreHost, counts occurrences instead of lines, and fails on
an allowlist that is too loose; the over-broad "Off while" text pattern is anchored to the old Settings string; commented-out
add_test lines and ==/!= comparisons no longer confuse checks 3 and 4.

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

* feat(app): build channel and the emulation-thread default rule (#967 3d)

VJ_BUILD_CHANNEL (release | nightly | dev, default dev) sets VJ_EMU_THREAD_DEFAULT (1 for nightly), next to VJ_TV_DEFAULT.
The default applies only while ui.emu_thread is unwritten: an explicit Off stays Off in nightlies and an explicit On stays
On in releases (emu_thread_default_unit). Nothing is built with the nightly channel yet.

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

* feat(app): show the build channel and the emulation-thread default (#967 3d)

Settings > Interface says which way the default goes in this build, About names the channel, and the start-up log reports
channel, default, the stored choice and this run. The smokes derive their expected output from the configured channel, so
a nightly configure checks the On default and an explicit Off still winning; the inline hashes are untouched.

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

* test(app): the Studio gate wakes the display before each run and stays quiet after a kill (#967 3d)

A window created while the Studio display sleeps blocks forever in the GL swap, threaded or inline (soak-results.md), so the
G1c attempt-1 "hang" was the display. Before each of runs A, B and C the gate now wakes it (caffeinate -u -t 3), holds it
awake for the run (caffeinate -u -d -i, ended after the run) and re-checks system_profiler. Still asleep: the run is skipped and
reported "display asleep, run skipped" as an environment failure (exit 3 when nothing else failed), distinct from a hang.

A killed or skipped run no longer prints python tracebacks or "[: : integer expression expected": the CSV helpers answer
"no data" for a missing file, empty reads become 0, the per-run asserts that need logs are skipped after a watchdog kill,
and the shell's "Terminated" job notices are silenced.

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

* test(app): testServeDebugLane waits for the lane service to finish, not for a fixed sleep (#967 3b flake)

emu_thread_unit failed on GitHub's hosted macOS runner (run 39929091926) at "it waited about maxWait despite the frame post
(0.0 ms)". The test waited for the fake core to take its serveNow count, then slept 10 ms and read `waited`, which the
emulation thread stores only after the 4 ms lane wait returns; a thread descheduled in between left `waited` at its initial
0. Not the stale debug wake: nothing sets wakeDbg_ before that first wait (reopen() clears it, the frame post only sets wake_),
and a stale wake would make a wait of microseconds, not leave the value unset.

The test now counts finished rounds (stored after `served` and `waited`) and waits for the count, with a 5 s bound, and the
fake core sleeps 30 ms before serving so the old reading order fails every time. The assertion is kept; its upper bound is 2 s
instead of 0.5 s for a slow VM.

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

* ci(app): nightly builds default the emulation thread on (#967 3d)

app-package.yml derives CHANNEL from release_tag (nightly; v* -> release; else dev) beside TV_DEFAULT and passes
-DVJ_BUILD_CHANNEL to the macOS, Windows and Linux configures; the flatpak job rewrites the committed manifest's
-DVJ_BUILD_CHANNEL=release to the lane's channel. app.yml configures one Linux row with the nightly channel so the On
default is built and tested on every PR. Docs updated, including the design text the G1d review found out of date
(closed windows and Neuro's watch copier, frame advance while halted, debugCall waits, the unstated bounds).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-23 16:15:50 -04:00
85cd3f1642 feat(app): debugger halt and GDB stub with the emulation thread on (#967 phase 3b) (#1068)
* feat(app): bounded halt pump on DebugCore; the UI mirrors the halt

DebugCore owns the halt: pump() serves the debug lane, waits at most kPumpWait (4 ms) on a debug-lane-only wait and
returns VJ_DBG_RESUME_NONE to GDBHalt's own loop, which keeps the socket service and the halt timeout.  The UI writes
its verdict tagged with the halt serial it saw, so a verdict for a dead halt never resumes the next one; frameEnded()
closes a halt the core left on gdb's say-so.  DebugSession mirrors HaltState once per UI frame (syncHalt) and fires
onHaltUi / onResumeUi on serial edges; attach/detach/breakNow are ordered through the lane and a pending detach.
Inline keeps the nested UI frame.

Refs #967

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

* fix(app): a STEP the core never re-halts must not leave the UI mirror halted

pump() returns STEP without bumping the halt serial, so the bar stays up while the core steps.  If the frame ends
before the core re-halts (the stepped instruction ended it, or the target had stopped), frameEnded() found nothing
halted and the mirror stayed halted for good; inline, stepEmulation() is gated on halted(), so the frame that would
re-halt never ran and F9 / Detach wrote verdicts nothing took.  DebugCore now remembers a STEP in flight (cleared by
the next halt entry or setIface) and frameEnded() closes it like a foreign resume.

Refs #967

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

* fix(app): verdicts and detach only against the core's live halt

After a STEP verdict the halt serial is unchanged but the core is running until it re-halts, so DebugSession::detach()
offered a DETACH for a serial the re-halt then dropped, left detachPending_ set (attach / Break refused) and, with
hardcore turning on in that window, left the core attached.  resume / step / detachAndResume now require the core's
halt to be live (parked, same serial); detach and detachAndResume fall back to the lane detach otherwise, and that post
also releases the pump if it runs inside a live halt.  haltedFrame() syncs the mirror first so the first nested frame
meets the current halt; the inline resume hook keeps headless audio unpaused as before.

Refs #967

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

* feat(app): the emulation thread pumps the halt; quitting ends it

Threaded, the pump's wait is App::haltWait (serve the debug lane for at most 4 ms, refill the views when a request is
pending); frameEnded() runs after every retro_run so a halt gdb ended is closed on our side (inline: from syncDebug
outside a frame).  beginQuit() sets quitting_, wakes the pump and waits at most 200 ms for the detach the pump answers
with, before the auto-save, the SRAM flush and the 3 s unload; closing the window inside an inline halt takes the same
path.  haltedFrame runs only inline.  The halt bar says "HALTED by gdb" for a halt the app did not ask for; a halt at
the CPU window's own temporary breakpoint (run to cursor, step over) is ours (DebugSession::ownsBreakAt).

Refs #967

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

* feat(app): calls and frame advances give up when a halt begins; content changes detach first

EmuThread::call and advance take a give-up predicate: a call queued behind a frame that halts returns instead of parking
the UI behind the busy spinner; advance registers its budget on the debug lane and waits with the same predicate.  Every
UI-thread call passes one (App::emuCall).  Threaded, open / close / reset / disc detach the debugger first and then
proceed; save / load state and boot refuse with a toast; frame-lane posts wait for Continue and the halt bar and the
Core options tab say so.  Inline keeps refusing (the call would run inside retro_run).  RetroAchievements replies ride
the debug lane, so none is ever cancelled.

Refs #967

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

* fix(app): a given-up disc follow-up retries; close reports a re-halt; deterministic give-up and re-drain tests

followDiscSwap advances seenDiscSwaps_ only when its call ran, so a halt no longer loses the RA / cheats follow-up.
closeContent says so when its detach predicate gave up.  The netplay headless step stops re-budgeting a frame whose
advance gave up.  testCallGiveUpLosesToStart covers the compare-and-swap-lost branch of call(), and
testAdvanceBudgetDoesNotOvertakeFramePost covers the loop's second frame-lane drain; the 300-iteration smoke loop is gone.

Refs #967

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

* feat(app): the halt and the GDB stub work on the emulation thread; a silent host behind every halt; hardcore locks the stub

The phase 1 limits are gone: Break / Step / breakpoints work threaded and virtualjaguar_gdb_stub is a plain option again.
Whenever the stub is enabled the app attaches its host on the emulation thread inside the load (before the first frame,
so a gdb_wait boot halt is seen) and again after any frame it is missing, so every gdb halt flows through the app's
pump: the halt bar shows it, calls give up, quitting detaches.  RetroAchievements hardcore locks the stub to disabled at
load, and a hardcore flip with the stub open reloads the game so the lock reaches the core (phase 3 plan, decisions 2
and 3).

Refs #967

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

* fix(app): B4 review: pre-frame silent host, gdb halts survive headless inline, Detach clears breakpoints

- emuRunOneFrame re-attaches the silent gdb host before the frame, so a gdb halt right after a Detach is hosted
- headless inline haltedFrame releases only app halts and quit; gdb's halt keeps the core's socket loop serving
- Detach drops the session breakpoint list with the core's table
- Settings > Emulation thread hint reflects that the halt and stub work threaded
- locked-option warning is generic; a hardcore restart waits out an inline halt

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

* test(app): the halt and the GDB stub end to end on the emulation thread

debug_e2e_threaded drives break / step / continue / detach / hardcore / quit from a UI thread that never blocks over
50 ms, pins the option post that applies after Continue, the save state that gives up when a halt begins, a posted lane
detach run inside a live halt, the 3 s join, and a gdb_wait boot halt with no client (Detach, quit).  gdb_threaded_halt
finds a headless run halted at boot from a gdb client, reads memory, continues, and quits the app mid-halt, threaded and
inline, plus an inline hardcore variant: SIGINT / SIGTERM end a headless run cleanly (installed after SDL_Init, headless
only).  The headless inline loop now closes a gdb halt after each frame (frameEnded + syncHalt), without which the
hardcore reload retry stays blocked.

Refs #967

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

* fix(app): B5 review: the cancelled save is proven absent after frames run; free ports; signal handler resets

debug_e2e_threaded waits for frames after the halt ends before asserting the cancelled save left no file (it is held while
halted, so the old check was vacuous) and saves to a different file; the gdb_wait sections probe a free port; the test
also fails on ThreadSanitizer.  The signal handler restores SIG_DFL after storing the flag (a second signal kills a wedged
run) and g_signalQuit is asserted lock-free.  gdb_threaded_halt.py's watchdog kills live children; a missing Python3 is
reported at configure time.

Refs #967

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

* docs(app): the halt and the GDB stub on the emulation thread

Halt model, Detach, hardcore lock and reload, gdb_wait, headless signal quit,
testing; README, testing.md, design section 6 (2026-10-22 as-shipped notes),
plan 3b task list.  Includes the MSVC fix: debug_e2e_threaded includes
<ws2tcpip.h> for socklen_t.

Refs #967

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

* fix(app): quitting while a gdb client holds a halt no longer hangs

The pump answered a quit with DETACH, which drops the app's host inside the core.
A gdb client's breakpoint that is still armed (a hot loop) then fires again at
once, with the socket open and no host: the core parks in GDBHalt() for the
client, and the quit's flush, autosave and unload wait on it forever.  The pump
now answers CONTINUE and the host stays attached (unloading drops it).  Every
wait in the quit epilogue is bounded (flush 1 s, autosave 1.5 s, skipped while
the core is still halted); the unload keeps its 3 s backstop.

Also: re-attach the silent gdb host before each frame (inline stepEmulation,
headless inline loops), comment/doc fixes, EmuThread::stop precondition, and the
three gdb tests' PASS regexes lose their ^ anchor.

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

* fix(app): a hardcore restart that gives up stays pending and retries

Achievements::pump cleared resetPending_ before the restart and ignored its result. Threaded, the
restart can give up when the core halts again (a gdb client with a breakpoint still armed): hardcore
was then active without a clean restart and the open GDB stub bypassed the hardcore lock.

The restart now stays pending until it takes. reset() returns whether it ran. A give-up with the game
still loaded (and, for the reload, the stub still open) retries at the next pump; a restart that
failed to load the game, or closed the stub, is dropped. The cheat reset is posted once and the
restart is counted once (--ra-expect-reset), the give-up is logged once.

Tests: VJ_TEST_RESTART_GIVEUPS=N makes the first N restarts report a give-up (a headless run cannot
stage the re-halt); gdb_hardcore_flip_giveup_* (reload path) and ra_hardcore_reset_giveup_* (reset
path), inline and threaded.

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

* fix(app): a signalled threaded --neuro headless run still prints the Neuro report

The signal-quit block returns before the TIMING/report section; report first (it only
touches UI-thread state) so a SIGTERM'd --neuro run behaves like a finished one.

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

* test(app): gdb_* tests take their port from the OS at run time (#967)

The six tests with hard-coded ports 23457-23463 (gdb_enabled, gdb_enabled_threaded,
gdb_hardcore_flip_*, gdb_hardcore_flip_giveup_*) all failed on the hosted Windows
ARM64 runner with 'failed to open port': every one of the distinct ports was
refused, which points at a reserved/excluded range, not a collision (the Python
gdb tests, which already pick a free port, passed there).

tests/with_free_port.py binds 127.0.0.1:0, reads the port back, substitutes {PORT}
in the command and, with --config-ini, writes the saved stub setting for the
hardcore tests.  The assertions and ctest regexes are unchanged.  Registered only
with Python3 (STATUS message otherwise).  RESOURCE_LOCK vj_gdb_port on every test
that binds a stub port closes the bind-0 TOCTOU window.

Refs #967

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

* fix(app): start Winsock before the core loads so the GDB stub can bind on Windows (#967)

Every gdb_* test that opens the stub failed on hosted Windows x64 with
"[GDB] stub enabled but failed to open port N", on fixed and on OS-chosen
free ports alike. The core's GDBSockOpen (src/debug/gdbsock.c) calls socket()
without WSAStartup, because its libretro host (RetroArch) starts Winsock. The
app never did on that path (neuro and netplay only start it for themselves),
so socket() failed with WSANOTINITIALISED and the stub stayed disabled. The
feature was therefore broken for users on Windows too, not only in tests.

platform::initNetworking() now runs WSAStartup once, right after SDL_Init and
before any core load, headless runs included. A no-op on macOS and Linux.
Winsock is reference counted, so neuro's and netplay's own WSAStartup stay
harmless. Also corrects the comments that blamed reserved port ranges.

Refs #967

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

* test(app): with_free_port.py hands the app explicit stdout/stderr (#967)

The Packages / Windows x64 job builds the GUI-subsystem exe (VJ_APP_WIN32_GUI). There, gdb_enabled, gdb_enabled_threaded and the gdb_hardcore_flip* tests missed their PASS regexes. Popen(cmd) left the child's standard handles to inheritance, so Windows started the GUI-subsystem app with no stdout. console_attach.cpp then attached the runner's console, and the output never reached ctest's pipe. The wrapper now passes stdin=DEVNULL and stdout/stderr explicitly, so the output stays on the pipe. Tests that ctest launches directly already get redirected handles and were unaffected.

Refs #1075

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: dry <dry@x>
2026-10-23 02:36:04 -04:00
e7009c440a ci: trim develop-push runs, debounce nightly, route macOS to Studio runner (#1074)
* ci: stop re-running PR-covered workflows on develop pushes; debounce nightly; route macOS to Studio runner

Drop develop from push: in c-cpp, app, regression-test, acid-test, installer,
jaguar-demos, microbench, jaguar-toolchain and cheat-import (PRs already ran
them); ignore develop in pull-request.yml. release.yml's nightly waits 10 min
in its guard job so a merge burst builds once. macOS rows of c-cpp/app/
regression and the unsigned iOS/tvOS nightly rows use the self-hosted Studio
runner on trusted events only, with a VJ_STUDIO_RUNNER=off kill switch.

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

* ci(spm): find blitter_simd objects independent of SwiftPM build layout

Xcode 26 SwiftPM keeps objects under .build/out/Intermediates.noindex/...
instead of <triple>/debug/virtualjaguar.build, which broke the vtable check on
the Studio runner. Search by object name and fail loudly if one is missing.

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

---------

Co-authored-by: dry <dry@x>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:24:46 -04:00
Joe MattielloandClaude Opus 5.5 6ce76c1cf8 feat(app): Neuro on the emulation thread (#967 phase 3c) (#1066)
* feat(app): AgentPlayer and the snapshot's agent channel (#967)

The Neuro agent's input becomes a program the emulation thread replays:
InputSnapshot gains an agent channel (AgentStep / AgentProgram /
AgentProgress; publishAgent, takeAgent, reportAgent, agentProgress) behind
its own mutex and a gen atomic, so the per-event lock is untouched.
InputQueue gains version, lineage, firstFrame, trim and program, and
AgentPlayer is the pure emulation-thread replica (load, frame, progress),
driven by pollAgent. Unit-level only: the app is wired in the next task.

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

* feat(app): Neuro presses through the input snapshot (#967)

The UI-side Agent now publishes the input it accepted as an AgentProgram
(Agent::publishTo) and absorbs the replica's progress (Agent::absorb), which
trims its queue and drives busy(), pressedNow() and framesPlayed().  The app's
input poll runs the AgentPlayer replica one step per emulated frame, inline and
threaded alike (pollAgent); Agent::frame() and InputManager::agentControls are
gone (InputManager::addAgentControls keeps the inline hashes unmoved).

The #1025 pins are ported to the replica (testAgentGating through a Replica
helper, loopback with a frame-numbered record) and extended (testAgentProgram:
busy covers older progress, release_all and hardcore clear the replica within
one poll, Neuro off, append mid-play does not replay).  New neuro_core_e2e runs
the replica against the real core (inline, lock-step threaded, free-running at
600 Hz, disabled) and neuro_app_inline runs the headless app against a fake
Neuro server.

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

* feat(app): Neuro reads RAM watches through a DebugViews request (#967)

Neuro no longer reads core RAM through the retro_get_memory_* externs.
The profile's watches are one DebugViews request, a Memory request over
the covering range or a Dram request when the span is wider than 64 KiB,
and the emulation thread's copier fills it after each frame.

neuro::WatchFeed (neuro_watch.h) owns the request on the UI thread: it
gives a changed range a fresh id so the old range's buffer is never read
as the new one, reads the buffer at most every 0.25 s, and adopts it only
when it is ok, current and answers exactly the request asked.  Anything
else leaves the state text at "?".  GameState::ram / ramSize become
watchBytes / watchBase, watchValue gains a base argument, and
Agent::watchRange gives the covering range.

Headless inline runs move the refresh serial (syncDebug) and run the
copier after each frame, and the end-of-run report prints the state line
(neuro_watch_inline pins a cheat byte read through the Dram path).

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

* feat(app): Neuro works with the emulation thread on (#967)

The gate that turned Neuro off while the emulation thread is on is gone:
settingsFrom no longer disables it, neuroInit does not log "off for this
run", and Settings > Neuro drops the hint.  The program/replica design from
the earlier 3c commits (AgentProgram through the input snapshot, AgentPlayer
in the core's poll, watches as a DebugViews request) is what makes this safe.

The threaded headless --neuro run ticks Neuro in lock step: one frame at a
time, in real time, like the inline loop, and neuroHeadlessReport prints in
both modes.

neuro_off_threaded is replaced by its twins: neuro_on_threaded,
neuro_watch_threaded and neuro_loopback_threaded (the loopback run with the
poll on its own thread; its frame-numbered press record equals the inline
run's).  Docs: neuro.md, app/README.md, docs/agent/testing.md.

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

* fix(app): Neuro final-review fixes: one lock-step headless loop, watch staleness (#967)

- App::runHeadless: the threaded mock-mode and --neuro loops are one lock-step
  loop in the inline loop's order, so the 3b rebase meets two loops, not three.
- WatchFeed::dropIfStale: neuroTick drops a previous game's bytes before it
  lends them to the state text, not one tick later.
- WatchFeed cadence: a rejected buffer is not re-read every UI frame; the
  clock resets on release, a key change and a dropped epoch.
- neuroHeadlessReport forces its read (comment now matches the code).
- docs/neuro.md: the 2 MiB copy runs while Neuro is on, about 10 times a second.
- Tests: dropIfStale and rejected-buffer cadence (testWatchFeed), a non-zero
  low byte for the Dram index, absorb on a stale lineage (testAgentProgram);
  the testAgentGating comment moves back above its function.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-22 19:38:19 -04:00
Joseph Mattiello 00d205caa4 Merge master (v3.10.0) back into develop
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	.github/workflows/cheat-import.yml
#	Makefile
2026-10-21 23:43:06 -04:00
Joe MattielloandClaude Opus 5.5 5d0afd4b37 feat(app): Tools windows work with the emulation thread on (#967 phase 3a) (#1061)
* feat(app): a debug lane in the emulation thread's command queue (#967)

EmuCommands gets Lane::Debug beside the frame lane; the loop drains both at
each frame boundary. EmuThread::debugCall / debugPost are safe inside
retro_run() once the halt pump serves the lane (phase 3b).

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

* feat(app): DebugViews, per-window requests the emulation thread fills (#967)

A request table with a double buffer per request: the UI sets and reads, the
emulation thread (inline: the same thread) fills after each frame. A changed
request or a content switch invalidates its buffer; no lock is held across a
core call.

Refs #967

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

* feat(app): DebugCore owns the debug interface; DebugSession marshals to it (#967)

DebugCore wraps every vj_debug_iface entry point (emulation thread only);
DebugSession routes each action through coreCall/corePost, which run inline
until App wires them to the debug lane. setHaltAvailable gates attach, Break
and breakpoints so a threaded run can keep the windows and refuse the halt.
Inline behaviour is unchanged.

Refs #967

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

* feat(app): Memory viewer and Palette read published view buffers (#967)

The Memory grid, its selection footer and the Palette ask the view copier for their bytes and draw the
published buffers; a content switch (DebugViews epoch) resets them. SearchJob::step and the CRY table
each take one callCore. Adds the shared counting test fixture (debug_ui_fixture.h) and the
debug_no_direct_iface static guard that the other tool windows reuse.

Refs #967

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

* fix(app): resized Memory window adopts its wider buffer; guard catches core() calls (#967)

fetchWindow compared only serial and start address when deciding a buffer was already adopted, so a paused
window that grew (bytes-per-row change at the region top) kept the shorter window. It now compares size too.
CheckNoDirectIface also rejects the session's DebugCore wrappers (s.core().peek( and friends). The smoke test
pins the resize case and asserts the Palette really adopted a changed CLUT byte.

Refs #967

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

* feat(app): CPU debugger reads Regs and Disasm view buffers (#967)

Refs #967

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

* fix(app): CPU walks drop stale answers; rows re-adopt on every answer (#967)

Refs #967

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

* feat(app): Object list reads the OpSnapshot request; step-a-frame via advance(1) (#967)

Refs #967

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

* fix(app): Object list step bumps the serial after the frame; release posts the clear (#967)

Refs #967

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

* feat(app): Blitter log and Textures through view buffers and core calls (#967)

Refs #967

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

* feat(app): Cheat scanner on the Dram request; Audio scope available with the thread on (#967)

The scan pass is one DebugSession::snapshotDram (callCore: the 2 MiB peek runs on the emulation side); the live column reads a published Dram request and copies the buffer only when a newer fill exists. The raw memoryRegion path is gone. The scope gets a framesSeen test hook and a threaded-audio gate test.

Refs #967

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

* fix(app): Cheat scanner shows -- until a value was read; new rows carry the scan's own values (#967)

Per-row known flag: cleared on a new result list, release and an epoch change; edit prefill and Add as cheat refuse a row with no value. doScan derives the rows from the RAM image it just read, not the older published buffer. Tests: search before the first answer, stale held buffer, collapsed window, unreadable core.

Refs #967

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

* feat(app): the Tools windows work with the emulation thread on (#967)

The App fills the debug views after each emulated frame (and on the UI thread
inline), wires DebugSession to the debug lane, hands the core's interface
over again and narrows the thread gate to "halt not available": Break, Step,
breakpoints and the GDB stub stay off until phase 3b. --show-window opens a
tool threaded. DebugSession::iface()/setThreadBlocked() are gone.
debug_views_threaded runs the real core on the emulation thread with the
session wired as App wires it.

Final-review fixes folded in:
- "Step a frame" refuses while the debugger is attached in the threaded case too, because advance() has no give-up.
- The scanner's live column waits on its own buffer (DebugViews::serialOf) instead of on any pending view.
- The -emu.csv perf trace gains a trailing fill_ns column that times the debug-view copier.

Refs #967

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

* docs(app): correct the guard's getter list and the Step-a-frame comments (#967)

CheckNoDirectIface.cmake named haltSerial() as a legal core() getter. DebugCore has no haltSerial() yet; it arrives in phase 3b. The op_view.h and op_ui.cpp comments now say that Advance also refuses while the session is attached, because emu.advance() has no give-up.

Refs #967

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

* fix(app): a replaced core closes the tools like an unload (#967)

Threaded openContent() on a running game publishes null and the new
debug interface inside one blocking emu.call(), so the UI's syncCore()
saw only the final non-null interface: closeAllTools() was skipped and
the cheat scanner kept the old game's candidates, which it could then
compare or write against the new game's RAM. syncCore() now closes the
tools whenever it replaces a core it had (generation moved, had a core),
which is what inline does when it sees null then the new interface.

Refs #967

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

* fix(app): every breakpoint control greys out while the halt is unavailable (#967)

With the emulation thread on, only the toolbar's Break button was gated:
the margin click, the context menu's Set breakpoint and "Add at cursor"
still called toggleBreakpoint(), which always refuses without the halt
machine and reported the misleading "table is full / not attached"
error. All three now share one predicate (canAddBp): the menu item and
button grey out with haltDisabledReason() as the tooltip, the margin
hover says why, and toggleBp() itself answers with that reason without
touching the core. Removing a breakpoint stays allowed. The Break
tooltip uses the same reason text.

Refs #967

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

* docs(app): Tools-window actions wait for the frame in progress (#967)

README: with the emulation thread on, an action that reaches the core (a Tools-window edit, a load, a state save) waits for the current frame to end, including link play's bounded peer wait. It does not run immediately.

Refs #967

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-21 19:36:13 -04:00
Joseph Mattiello 3c683169ef ci(core): share compile-only release builds and preserve CI artifacts 2026-10-20 18:39:49 -04:00
Joseph Mattiello 2165519e97 ci: allow ref-scoped temporary runners for lightweight checks 2026-10-20 17:34:02 -04:00
Joe MattielloandClaude Opus 5.5 0085c4485d feat(app): emulation thread phase 2 — achievements, netplay and busy calls (#967) (#1031)
* docs(app): emulation thread phase 2 plan (#967)

Achievements and netplay back on with the emulation thread (core-event stream, rc_client_do_frame on the
emulation thread, netplay as a UI-thread object fed by the stream), the cheat batch as a regular command, the
250 ms busy spinner, and the Phase 1 open items (MinGW waits, limits after a failed start, the vsync-off nap).

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

* feat(app): EmuSnapshot in its own file, with the core-event stream (#967)

CoreEventQueue carries log lines, vj_events and option writes from the emulation thread to the UI thread in order;
log lines are bounded, events and option writes never dropped.

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

* feat(app): core log lines, events and owner option writes reach the UI thread through the stream (#967)

CoreHost::onUiEvent gets every log line and vj_event on the UI thread (pinSnapshot), with an epoch that rides the
command queue; option writes made on the emulation thread inside a call() are applied to the UI's mirror and the
config there too, never on the emulation thread.  A content switch drops queued lines and events.

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

* feat(app): netplay works with the emulation thread on (#967)

Netplay stays a UI-thread object: the core's link lines and events reach it through CoreHost::onUiEvent, filtered
by an epoch bumped at every start/stop/bounce, and the endpoint bounce completes after the core has read "disabled"
for 2 emulated frames (3 threaded), counted with frameCount().  Threaded twins of netplay_pair (both sides, voice
modem, a mixed threaded/inline pair) and of netplay_no_partner, which must exit 1, never 3.

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

* refactor(app): the cheat list reaches the core as one regular command (#967)

CheatManager::apply() builds one CheatBatch (reset, then every set) and hands it to commit, which the app posts to
the emulation thread; the three sinks and the UI-thread batch collector are gone.

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

* refactor(app): RetroAchievements safe on two threads (#967)

rc_client events are copied into AchEvents and applied in pump(); core RAM, the memory map and the cheat resets
are reached on the emulation thread (call/post); read_memory off it answers address checks from the map without
touching RAM; the rich-presence line is computed with the frame and published; the mock server is locked.  Inline
runs behave as before.

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

* fix(app): high-resolution short waits for the emulation thread on Windows (#967)

The MinGW runner paced threaded fast-forward at one frame per ~15.6 ms (63.4 vs 96.6 frames/s); develop's newest
completed MinGW runs (x64 and ARM64) pass emu_thread_unit, but ctest hides a passing test's output so the timer note
is not visible there, and no MinGW run on develop has yet carried the 1 ms precise-wait check.  preciseSleepNs uses a
high-resolution waitable timer, and every app job now prints emu_thread_unit's timer report.

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

* test(app): the precise-sleep check uses the shortest wake (#967)

A starved host still has some on-time wakes, while timers that are coarse by platform (every wake ~15.6 ms on MinGW)
have none, so the check asserts on the shortest of 20 preciseSleepNs(1 ms) waits; the note prints mean and min.

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

* feat(app): RetroAchievements works with the emulation thread on (#967)

rc_client_do_frame (and with it rc_client_idle) runs after each frame on the emulation thread; the UI pump posts the
idle while paused.  A threaded headless RA run steps one frame at a time in the inline order, so every ra_* test has
a _threaded twin that lands its steps on the same frames.  Only the debugger tools and the GDB stub stay off.

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

* fix(app): precise_sleep.h includes a lean windows.h (#967)

emu_commands.h reaches ~24 MinGW translation units through app.h; WIN32_LEAN_AND_MEAN keeps legacy winsock and macro
collisions out of them (the waitable-timer and Sleep calls need nothing outside it).  A WaitForSingleObject that
does not return WAIT_OBJECT_0 now falls back to Sleep(1), so a failed wait cannot make waitUntil spin.

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

* fix(app): quiesce the emulation thread before RetroAchievements shuts down (#967)

App::shutdown destroyed the rc_client while a free-running emulation thread could still be inside rc_client_do_frame
or a posted emuIdle: it now unloads and stops the thread first, then ach.shutdown() (still before video.shutdown(),
images.stop() needs the GL context).  AchHttp::ensureWorker runs inside request()'s locked block, and shutdown()
moves the worker out under the lock and joins outside it, so request() is safe from the UI and the emulation thread
(new two-thread case in ach_http_test).  onContentLoaded and onReset resync framesSeen_ so frames that ran before the
load are not counted as the new game's.

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

* feat(app): a call() that takes over 250 ms shows a busy spinner (#967)

EmuThread::call() runs a wait hook on the waiting thread from 250 ms on; the app's hook pumps OS events and swaps a
spinner drawn with scissored clears -- no ImGui frame, no call(), no read of a field only a call() writes (CoreHost
counts those while the UI waits).  Headless, it logs once per call.

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

* fix(app): a failed emulation-thread start lifts the feature limits; vsync off naps to the next refresh (#967)

Phase 1 final-review minors 2 and 3.

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

* docs(app): emulation thread phase 2 -- achievements, netplay, the busy spinner (#967)

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

* fix(app): drop the previous game's achievement trackers and indicators after a content switch (#967)

Events carry the generation they were queued in; onContentLoaded and onContentClosed bump it, and drainEvents drops
the stale tracker, challenge, progress, attempt started/failed and reset events (no HIDE would ever come for them).
A real unlock, completions, scoreboards and the connection state are kept.

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

* fix(app): drive rc_client's mutating calls from the emulation thread only (#967)

App::reset runs ach.onReset() inside the emulation-thread call (rc_client_reset raises events); applyHardcore,
setEnabled(false), logout and the pause check (rc_client_can_pause advances the pause-decay state) go through coreCall,
so none of them runs while rc_client_do_frame does.

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

* fix(app): the vsync-off present loop runs on a display-period deadline grid and handles input while it naps (#967)

The nap was anchored at the swap end, so the period was refresh + build + swap (~54-57 Hz on a 60 Hz display) and no
input was published during it. LateLatch::advanceGridNs / gridNapNs keep a deadline grid; App::presentNap waits for
events as latchWait does and hands them to the emulation thread at once. (SDL 3.4 has no SDL_WaitEventTimeoutNS.)

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

* fix(app): the emulation-thread setting hint names only what is still switched off (#967)

RetroAchievements and netplay work with the thread on; the Tools (debugger) menu and the GDB stub do not.

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

* test(app): pin that a reload drops the previous game's queued core log lines (#967)

The block had no onUiEvent, so it only proved option survival. It now queues a log line inside the same call as the
reload, and asserts it is not delivered while the option write still is.

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

* fix(app): final-review minors -- ci report step, inline pin, display mode, RAII guards, stale docs (#967)

ci: the timer-report step is informational (continue-on-error). CoreHost::pinSnapshot applies queued option events
when the UI thread is the owner (thread stopped / inline). The display period is re-read on
SDL_EVENT_DISPLAY_CURRENT_MODE_CHANGED. busyFrame's UI-waiting flag and call()'s wait-hook flag are RAII. netplay.h and
cheats.h comments, docs/agent/testing.md (TSan recipe) and the ach_event_unit label are brought up to date. The
off-thread read_memory answer notes the rcheevos v12.5 assumption.

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

* fix(app): serialize achievement replies and synchronize scope capture (#967)

Run HTTP and mock rc_client replies on the core owner; guard scope visibility and its sample ring. Add fail-first ownership/concurrency regressions to the sanitizer suite and prevent Windows GL-loader macros from breaking the busy spinner.

* fix(app): synchronize achievement UI client access (#967)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 20:15:25 -04:00
Joe Mattiello 998dee625c docs: make AGENTS.md the shared Codex and Claude guide (#1032) 2026-10-19 17:35:29 -04:00
Joe MattielloandClaude Opus 5.5 df0f6d271b feat(app): emulation on its own thread, phase 1 (#967) (#1000)
* feat(app): FrameExchange triple buffer for the emulation thread (#967)

Frame moves to frame.h so CoreHost, FrameExchange and VideoOut share it.
Content epochs keep a previous game's frame from ever being shown.

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

* feat(app): EmuCommands, the emulation thread's ordered command queue (#967)

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

* feat(app): PacingClock mode A, the audio clock as master with an even frame grid (#967)

A wall-clock grid at the core's period, trimmed at most 0.5% by the smoothed
SDL queue level: true Jaguar rate on the sound card's crystal without the
10.7 ms device-pull jitter a raw queue trigger would add.

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

* fix(app): PacingClock refill rebases the grid so it converges (#967)

During refill frames start at up to 2x the core rate but the grid still
advanced a full period per frame, leaving it ahead of the clock by the extra
audio just queued. The loop then slept that long, drained the queue below the
low-water mark and re-armed refill: bursts and ~66 ms stalls forever, with
underruns. Rebase the grid on each early start while refilling, and make
testRefillFromEmpty check evenness, rate and underruns after the refill.

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

* feat(app): EmuThread, the single owner thread of the core (#967)

post() at frame boundaries in order, call() that runs inline on the thread
itself and after a stop, callFor() and a join timeout for a core stuck in
retro_run, advance() for headless and tests, inline mode for everything else.

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

* feat(app): InputSnapshot, the newest input for the emulation thread's poll (#967)

States are newest-wins; pointer motion is summed until a frame takes it and is
delivered exactly once.  InputManager::poll can run on the UI thread with the
keypad overlay ticking per emulated frame.

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

* fix(app): EmuThread: pending call returns on abandon, exact advance, rate-based pacing test (#967)

call() polls for abandonment instead of blocking forever on a queued task;
advance(n) counts only budgeted frames so a frame already in flight cannot
satisfy it; the pacing test checks rates over measured windows.

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

* feat(app): AudioOut safe across the emulation and UI threads; mode-A update (#967)

update(false) counts underruns without nudging the playback ratio: in mode A
the pacer follows the audio clock and the two controllers must not fight.

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

* feat(app): CoreHost owner thread, EmuSnapshot and queued writes (#967)

Inline nothing changes.  With an owner, the UI reads options, AV info and
discs from a pinned EmuSnapshot, option values through a UI-side mirror (the
config is still written on the UI thread), and its writes reach the core at
the next frame boundary.  Any other core call off the owner is refused,
counted and logged once.

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

* test(app): the real core inline vs on the emulation thread, frame for frame (#967)

Also pins a load while frames run (no stale frame, empty buffer inside the
load call, rising serials) and the owner rule.

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

* fix(app): CoreHost publishes the snapshot before a load call returns (#967)

A load or unload made through EmuThread::call() returned before the batch
ended, so the UI could pin the previous option table, AV info or disc list.
finishLoad, stopSession, a failed startSession and the disc eject / index
commands now publish themselves.  With no post set while threaded, queued
writers are counted and dropped instead of recursing into themselves.

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

* feat(app): Settings > Interface > Emulation thread and --emu-thread (#967)

Off by default in every build for now (nightlies default it on in phase 3,
once nothing is switched off while threaded).  Decided once per run;
headless runs stay inline unless --emu-thread on.  Nothing starts a thread
yet.

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

* feat(app): VJ_PERF_CSV input-latency and underrun columns, --perf-input (#967)

Latency is the oldest input event's SDL timestamp to the core's poll; the
first three columns are unchanged.

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

* feat(app): content and state actions through EmuThread::call; headless --emu-thread on (#967)

Load, boot BIOS, unload, reset, save and load state, SRAM flushes and the
shutdown unload run where the core lives; inline they run in place as before.
The save-state thumbnail is the frame the state was taken on.  UI checks ask
"halted?" instead of CoreHost::inFrame().  The headless smoke tests pass with
--emu-thread on with the inline hashes.

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

* feat(app): switch off what is not thread-safe yet while the emulation thread is on (#967)

RetroAchievements, netplay, the Tools windows and the GDB stub option are off
for a threaded run (phases 2 and 3 bring them over).  Cheat changes from the
Cheats window reach the core as one command, never half a list.

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

* test(app): windowed emulation-thread check for the Mac Studio (#967)

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

* feat(app): the interactive loop with the emulation on its own thread (#967)

The UI thread polls and publishes input, takes the newest frame, drains
messages and counters and presents; it never waits for the emulation.  The
5 s SRAM flush is posted.

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

* feat(app): VJ_PERF_CSV event-to-present column (#967)

The swap that first shows a frame which polled an input event, timed from
the event: inline from the iteration's oldest polled event, threaded through
the InputSnapshot publish number each frame carries.  --perf-input now
injects its key from an SDL timer at a swept delay, so events land evenly
over the display period in both loops.

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

* feat(app): late latch for the threaded loop (#967)

With the emulation thread and vsync on, the UI thread waits until just
before the next vblank (the slowest recent UI build plus a pad that grows on
a missed vblank), publishing input as each event arrives, then takes the
newest frame.  It tracks the vblank grid from swaps that block and stays off
while it cannot.  The fake-clock model: event -> present 25.3 ms for 5 ms
core frames (inline 25.0, unlatched 46.4); 34.2 ms for 14 ms frames, which
only mode B can fix.  --late-latch off for A/B measurement.

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

* fix(app): late latch backs off on persistent misses, hysteresis (#967)

The latch now releases (with a 300-swap cool-down) when more than a quarter
of its last 60 waits missed the vblank they aimed at, so a build whose GPU
work lands late cannot keep every frame a vblank behind.  Once locked it
lets go on jitter only at 2 ms (locks below 1 ms), and a wait that itself
overran its wake time no longer counts as a miss.  The locked/released log
line and the perf note show misses, releases and the pad.

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

* ci(app): ThreadSanitizer job over the emulation-thread tests (#967)

VJ_APP_TSAN instruments the app, the core, SDL and the tests; the job runs
ctest -L emu_thread and fails on any race report.  tsan_canary proves the
sanitizer is really on.

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

* docs(app): the Emulation thread setting (#967)

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

* fix(app): route UI disc commands through call() so staged_ is never written off the UI wait (#967)

A disc insert can re-stage the CD BIOS in the core (stage_cd_bios via
open_disc_and_resolve_boot), which writes CoreHost::staged_ from the
VJ_EVT_BIOS_STAGED/REJECTED event.  Posted from the UI, that write ran on the
emulation thread while the BIOS panel read stagedBios() every frame.  The
File menu tray/disc items and desktop.cpp toggleDiscTray/selectDisc now run
inside EmuThread::call (in place when inline), like reset.  core_host.h's
threading comment and the plan's boundary table say so.  Latent today: the
app never registers disc images with the core.

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

* docs(app): thread-test fail patterns, README frame-skip wording, two comments (#967)

- README: the drift alone skips a frame about every 18 s, but frames finishing
  near the take point also repeat/skip (~1-2.5/s measured at 60 Hz); the
  display-locked mode planned later addresses it, hence off by default.
- emu_thread_flag_rejects, emu_thread_limits, netplay_refused_threaded and
  gdb_locked_threaded only had a pass regex (ctest then ignores the exit
  code): they now also fail on "ThreadSanitizer|owner violation".
- late_latch.h: kLockAfter counts tracked observations; single outliers do not
  reset it.  emu_thread.h: a call queued before an abandon may still run later
  on the detached thread, so captures must be by value.

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

* test(app): emu-thread tests on CI runners: Windows timer, MSVC capture, libdbus TSan (#967)

- emu_thread_unit raises the Windows timer resolution to 1 ms (timeBeginPeriod,
  winmm), as SDL_Init does in the app; at the default ~15.6 ms, fast-forward
  could not beat normal speed (63 vs 97 frames/s on the Windows runners).
- Its "keeps running" check is a deadline poll instead of a fixed-window
  quarter-rate floor, which a starved macOS runner missed (6 of 36 frames).
- emu_commands_test: kEach is static constexpr, so MSVC does not need it
  captured (C3493).
- tsan.supp: suppress libdbus's own lock-order-inversion between SDL_Init and
  SDL_Quit on the main thread (system library, not instrumented); it was the
  only report in the Linux TSan job, on every threaded app run.

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

* fix(app): precise emulation-thread waits on MinGW (#967)

MinGW's timed condition wait (winpthreads) rounds short timeouts up to the
scheduler tick (~10-16 ms) even with the 1 ms timer resolution SDL sets, so
threaded fast-forward ran below normal speed on the MinGW Windows runner (67.5
vs 98.2 frames/s; MSVC was fine) -- and the shipped Windows packages are
MinGW builds.  EmuCommands::waitUntil now waits on the condition only while
more than 20 ms remain, then polls in 1 ms sleeps until the deadline or a
post()/notify().  Other platforms keep the plain condition wait.

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

* test(app): emu_thread_unit reports the host's timer precision for the fast-forward check (#967)

The MinGW runner still showed fast-forward at one frame per ~15.6 ms (64 vs 98
frames/s) after the polling wait, which points at the platform's timers rather
than EmuThread.  The test now measures sleep_for(1 ms) and the steady_clock
step and prints them with the rates.  The fast-forward ratio is still
asserted whenever it holds or the host can pace 2.5 ms frames; it is reported
instead only when it misses on a host whose sleeps exceed 6 ms or whose clock
steps exceed 1 ms (or that cannot even sustain normal speed).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 19:01:55 -04:00
Joseph MattielloandClaude Opus 5.5 7988dc8b6d test(app): pacing_trace replays a measured cost trace through the pacer; VJ_PERF_CSV per-iteration dump
pacing_trace feeds frame_cost_probe's ms column to the real Pacer on a fake clock and reports uneven
image gaps, dropped frames and swap-interval toggles (plus a decoupled-emulation model for comparison).
--perf-frames gains VJ_PERF_CSV=path: one row per loop iteration (ms, emulated frames, swap interval).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:37:46 -04:00
Joseph MattielloandClaude Opus 5.5 fdfbef078a docs(agent): corpus_ab_sweep AUDIO=1 / ARGS
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:16:20 -04:00
Joseph Mattiello ca73bea1b9 Merge remote-tracking branch 'libretro/feat/timing-s2-contention' into test/v3.8.1-timing 2026-10-13 18:04:25 -04:00
Joseph MattielloandClaude Opus 5.5 7b5fa04124 docs(timing): S2 result -- traffic census, read-derived grant-latency function, NO-GO
Doom's critical (GPU) chain gains 0.31/0.46/0.68 field per flip from the
latency function x measured traffic (every bound < 1.0); injected into the
emulator it moves the present rate +0.01..+0.05 (stock) / +0.16..+0.46
(all timing options).  The function is assembled from JTRM rev 8 and the
ARB.NET state machine (not simulated: no Verilator rig or jag_sim checkout
on any host) and is written up as gpu-timing-spec.md section 15.  Also
corrects plan premises H-A (94% of GPU external traffic is spin polls),
H-B (68K work is 0.31 field/flip) and H-C (DSP traffic is large but its
chain has 1.25 fields of slack), and records the 6% 68K slice-overshoot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:10:05 -04:00
Joseph MattielloandClaude Sonnet 5.5 f2a2cac78d docs(timing): document S0 gates; correct plan 1.3/5.3 Checkered Flag budget premise
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 23:28:02 -04:00
Joseph MattielloandClaude Opus 5.5 7659291add Merge develop (v3.8.0) into feat/833-app-shell
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:44:50 -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 Sonnet 5.5 7e6c1a4d99 docs: point docs/agent/build.md at the standalone app build
Refs #833

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 19:44:17 -04:00
Joseph MattielloandClaude Opus 5.5 8af2132793 fix(crash_detect): test the raw DSP PC for dsp_pc_escape
dsp_pc_valid() canonicalised the PC to 24 bits, by symmetry with the GPU,
where GPUReadWord and the 68K bus really do alias $FD012786 to $012786.
The DSP does not: DSPExec's own PC-escape bail-out tests the UNMASKED
dsp_pc against exactly this predicate (<= $E3FFFF, or DSP local RAM
$F1B000-$F1CFFF) and drains the slice without fetching.  A DSP PC that
fires the new check is one the core refuses to execute, so a fire is a
non-executing DSP by construction.  The mask hid Music Demo's HLE runaway
to $C1903000 (#853); an interrupt kept vectoring the DSP back into its ISR,
so the opcode counter moved and dsp_wedge never fired either.  The log line
now prints the raw PC too.

Evidence (1200 frames):
- True positive: d14bb22 + this change alone flags Music Demo with
  dsp_pc_escape at frames 5 and 606 -- pc=$C1903000 in HLE (previously
  silent) and $FFFFFFEF under --bios.  With the #853 DSP fix as well: clean
  in both modes.
- No new lines on the cart corpus: all 160 cart ROMs, HLE and --bios, every
  CRASH-DETECT line identical to the fix-only core, and audio + video
  byte-identical (the watchdog is observe-only).
- NOT swept: CD discs.  A CD title whose DSP sits at a high-byte PC would
  now log dsp_pc_escape, which matrix_diff.py reports as a new crash
  signature at release -- a detection gain, not a regression; read it that
  way.

Suite: `make TEST_EXPORTS=1 test` exit 0 (test_crash_detect_cd_wedge 12
cases, test_crash_detect_inframe 33 cases; audio clipping/presence pair ran
and passed).  c89-lint clean.

Refs #853

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 00:45:24 -04:00
Joe MattielloandClaude Sonnet 5.5 d316292c96 test(harness): --input-bitmasks to exercise the RetroArch RETRO_DEVICE_ID_JOYPAD_MASK input path (#737) (#857)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-10 23:26:09 -04:00
Joseph MattielloandClaude Opus 5.5 e8be4e6817 feat(tools): score BIOS-mode cart boot rows from the boot-ROM handoff, add BIOS_REJECT
The cart boot matrix scored real-BIOS runs from frame 0, so the identical
~490-frame boot logo animation and jingle counted as every title's "video"
and "audio", and carts the boot ROM rejects (red "Jaguar" screen) read as
"BIOS works".

cart_boot_probe now finds the boot-ROM -> cart handoff itself (68K or any of
its last 1024 instructions, via pcQueue, in $800000-$DFFFFF) and restarts
scored_* counters there; --post-handoff N runs N frames past it (--frames is
the cap) and stops early once the boot ROM sits on its BRA.S * halt loop.
Headerless RAM-loaded .jag files never run the boot ROM (bios_ran=0): whole
run scored, never a reject.

The classifier moves to test/tools/cart_classify.sh (so a unit test can feed
it synthetic logs) and gains BIOS_REJECT (boot ROM ran, no handoff, parked on
BRA.S *) and ? (bios_trap) (cart got control, then PC fell back into the
boot ROM: a crash, not a reject). Crash-watchdog signatures stay whole-run:
an early escape is a real bug (#853), not excused.

matrix_diff.py ranks BIOS_REJECT equal to GAME_CODE: GAME_CODE -> BIOS_REJECT
is a lateral scoring correction, and HLE GAME_CODE / BIOS BIOS_REJECT is not
an HLE-vs-BIOS asymmetry. BIOS runs get FRAMES+700 frames and a 150s default
timeout. docs/cart-boot-matrix.md prose updated; table not regenerated.

Closes #852

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 20:29:53 -04:00
Joseph MattielloandClaude Opus 5.5 3539fbb65b feat(tools): corpus gate -- matrix_diff.py, never-backward cart rule, pre-tag checklist (#749)
test/tools/matrix_diff.py OLD.md NEW.md compares two cart or CD boot
matrices per row. Stage rank (low->high): -1 ?(core_error|build_mismatch|
no_reg), 0 LOAD_FAIL, 1 HARNESS_HANG/?/?(pc_escape|timeout|BIOS band),
2..6 BIOS_INTRO, BOOT_STUB, GAME_CODE, MENU, IN_GAME. At equal rank a new
crash-watchdog signature (from the notes/Watchdog cell) is backward.
Prints regressed / still-asymmetric / improved / added / removed; exits
1 on any backward move, 2 on parse error. test/test_matrix_diff.sh (43
checks) runs in make test. The cart matrix header (generator + doc) now
carries the never-backward rule; docs/release-process.md gains the
pre-tag checklist (regenerate both matrices on the RC on the corpus
machine, diff vs the previous tag, a backward row blocks the tag until
ticketed and deferred, improved list feeds release notes).

Sanity: cart v3.7.0 -> v3.7.1 shows 5 regressed cells (Music Demo BIOS
GAME_CODE -> ?(pc_escape); inframe_hang newly visible on 4 homebrew).

Closes #749

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:36:59 -04:00
Joseph MattielloandClaude Opus 5.5 2f17c675b1 fix(gpu): NORMI normalises to bit 23, not 22 (#611)
NORMI returns the right shift that normalises an unsigned integer "as an
IEEE 32-bit floating point value" (JTRM v8 p.52), so the top set bit lands
on bit 23, the hidden-bit position: NORMI 1 = -23, NORMI $800000 = 0,
NORMI $80000000 = 8. TOM's ARITH.NET "normalisation integer generator"
encodes exactly that, and gives -32 for a zero source. The old code
normalised to bit 22 and returned one too many for every input (and 0 for
a zero source).

Club Drive clips polygons against the screen with a four-entry corner table
indexed by (NORMI(outcode) << 3) & $1F, so every inserted screen corner was
rotated by one. A polygon crossing two screen edges (common while steering)
came out non-monotonic in Y; the edge walker bailed to the command
interpreter while a span callback still borrowed its return stack (r10) as
scratch, and the interpreter's return popped a garbage address into main
RAM. From the user's mid-race savestate, holding accelerate and steering
left escaped at frame 82; with the fix all six driving patterns run 2400
frames clean.

Tests:
- test_gpu_ops: NORMI cases from the netlist decode (the old expectation,
  9 for $80000000, encoded the bug).
- test/tools/test_club_drive_611: ROM- and savestate-gated; drives from
  states/Club Drive (1994).state3 and requires the GPU PC to stay in local
  RAM for 300 frames with the picture moving. Fails on develop at frame 82.
- Retire Native Demo (bin) as a test_blitter_hung reproducer: its GPU runs
  into data at frame 1 on develop, and the corrected NORMI changes that
  garbage path so it no longer issues the never-ending blit. The non-approx
  path stays covered by the synthetic test on both engines; Music Demo
  --bios still covers the approx path.

The DSP has the identical NORMI block and off-by-one; that half is fixed
separately on fix/dsp-normi-off-by-one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:20:49 -04:00
Joseph MattielloandClaude Opus 5.5 ada7055b47 fix(file): load $5000-linked raw binaries at $5000 (#818)
The raw-binary loader scored candidate bases only by whether absolute
JSR/JMP/LEA/MOVE targets fall inside [base, base+size).  $5000 was not
a candidate, and a $4000 window also contains almost every target of a
$5000-linked image, so such images loaded $1000 low: Chroma-Luma (bin)
ran its font data from its first JSR, the 68K hit ILLEGAL and parked at
$1000 (#800's guard), and the screen stayed black in HLE and BIOS mode
(RAM-loaded images never take the boot-ROM path, so both modes share
the failure).

Add $5000 and break overlapping-window ties on entry-point consistency:
a JSR/JMP abs.L target that is the image start or the word after an
RTS/RTE/RTR is a subroutine start under the right base and a
coincidence under a wrong one.  Among bases that clear the containment
bar, the most entry hits wins; with no hits anywhere the containment
winner stands, so images without the signal do not move.

Across the 155-title cart corpus exactly six load decisions change, all
$4000 -> $5000 and all from black/parked to running video: Chroma-Luma
(bin), JagMania (Jul 8), JagMarble (2000), FORCE Design Legion Force
intro, Joypad-TeamTap Tester [a1], and Music Demo (ScatoLOGIC), whose
#794 blitter spray in BIOS mode was this misload.  Music Demo is
therefore no longer a hung-blitter reproducer; it moves to the new
ROM-gated test_raw_binary_boot check next to the two #818 titles.

Closes #818

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:03:53 -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
Joseph MattielloandClaude Opus 5.5 c1eb2b10a0 fix(hle): park ILLEGAL for uploaded executables so homebrew stops hanging the accurate blitter
Seven homebrew carts froze retro_run under the default (Accurate) blitter.
The blitter was the victim, not the cause:

- Raw BJL binaries and .abs images carry no vector table, and many end
  their 68K main with ILLEGAL (the Alpine/BJL "drop into the debugger"
  idiom; the GPU/OP keep running the demo).  HLE installed RTE stubs only
  for cartridges, and file.c guarded only Alpine/JagServer images, so the
  trap went through a zero vector to $0, ran the vector table as code and
  walked the stack down through $0, $FFxxxx and into TOM.
- There the 68K's own exception frames land on B_COUNT/B_CMD ($F0A238
  reaches $F02238 through TOMWriteWord's $FF3FFF mask): SR becomes B_CMD's
  low word ($2208 = S|IPL2|N for DEMO1B), the pushed PC becomes B_COUNT,
  and the write starts a blit with the zeroed A1 registers -> phrase
  mode, 1bpp, X=0.
- That blit never ends on real hardware either.  INNER.NET's inner-counter
  decrement (Inc0-Inc3) only uses the 8/16/32 bpp decodes beyond bit 0, 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.  BlitterMidsummer2 matches the netlist term for term, which is
  why it spun forever; the Fast engine just shrugs the garbage off.

Fix: JaguarReset parks vector 4 at a BRA.S * loop at $1000 for every
non-cartridge, non-CD image -- the guard file.c already used for Alpine
and JagServer, now re-armed on every reset so retro_reset()'s RAM clear
cannot wipe it.  Only vector 4 is parked; skipped if the image covers the
vector or the loop.  No blitter semantics change, no savestate change.

Also:
- crash_detect: inframe_hang now also fires (never_ends=1) for a blit
  whose inner counter cannot decrement (phrase mode below 8bpp).  DEMO1B's
  blit (14.4M pixels) was under the 2^24 pixel rule and logged nothing.
  The "inner 0 = one step" assumption now cites INNER.NET's zero/underflow
  detect.  CrashDetectNoteBlit takes the destination flags and X.
- cart_boot_matrix.sh: CART_MATRIX_PROBE_ARGS knob so the matrix can sweep
  the Accurate blitter.  Folded into the row-cache id and default log dir,
  and refused without an explicit CART_MATRIX_OUT, so it can never reuse
  or publish over the Fast rows.
- test/tools/test_upload_illegal_park: ROM-free synthetic raw binary that
  ends in ILLEGAL must park at $1000, also after retro_reset (the old core
  hangs on it); plus a ROM-gated DEMO1B run under the Accurate blitter.
  test_crash_detect_inframe gains 11 never_ends rows.

Before/after, Accurate blitter, 600 frames: before, all 7 carts timed out
in HLE (120 s cap) and 6 of 7 in real-BIOS mode (90 s cap; JagMania
(Jul 8) completed); after, all 7 complete in both boot modes.
Chroma-Luma (bin) and JagMania (Jul 8) no longer hang but stay broken:
their raw-binary load address is inferred wrong (a JSR lands in font data
/ mid-instruction), a separate loader bug.

Native Demo (bin) HLE, already broken (68K wild by frame 2, GPU runaway at
frame 1), now takes a different chaotic path that reaches the same
never-ending blit at frame 170, so it hangs under Accurate where it used
to end on a black screen.  That is the #794 class below.

Music Demo (ScatoLOGIC) --bios (#794) is a cartridge in real-BIOS mode,
so this guard does not apply; its hanging blit is the same never-ending
1bpp phrase-mode shape and is now named by never_ends=1.

Closes #800
Refs #794

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 06:28:41 -04:00
Joseph MattielloandClaude Sonnet 5.5 29998fee28 docs: JTRM errata reference and verified corrections to the distilled docs (#820)
Add docs/jtrm-errata.md: where the manual is wrong/inconsistent/hardware
differs (A), where it is silent and the answer came from netlists, Verilator,
MiSTer or games (B), Atari's own bug lists with a modeled? column (C), and the
doc mistakes we already paid for (D). Point docs/agent/hardware.md and
CLAUDE.md at it.

Correct, against JTRM Rev 8 / Rev 10 / Software Reference v2.4 / Hardware Bugs
& Warnings, the distilled docs that CLAUDE.md tells agents to trust:
- jtrm-gpu-dsp.md: 27 wrong opcode numbers (14 instructions missing), SH direction, MULT/IMULT
  signedness, PACK/UNPACK, DSP-only opcodes (SAT32S 42, MIRROR 48), DSP flags
  and ctrl layout (int 5 at bits 16/17, INT_LAT5 at bit 16), wave table
  geometry, BUS_HOG/DMAEN gotchas
- jtrm-register-map.md / jtrm-blitter.md: LFU encodings, PITCH 3, window width
  encoding, SRCZ1/SRCZ2 and the Rev 2.2 footnote, MEMCON1/MEMCON2 bit
  placement, ZMODE, GOURZ, G_HIDATA, D_FLAGS, misattributed bug list
- jtrm-clocks-timing.md: video timing and pixel-divisor tables vs v10 p.6/p.8,
  CLK1/2/3 formulas, MEMCON register table
- jtrm-jerry.md, jtrm-object-processor.md: wavetable entry count, STOPOBJ,
  unsourced VC=$FFFF claim
- comments only (no behaviour change): stale PIT clock comment in jerry.c,
  blitter status value comment, bcompen_basic.s "differs from JTRM" claim,
  test_texdump.c LFU label, and several tests that cited our own source as the
  hardware spec

Code that contradicts the manual (DSP DIV_OFFSET bit, STOP object interrupt
gating, test_texdump LFU constant) is recorded as follow-ups, not changed.

Refs #820

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 04:19:11 -04:00
Joseph MattielloandClaude Sonnet 5.5 0983d22c2d test(cd): fail test_cd_bios_boot when the CD transfer wedged (#799)
test_cd_bios_boot printed [PASS] on a run where cd_seek_wedge fired: its
criteria (pc_in_ram, not_loop, not_thrash, ram_payload) are all satisfied by
a 68K that keeps running its CD service loop while the transfer is dead.

Ask the watchdog instead of grepping its log: crash_detect.c now keeps a
sticky count of cd_seek_wedge threshold crossings (counted before the log
throttle, reset with CrashDetectReset) behind CrashDetectCDSeekWedgeFires()
and CrashDetectCDSeekWedgeStreak(), exported via the existing CrashDetect*
test ABI. The harness reads them per disc, forces virtualjaguar_crash_detect
on, prints a [CD-WEDGE] reason, and FAILs the disc. A core without the
symbols FAILs rather than passing blind.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 00:41:50 -04:00
Joseph MattielloandClaude Opus 5.5 9bcaebd4c2 feat(crash_detect): inframe_hang signature for absurd blits (#740)
The blitter runs synchronously inside one register write, so a garbage
B_COUNT can keep retro_run from returning for hours -- the per-frame
watchdog never gets a turn and the frontend sees a hung core with no log
line. Check at blit dispatch (both engines, memo and compare paths):
when B_COUNT asks for more than 2^24 pixels, log [CRASH-DETECT]
inframe_hang with the counts, the command, A1 base and all three
processor PCs, then run the blit as before. Log-only, latched like the
other signatures, one multiply per blit.

Pixels are counted the way the accurate (default) engine runs them:
outer 0 = 65536 lines (JTRM BLIT_COUNT), inner 0 = one step (<= 64 px;
BlitterMidsummer2 stops on the bit-15 crossing). Counting inner 0 as
65536 per the JTRM flagged Troy Aikman, Double Dragon V and Brutal Sports
Football, which write B_COUNT=0 at boot and run fine.

Reproducers: Music Demo (ScatoLOGIC) --bios writes B_COUNT=$2710826D
(334M pixels) at frame 158; DEMO1 (bin) in HLE freezes at frame 400 on
the accurate engine.

The issue proposed sampling PCs in the JaguarExecuteNew slice loop. That
loop advances emulated time on every iteration, so the frame always ends
unless a single call inside it never returns -- and a sampler there never
runs during such a call. Every in-frame hang sampled (Music Demo, DEMO1,
DEMO1B, Painter) was inside BlitterMidsummer2.

cart_boot_matrix.sh now lists inframe_hang among a timed-out row's
signatures (cd_boot_matrix.sh already reports the first CRASH-DETECT line).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 15:29:55 -04:00
Joseph MattielloandClaude Opus 5.5 b28b89bfdd fix(crash_detect): cd_seek_wedge only while the drive still owes data (#741)
cd_seek_wedge counted every frame with frozen FIFO drains. Under
cd_boot_mode=bios it fired 15 times across Baldies, Battle Morph,
BrainDead 13, Highlander, Primal Rage and Myst (+ both Myst demos), all of
which go on to run: the transfer had simply finished.

Measured at each fire (temporary dump, not committed): BUTCH low byte $02
every time -- the game's CD ISR clears the master interrupt enable to
signal completion. Philia (bios) ends its transfer the other way, leaving
BUTCH $03 but clearing I2CNTRL bit 2 (FIFO data enable) while it waits for
a button (it responds to B). The real IMASK-stuck transfer wedge
(7c98e16 re-broken behind an env flag as a control) has both still on:
BUTCH $03, I2CNTRL $05. fifoDataReady / cdPlaying do not separate them --
both are 1 in the false fires and in the wedge -- so the issue's proposed
"FIFO idle" test would not have worked.

A frame now counts only if a seek is outstanding (seek_starts !=
seek_dones), or the transfer is still open: BUTCH bits 0+1 armed AND
I2CNTRL bit 2 set. The predicate is CrashDetectCDSeekWedgeFrame(), pinned
by test/test_crash_detect_cd_wedge (measured rows from both sides, runs
in make test). test/tools/cd_seek_wedge_regress.sh checks the 8 titles
against the private corpus (SKIPs without it) and asserts on the log line,
not [PASS] -- test_cd_bios_boot reports [PASS] on the real wedge too.

Controls on the final code: Primal Rage and BrainDead 13 with the
delay-slot fix disabled still fire (butch_int=$03 i2s_ctrl=$05).

The log line now prints butch_int= and i2s_ctrl=.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 09:45:09 -04:00
Joseph MattielloandClaude Opus 5.5 b563b2c38b ci(release): check-info-version.sh checks all three version copies
The version lives in the Makefile, dist/info and src/core/version_fallback.h.
The script compared only the first two; v3.6.1's stale fallback header was
caught by a different CI job ('duplicated lists in sync') instead. Now it
fails naming the fallback file when that one drifts. Verified: passes on
develop (all v3.6.1), fails with exit 1 when the fallback says v3.6.0.
docs/agent/github.md's release step lists all three.

Fixes #734

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 05:59:36 -04:00
Joe Mattiello 7ce123bc02 build(arm): iOS/tvOS mtune, LTO default for ELF targets, libnx cleanup (#680)
* build(arm): iOS/tvOS mtune, LTO default for ELF targets, libnx cleanup

ISA-safe -mtune on Apple device floors (A10/A8) without -mcpu, plus an
opt-in IOS_MCPU knob for iOS 17+ deploys. GNU-ld ELF now defaults to
-flto (LTO=0 opt-out); win/qnx stay excluded. libnx drops redundant
-mtune next to -mcpu.

* fix(build): force LTO off under COVERAGE=1

gcov + -flto is known-broken on GCC: the computed-goto dispatch tables
in dsp.c/gpu.c become .data.rel.local arrays of .L label addresses, and
coverage instrumentation strands those local symbols across LTO
partitions ("undefined reference to `.L106'" at link).  Coverage wants
per-line attribution without cross-module merging anyway, so COVERAGE=1
now overrides LTO to 0 even when LTO=1 is explicit.

Two matrix rows pin the behaviour (unix COVERAGE=1, with and without
LTO=1).
2026-08-27 02:56:40 -04:00
Joseph Mattiello 5bf1a9741f test(tools): corpus_ab_sweep.sh -- bounded A/B of two cores over a ROM corpus
The tooling gap that let #641's regression through. That change clamped the
object-processor window and was merged on the strength of five ROMs; run
across 160 it turned out to blank the bottom three scanlines on about a
third of them. A corpus A/B existed and was running the whole time -- it
just never reported, because it had no per-run timeout and wedged on
Chroma-Luma Color Pick (#659), which makes retro_run never return. It sat
there 2h23m emitting nothing, and "still running" got read as progress.

So the two things this encodes are the two mistakes, not the happy path:

* Every run is bounded by timeout(1). A hang is one visible SKIP line, and
  a SKIP makes the script exit 1 -- an unchecked ROM must not be able to
  look like a clean sweep.
* It compares the full hash stream, not the transition count. Super Burnout
  reads 143 transitions on both sides of the #632 fix while rendering
  different pixels; a count comparison would have called that "no change".
  Reported as "transitions A->B, N frames differ" so the shape of a
  divergence is visible without opening the CSVs.

Progress prints every 25 ROMs, so an empty tail is distinguishable from a
stall -- the failure mode above, made visible.

Verified against the #632 work: 158 identical, 1 differing (Super Burnout,
the intended change), 1 skipped (the ROM from #659).

Refs #659

<!-- link-issue: #659 -->
2026-08-27 00:58:17 -04:00
Joe Mattiello 7b66c02018 ci(pr-issue-link): create the link from a body tag instead of demanding a manual step (#644)
* ci(pr-issue-link): create the link from a body tag instead of demanding a manual step

Every new PR here went red on creation for a step that cannot be performed
until after the PR exists: the linked-issue check failed, and the fix was a
manual click in the Development panel that does not re-trigger the job.

The premise behind that was stale. This job's own comment -- and
docs/agent/github.md -- asserted "there is no public API to create that
link, so it has to be done in the UI" and "don't burn time on a gh flag or
GraphQL mutation". The mutation exists:

  addCloseIssueReferences -- "Adds one or more pull requests as manually
  linked closing references on an issue."

That is exactly the Development-panel relationship, and it is idempotent
(verified against PR #641/#632, already linked: re-running changed nothing;
and end-to-end against #642/#633 by unlinking, re-linking from a body tag,
and confirming the result).

So the job now makes the link rather than complaining that nobody did:

  * scripts/pr-link-issue.sh <pr> [issue] [--repo o/n] does the work and is
    runnable by hand. No issue argument -> scan the PR body for a tag.
    Already linked -> no-op, so it is safe to run unconditionally.
  * The workflow runs it and only fails when there is no tag to act on. Its
    failure summary now says to edit the description, because `edited`
    re-runs the job and it links then -- no push required.

Accepted tags, case-insensitive:

  Closes #123 / Fixes #123 / Resolves #123 / Refs #123
  <!-- link-issue: #123 -->      invisible in the rendered body

The hidden form wins over the keywords, so a PR can discuss one issue in
prose and link a different one deliberately. The keywords still do nothing
on GitHub's own side here -- GitHub honours them only when the PR targets
the default branch, which is `master` while PRs target `develop`. They are
markers for this job.

Security: the job runs pull_request_target with a write token, so it checks
out the BASE ref (actions/checkout's default for that event) and never the
PR head -- the script it executes is the trusted copy from the target
branch, not one the PR could have edited. The only untrusted input is the
body, parsed to an integer and validated to be an existing issue (not a PR)
before use. Permissions widened by exactly one: issues: write, which is what
the mutation needs.

Refs #643

* fix(pr-link-issue): ignore link tags inside code blocks

Caught by the feature's own PR. #644 documents the accepted tag syntax with
a fenced example:

    Closes #123    Fixes #123    Resolves #123    Refs #123
    <!-- link-issue: #123 -->

The scan matched that example before reaching the real `<!-- link-issue:
#643 -->` at the bottom of the same body, and linked the PR to issue #123 --
a closed OP performance spike with nothing to do with it. Any PR that
explains this feature would have done the same, which makes it exactly the
kind of body the parser has to survive.

Strip fenced blocks and inline code spans before scanning. Anything inside
code is a sample, not an instruction.

Verified: re-running against #644 now yields "PR #644 -> linked to #643",
and a synthetic body with an inline `Refs #111`, a fenced `Closes #222` and
`<!-- link-issue: #333 -->`, plus a real `<!-- link-issue: #444 -->` outside
any code, selects 444.

Refs #643
2026-08-26 22:04:04 -04:00
Joseph MattielloandClaude Fable 5 9031e0599f perf(build): keep the win platform out of the ELF visibility/interposition gate
Review caught scope creep: the new -fno-semantic-interposition /
-fvisibility=hidden / LTO=1 block was gated on GC_STYLE=gnu, but the
native win (MinGW) platform block also sets GC_STYLE=gnu -- purely
because MinGW's ld honours --version-script/--gc-sections too, not
because its output is ELF. win's TARGET is a PE/COFF .dll, and
libretro.h's RETRO_API expands there to
__attribute__((__dllexport__)), not the visibility attribute, so none
of this block's rationale (ELF-style GOT/PLT interposition, RETRO_API
surviving -fvisibility=hidden via inherited default visibility)
applies. win is built in CI (c-cpp.yml's MSYS2 job, release.yml), so
this was live scope creep, not a theoretical one: `make -n
platform=win` confirmed both flags landing in its CFLAGS before this
commit.

Wrap the whole GC_STYLE=gnu block in an outer `ifneq
($(platform),win)`, the same nesting style the --gc-sections block
above it already uses for its own "GC_STYLE says gnu but this platform
isn't the thing the flag is for" case (theos_ios). win now gets none
of -fno-semantic-interposition, -fvisibility=hidden, or LTO=1's
-flto -- confirmed via `make -n platform=win` (with and without
LTO=1) both before and after. qnx's narrower exclusion (loses only
-fno-semantic-interposition, keeps -fvisibility=hidden) is unchanged.

Updates the platform-enumeration comment in the Makefile and the
"ELF visibility / interposition / LTO" subsection in
docs/agent/build.md to actually list both exclusions (qnx and win)
and why each applies, and adds a `win` negative-control row to
test/tools/simd_matrix_check.sh's new expect_vis rows (now 8, one
per platform/exemption combination) so a future regression is
caught automatically.

Verified: `make -n platform=win` (plain and with LTO=1) shows neither
flag; unix/qnx/rpi4_64/rpi1/TEST_EXPORTS=1 dumps are unchanged from
the prior commit; bash scripts/c89-lint.sh passes (no C files touched
by this commit); full `make TEST_EXPORTS=1 test` passes (0 failures,
simd_matrix_check: OK, 42/42 rows including the new win row). See the
task report for the full captured output.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 06:22:59 -04:00
Joseph MattielloandClaude Fable 5 06a8c65b70 perf(build): hide internal symbols and drop semantic interposition on ELF targets
Every ELF/GNU-ld target (unix, rpi*, generic arm64/aarch64/armv*) compiled
-fPIC and relied only on the link-time version script (link.T), so GCC
still assumed per-TU symbol interposition: every cross-file global in the
hot interpreter loops (gpu_reg, dsp_pc, the flag/counter externs the
68K/GPU/DSP headers share) reloaded through the GOT, and cross-file calls
(JaguarReadLong &c.) went through the PLT, even though this project
already pins its entire exported surface via link.T/link-test.T.

Add -fno-semantic-interposition unguarded (no cc-option probe exists in
this Makefile, and GCC has had it since 5.1/2015) to every GC_STYLE=gnu
target, except qnx: nothing in .github/workflows or .gitlab-ci.yml builds
it, and QNX SDP 6.x's qcc wraps a GCC old enough for the flag to be a
hard error rather than a no-op there.

Add -fvisibility=hidden for non-TEST_EXPORTS release builds, gated the
same way. TEST_EXPORTS=1 keeps default visibility because the white-box
harnesses dlsym() internal symbols via link-test.T/exports-test.list.
retro_* stays exported either way: libretro.h declares every entry point
RETRO_API, which expands to __attribute__((visibility("default"))) on
non-Windows GCC/Clang >= 4, and a visibility attribute already present on
an earlier declaration governs a later definition that doesn't repeat it.
Verified this empirically at the .o level with nm -m: retro_init stays
"external" under -fvisibility=hidden while an ordinary global like
videoBuffer drops to "private external".

Neither flag is an -O, so neither can trip issue #516 (last -O wins).
Also add an opt-in LTO=1 knob (appends -flto to compile+link for every
GC_STYLE=gnu target) so it can be A/B'd on real Pi hardware before ever
becoming a default; classic_armv7_a7 already runs its own -flto=4
-fwhole-program pipeline and is unaffected.

Extends test/tools/simd_matrix_check.sh with expect_vis rows asserting
the new flags land where expected and vanish where they must not, and
documents the change in docs/agent/build.md.

Verified: make -n dumps for unix/rpi4_64/rpi1/qnx/TEST_EXPORTS=1 show the
expected flags and an unmoved last -O; full make TEST_EXPORTS=1 test
passes (0 failures, 41/41 simd_matrix_check rows including the 7 new
ones); macOS dylib retro_* export count unchanged (46 before/after, a
Mach-O control since GC_STYLE=macho there is untouched by this change).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 06:22:58 -04:00
Joseph Mattiello 6624ef1b8f perf(build): per-SoC -mcpu and -O3 for every Raspberry Pi target
Maintainer decision: the rpi* platform names identify the silicon exactly,
so -mcpu is a statement of fact rather than the guess it would be on a
generic ARM target. That is why these get tuning and `unix`/`armv7-*` still
do not.

  rpi0 rpi1   BCM2835  ARM1176JZF-S  ARMv6, NO NEON -> scalar
  rpi2        BCM2836  Cortex-A7     ARMv7-A + NEON-VFPv4
  rpi3(_64)   BCM2837  Cortex-A53
  rpi4(_64)   BCM2711  Cortex-A72
  rpi5(_64)   BCM2712  Cortex-A76

-mcpu implies -mtune, so both are never passed. rpi2 stays Cortex-A7 even
though board rev 1.2 shipped a BCM2837: the libretro `rpi2` name
conventionally means the A7 part, and A7 code runs on an A53 while the
reverse does not.

-O3, NOT -Ofast
---------------
-Ofast implies -ffast-math, which #517 measured at ~0% here (integer hot
loops; the only FP is a SAVESTATED I2S resampler and event scheduling) and
then removed globally as a latent determinism hazard. Determinism is
load-bearing -- run-ahead (#400), netplay, savestate compat -- so -Ofast
would reintroduce exactly what #517 took out, for a measured-zero gain, on
a platform people run netplay on. `make OPT_LEVEL=-Ofast platform=rpi4`
still works for anyone who wants to measure it.

The level is resolved in OPT_O3_PLATFORMS, never appended in the platform
block: appending an -O there IS the #516 bug.

Three traps, all found by compiling rather than by reading
----------------------------------------------------------
1. -mfpu/-mfloat-abi are 32-bit-ARM ONLY. aarch64 gcc hard-errors:
   "unrecognized command-line option '-mfpu=neon-fp-armv8'". Leaking one
   onto a *_64 row does not merely mistune it, it fails to build. Gated on
   the same 64-bit split as ARCH.
2. rpi0/rpi1 need -marm. Debian's armhf cross toolchain is
   --with-arch=armv7-a+fp and defaults to Thumb; ARMv6 has only Thumb-1 and
   GCC cannot pair Thumb-1 with hard-float:
       sorry, unimplemented: Thumb-1 'hard-float' VFP ABI
   A native Pi 1 compiler defaults to ARM mode, so this bites cross-builds
   only -- i.e. CI, which is exactly where it would have landed.
3. Cross-built ARM cores need the CROSS objcopy in the packaging step.
   Ubuntu's binutils is single-target, so the host x86_64 objcopy cannot
   read an ARM ELF and the split-debug step would fail.

Regression guard extended (this is the deliverable, not a nicety)
------------------------------------------------------------------
simd_matrix_check.sh gains 12 rows asserting, per platform, that the LAST -O
in CFLAGS is the expected one, that exactly ONE -O appears at all, that
-mcpu is exactly what the SoC calls for, and that 32-bit-only flags stay off
64-bit rows. Also asserts classic_armv7_a7 still reaches -Ofast (the
original #516 target) and that non-Pi ARM targets do NOT acquire an -mcpu.

Verified it has teeth: appending a stray `CFLAGS += -O2` in the rpi block
reproduces #516 exactly, and the guard fails every rpi row with
"2 -O flags in CFLAGS; expected exactly 1 (issue #516)".

CI: release.yml gains 8 rows
-----------------------------
One matrix serves both nightly (push to develop) and tagged releases, so
these appear in both. Cross-compiled on x86_64 -- -mcpu names a core to
generate code FOR, it does not require running on one, so no Pi hardware is
needed to produce them.

rpi0 is deliberately absent: same BCM2835/ARM1176 part as rpi1, identical
resolved flags, so the rpi1 artifact serves both. 32- and 64-bit rows for
the same chip are both present because they are different userlands and the
32-bit ones carry flags aarch64 rejects.

The libretro GitLab buildbot half is NOT possible from this repo:
libretro-infrastructure/ci-templates has no rpi* template (65 templates;
linux-aarch64.yml is the only ARM Linux one), so per-chipset Pi cores there
need a template added upstream.

Verified
--------
- Cross-built the real core in an arm-linux-gnueabihf / aarch64-linux-gnu
  container: rpi1, rpi2, rpi4 -> "ELF 32-bit LSB shared object, ARM,
  EABI5"; rpi4_64, rpi5_64 -> "ELF 64-bit LSB shared object, ARM aarch64".
  Cross objcopy strip succeeded on all five.
  This also proves blitter_simd_neon.c compiles for 32-bit ARM NEON, not
  just aarch64 -- rpi2/rpi4 select it and built clean.
- simd_matrix_check: OK (34 rows)
- `make test` exit 0 (2 unrelated skips: fountain ROM, chdman)
- release.yml parses; 22 matrix rows, no duplicate platform names
2026-08-22 03:22:13 -04:00
Joseph MattielloandClaude Opus 5 1ae49088e8 docs: fix jtrm-*.md provenance -- stop citing source code as JTRM (#522)
21 of 23 `Source:` lines in docs/jtrm-*.md cited source files (e.g.
`src/jerry/jerry.c`) rather than the manual, so CLAUDE.md's claim that
these files "supersede source comments" was circular for most sections:
a wrong constant in the source could get echoed back as the manual's word.

Phase 1 (mechanical): relabel every source-code citation as
`Derived from: <file> -- NOT verified against the JTRM`, matching the
style of the two lines that already cited the manual correctly. Update
the docs/jtrm-*.md pointer block in CLAUDE.md and docs/agent/hardware.md
to say these files are a mix of manual- and source-derived material, and
add a provenance caveat to jtrm-register-map.md (no per-line Source: tags
at all).

Phase 2 (bounded verification against the JTRM PDFs, read as rendered
images): clocks/timing and interrupt/register sections only, per the
issue's own risk ranking.

- Upgraded to page citations (verified correct): PIT timer formula
  (jtrm-clocks-timing.md), DSP timer-interrupt routing and JERRY-to-TOM
  INT1 routing (jtrm-jerry.md), against Jaguar Software Reference Manual
  v2.4 pp. 14, 69-71, 79.
- Two disagreements found and reported inline, NOT silently fixed:
  (1) the CLK1/CLK2/CLK3 "system_clock / (2*(N+1))" divider formula is
  wrong -- those are PLL "Do NOT Modify" registers with a different
  formula (PCLKDIV/VCLKDIV = (N+1)*CHRDIV); the doc's formula is actually
  JERRY's unrelated SCLK register. Not a live emulation bug: the source
  never computes anything from these registers, matching hardware's "for
  information only" status.
  (2) the wavetable ROM entry count ("256 x 16-bit samples" per waveform)
  is wrong per both the manual and this repo's own `wavetable.c` comment
  ("Each entry has 128 values") -- docs-only error, source code already
  correct.

No content deleted; no src/ changes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 23:00:31 -04:00
Joseph MattielloandClaude Opus 4.8 1ced42b14d docs: split CLAUDE.md into on-demand agent reference docs
CLAUDE.md loaded ~10K tokens on every message and into every subagent at
startup. Slim it to load-bearing always-on rules (~2K) plus an index, and
move the catalogs and how-to detail into docs/agent/*.md that load only when
read:

- docs/agent/build.md    — build cmds, C89 exempt list, TEST_EXPORTS relink,
                           build-identity guard, stale-.o hazard
- docs/agent/hardware.md — processor model, memory map, clocks, source
                           layout, distilled JTRM index
- docs/agent/testing.md  — harness catalog, vjtrace, audio-test requirements,
                           crash-watchdog signatures, acid gating, profiling
- docs/agent/github.md   — PR-issue linking, Copilot/Kimi, release process,
                           site publishing

Safety rules (C89, branch-off-develop, DEVELOPER_DIR, audio-test pair,
PR-issue link, private-ROM / git clean danger, shell-alias trap) stay inline
so no agent can miss them without opening a doc. Removed the duplicated
build-identity-guard block; compressed language to shorthand. No content
dropped — verified the tripwire facts (MMULT fix, PR #170, closingIssues,
ROM-symlink rules) survived the move.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AiMAfypdq7ASvQWK6vQ5hW
2026-08-20 21:52:50 -04:00