25 Commits
Author SHA1 Message Date
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
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
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
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 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 4841d260f7 perf(netlink): wait for unprompted link bytes, add on-device hop diagnostic (#860)
A Doom link tic is 12 one-way single-byte hops (6 pairs, ping-pong).  Two
parts of the old behaviour made the tic rate depend on the vsync phase
between the two devices, collapsing to 5 tics/s at bad phases even on a
fast link: JLinkAwaitReply only engaged after we transmitted, so a side that
only ANSWERS spun on ASISTAT with no wait, finished its frame in ~2 ms and
sat in the frontend while the partner's byte arrived; every such hop then
cost a whole video frame.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 23:01:35 -04:00
Joseph MattielloandClaude Opus 5.5 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 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 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
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