Skip to content

release: v3.10.0 — desktop features and emulation fixes - #1042

Merged
JoeMatt merged 49 commits into
masterfrom
release/v3.10.0
Oct 21, 2026
Merged

JoeMatt merged 49 commits into
masterfrom
release/v3.10.0

Conversation

@JoeMatt

@JoeMatt JoeMatt commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Release v3.10.0

Ship TV mode and controller library navigation, Pro Controller controls, fullscreen menu auto-hide/native breakout-window chrome, multi-disc playlists and per-disc state resume, opt-in emulation threading with achievements/netplay, and optional Neuro API support.

Include the completed AtariAge follow-ups: GPU/DSP MMULT address persistence/wrap (#1048), live PIT/MEMCON1 readback (#1047), GT regression coverage (#1049), and the reproduced Fast blitter DSTA2 computed-Z destination-lane correction (#1051, backport 338e2b65).

Core options and savestate format v17 carry over. Emulation threading remains experimental and off by default; Tools/debugger and GDB require inline mode. The three version copies, release notes, WHATSNEW and boot matrices are included. This GitFlow release targets master.

Refs #1036

Validation

Combined core candidate 344dc0bc on Mac Studio. 62118c06 adds validation docs/matrices; 24ae1bdb imports the TOM PR's follow-up screenshot reference. Neither changes emulation sources.

  • Core build, C89 lint and full make TEST_EXPORTS=1 test: passed. Three skips: missing optional Fountain live-abort fixture, CHSE-capable chdman and one-session Red Book disc. Host-sensitive network checks ran and passed.
  • Explicit private-ROM audio pair: Skyhammer and Iron Soldier 2 clipping passed; Iron Soldier 1 presence passed.
  • Updated release candidate 378c15af: all 93 blitter command checks pass; acid baseline has 152 cases, 149 pass / 3 established known failures, zero regressions and zero missing. C89 lint passes. The new DSTA2 GT ROM and all associated regression tests are included unchanged from fix(blitter): use A2 destination lanes for GT computed Z #1051. Earlier source-session RetroArch testing confirmed red before / green after the fix.
  • Fresh final-core Studio matrices (b5e40090, matrix input identity 67acceb9dc39, committed in f6e2e7b7): 155 cartridge titles in HLE/BIOS and 28 CD title/mode rows. Against both v3.9.1 and pre-GT candidate 24ae1bdb: zero backward moves, zero added/removed rows, no new watchdog signatures or asymmetries. Three existing cartridge asymmetries are unchanged. All four matrix comparisons and the sweep pipeline exited 0.
  • App build and full CTest: 159 passed, zero failed/skipped, including 53 emulation-thread tests.
  • Screenshot regression suite on Studio: 10 passed, 0 failed after importing the reference update. All 86 changed pixels are the Jagniccc PIT profiling digits; geometry and frame count are unchanged. The new PNG exactly matches Linux/macOS ARM64 CI captures. Strict pixel comparison remains enabled.
  • Independent reviews of combined core changes and screenshot provenance: no findings.
  • Incoming MMULT/TOM regressions failed on original code; MMULT row/column/consecutive/wrap and savestate checks now pass. Original netlists checked during release review. Source-session RetroArch validation confirms filled polygon geometry, live PIT counter and matching MEMCON1 values. GT negative control fails when comparison is removed.
  • Playlist regressions cover invalid entries, hidden per-disc ownership, equal slots, stem collisions, warm-cache lookup and real inline/threaded resume. Windows expectations now normalize native path separators, fixing the three failed Windows library jobs.
  • Existing focused Studio windowed checks cover inline/threaded operation, settings, fullscreen hover/F1/restore/persistence and native-only chrome. POSIX installer hermetic tests: 208 passed. Clean-machine Windows qualification remains tracked separately in [app] Qualify core/app installer and update flows on clean macOS, Linux and Windows #840.

Merge is authorized after all applicable checks on the latest PR head and release build are green and unresolved reviews are addressed. Follow the live head rather than an older validation snapshot. Fresh private-ROM boot matrices on the final core candidate are complete; published-package qualification remains a pre-tag check; tagging, published-asset verification, master-to-develop back-merge and downstream info/site updates are separate publication work.

Tracker reconciliation

Confirmed AtariAge bug tickets belong to v3.10.0. The 4.0 milestone tracks threaded debugging/display pacing, stable-default qualification, completed TV pages/Auto mode and package/install/update validation. The hardware timing campaign and dual-Jaguar rollback remain separate future work. See #254 and #371.

JoeMatt and others added 28 commits October 16, 2026 19:47
* feat(app): C_TVMENU action, hold bindings and pad capabilities in remap (#991)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(app): hold arbiter and release guard for dual-use buttons (#991)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(app): route hold bindings, the TV-menu toggle and the release guard through input (#991)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(app): UP edges always reach the hold arbiter; hand-offs mask held inputs and reset the menu latch (#991)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(app): TV focus model, nav intents and style metrics (#991)

Pure units behind the TV shell: LinearFocus/GridFocus/FocusMemory and the
A-Z jump helpers, NavInput (newest-direction repeat, stick hysteresis,
trigger paging) with the button/key intent tables, and the u = h/1080
style metrics with the 120 ms ease-out tween.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(app): TV in-game menu logic, App adapter and headless script test (#991)

tv::Host + AppHost, the tv::Shell menu model (Resume, Save/Load state slot
pickers, Controls, Reset and Close game with a No-first confirm), input
ownership while the menu is open (Esc = Back = Resume, F4 and the menu
buttons still reach the app), App::menuPause/menuResume (silent, keeps a
user pause, netplay does not pause, hardcore refuses), and the
--tv-test-script runner with a virtual gamepad plus the tv_menu_headless
CTest. Drawing comes in the next part.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(app): TV menu owns input while open, releases reach the app, Start stays with the app (#991)

The menu masks all local input while open (netplay included), refuses
pause toggles while it holds the pause, lets key/button releases through,
leaves Start with the app, and the headless script drives only its own
virtual pad. Confirm pages are [Yes, No] with focus on No.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(app): TV in-game menu drawing, smoke test and controls hooks (#991)

Draws the in-game menu over the dimmed game (rail-and-list, focus ring,
hint bar), adds the tv_ui_smoke test, the C_TVMENU row with "Add hold"
and the Guide-chord hint in the controls screen, the cheat-sheet row, and
a windowed --tv-test-script path for screenshots.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(app): TV menu final-review fixes: pad caps in the controls screen, deterministic headless tap (#991)

On pads without Share, a Share-hold TV-menu binding resolves to
Guide-hold, and the controls screen and bind capture use the pad's caps.
The headless Share tap is atomic. Also: unfocused rows at body size,
wheel scrolls slot pages, withheld hold keys stay out of the core,
Back stays in the hint bar, Controls only shows the sheet, no menu toggle
during capture, and the menu releases a captured mouse.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(app): design spec for running emulation on its own thread (#967)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(app): Phase 1 implementation plan for the emulation thread (#967)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(app): emulation thread phase 1 plan: late latch, nightlies off (#967)

Two decisions from 2026-10-16 folded into the plan and the spec.

Input latency: a late latch in the threaded UI loop (new Task 15), plus an
event-to-present VJ_PERF_CSV column to measure it (new Task 14).  The UI
thread publishes input as each event arrives while it waits, then takes the
newest frame just before the vblank.  The fake-clock model in
late_latch_unit (real LateLatch) gives event -> present 25.3 ms vs 25.0
inline for 5 ms core frames, but 34.2 ms for 14 ms frames, which only mode B
can close; Task 18 measures inline / latched / unlatched A/B/C/C/B/A.

Rollout: the setting defaults off in every build in phase 1 (it switches off
RetroAchievements, netplay and the debugger); the VJ_BUILD_CHANNEL plumbing
moves to the phase 3 plan.  Old Tasks 14-16 become 16-18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķ997) (#998)

A 404 from the site mirror wrote a .miss marker that held for 7 days, so a
disc looked up before the mirror served its art (or during a deploy gap)
stayed blank for a week after the site had the picture. Mirror misses now
live in memory until the app quits, and marker files an older version
wrote for the mirror are removed and ignored. libretro-thumbnails keeps
its 30-day markers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
FindCURL sets CURL_INCLUDE_DIR to <SDK>/usr/include, and CURL::libcurl passes
it as -isystem.  CMake drops that only when the path matches the compiler's
implicit include list.  With two Xcodes installed and ccache's c++ wrapper on
PATH (compiler_check=mtime on the /usr/bin/c++ xcrun shim), CMake's compiler
probe is served from cache and the list names the other Xcode's SDK.  The real
SDK path is then emitted ahead of libc++'s headers and every C++ file linking
curl (ach_http_test, vj_library) fails in <cstring>.

On Apple, strip <SDK>/usr/include from CURL::libcurl after both
find_package(CURL) calls; it is on the default search path anyway.
Non-SDK curl (Homebrew), Linux and Windows are unchanged.

Closes #1004

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķe 2) (#999)

* docs(app): flesh out TV mode phase 2 plan against the merged phase 1 code (#991)

Screen stack beside the in-game menu, TV-owned GameRef/Art/StateRef host types,
geometry from mockups A and B, test bodies for Home/Library/Details, Auto acting
as Manual until phase 4, and the spec changes phase 2 needs approved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): TV mode state machine and input ownership (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): TV mode fullscreen hold is state, leaving fullscreen turns TV off (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): TV shell, Manual TV mode, --tv and the View menu entry (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): TV shell leaves bind capture and text fields their input; clickable section tabs (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): TV Home screen with shelves and hero panel (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): TV Library screen with rail, cover grid and letter jump (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): TV pad trigger glyphs, held Backspace stops at an empty filter (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): TV Game details and the TV-mode toggle in the in-game menu (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): TV script verbs for windowed screenshots (wait library, shot, quit) (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): TV hint bar band and device-true hints; Home hides a shelf that cannot fit (#991)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): TV mode final-review fixes: fresh launches, focus follows the game, input ownership (#991)

- A launch from a TV screen starts the game fresh: the resume prompt needs a mouse
  (X and Game details already offer the auto-save).
- ui.tv_mode is read, never written, until the setting has its UI (phase 4), so
  the Auto default can still reach users.
- Focus on Home shelves and Library lists follows the game's key, so coming back
  from a game lands on it after Recent reorders.
- A focused desktop text field keeps the keyboard (no forced WantCaptureKeyboard);
  OS key-repeat of nav keys is dropped (NavInput repeats directions itself);
  an open desktop window takes the pad away from the TV screens under it.
- Empty Library grid shows only the hints that work; Home says "Scanning your
  library..." during the first scan; Ctrl/Cmd+L and File > Game library open the
  desktop library window while TV mode is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķse (#1013) (#1016)

build_site.py now writes "thumbnails/Atari - Jaguar CD.zip" next to the
mirror. It holds only Named_*/*.png under an "Atari - Jaguar CD/" folder,
so it unzips straight into RetroArch's thumbnails directory and can be
offered to libretro-thumbnails#141 as-is. Entries are sorted with a fixed
timestamp, so the same images always give the same bytes.

The core info now lists "Atari - Jaguar CD" alongside "Atari - Jaguar",
so once libretro-super builds that rdb, RetroArch offers this core for
discs a scan matches. The mirror README records what still has to land
upstream: the thumbnail repo, the rdb build line, and a RetroArch
scanner fix (every Jaguar CD track is AUDIO, and the scanner only hashes
data tracks).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķset (#1014) (#1015)

RA's game list includes every game it has a hash for, so the Jaguar games
tab showed 85 of 126 entries with 0 achievements and 0 points, and the
header counted them as games with achievements. viewGames now leaves out
games without a published set (unless a progress row reports one), and the
header counts with countWithSets.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķop (#967) (#1018)

* test(app): VJ_PERF_TRACE frame/take timing trace + evenness model (#967)

VJ_PERF_TRACE=<prefix> with --perf-frames writes <prefix>-emu.csv (each
emulated frame: due on the pacing grid, start, end, trim, audio queue,
serial) and <prefix>-ui.csv (each UI iteration: take time, serial shown,
swap begin/return, the late latch's wake/target/budget), all on
SDL_GetTicksNS, so frame ends and take points can be placed against the
vblank.  VJ_PERF_CSV counts frames run between takes, not frames shown.

pacing_clock_test --model prints the fake-clock model: the +/-0.5% trim
moves the grid's phase ~1 ms per minute even with 5 ms device-pull
jitter, while repeats+skips grow ~32/min per ms of frame-end spread.

Refs #967

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 462c146)

* fix(app): late latch: cap late-start exemptions, one long build no longer sets the budget (#967)

Two items from the Phase 1 review:
- a wait that overran its wake time is excused from the miss rate only
  kMaxLateExempt (8) times in a row; a wait that always overruns is the
  latch's own cost and must be able to release it;
- the budget is the second-slowest of the last 60 builds: one long build
  (a window opening, a shader compile) misses its vblank whatever the
  budget says, and as the max it also gave up the wait, or moved the take
  point several ms earlier, for the next second.

Refs #967

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bdc8786)

* fix(app): mode A steers the emulation grid's phase off the UI's take point (#967)

The threaded loop repeated and skipped frames 60-160 times a minute
where the 60.054-vs-60 Hz beat owes 3-4 skips.  A frame ends at its due
time plus its wake-up lateness and cost, so while the slowly drifting
grid carries a take point through that spread, whether each frame makes
the take is decided by its own cost: spread / 15 us frames in a row come
out as random repeat + skip pairs.  The single-thread loop never sees
this: it runs the frame for each vblank inside that iteration.

PacingClock now remembers the last 120 frame ends against their due
times, and when the next frame's window (p2..p98, plus 0.75 ms) would
contain a take point, steps the grid once across it in the direction the
beat already moves it.  Each crossing is then the one skip (or repeat)
it has to be.  Only the phase moves, by at most the window once per
beat; the rate stays the trimmed audio rate (pacing_clock_test: 3 ppm
over 150 s against a 200 ppm sound card, no underrun).  The UI publishes
its take time and the display period while the latch has the vblank
grid locked (with or without the wait); --pace-steer off disables it for
A/B.

Model (pacing_clock_test --model): 4 ms cost spread, 60 Hz, take 3 ms
before the vblank: 133 repeats + 136 skips per minute unsteered, 0 + 3
steered (4 steps); 120 Hz: 577 frames held 1 or 3 refreshes -> 7.

Refs #967

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit a015047)

* docs(app): mode A phase steering in the emulation-thread design (#967)

Refs #967

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 133ee01)

* fix(app): steering holds the end window across cost switches (#967)

Measured on the Studio (yarc, VJ_PERF_TRACE): the emulation thread wakes
0.5-3 ms late and the frame cost switches with the content between ~7.5
and ~2.9 ms for seconds at a time.  Two faults showed in the first
steered run (12 repeats + 35 skips, 29 steps):
- the 'not before this frame can end' guard used the window's p98, so
  after a switch to cheap frames every earlier step was refused while the
  ends dithered on the take point; the guard is now only 'not before the
  frame starting now' (a step that lands early just starts late);
- the 120-frame window forgot the slow mode within 2 s, so the switch
  back landed the ends on the take point; the window's edges are now
  held and shrink back at 0.2 ms/s.

pacing_clock_test models both (wake lateness + a switching cost, six take
offsets): 18-38 repeats+skips per minute before, 3-5 after.

Refs #967

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 90643d1)

* fix(app): steering follows the trimmed drift, at most one step per 30 frames (#967)

Studio, Cybermorph, steering v2: 20 repeats + 38 skips and 98 steps.
Every step went earlier (the untrimmed 60.054-on-60 Hz direction) and
queued its few ms of audio; 55 ms in a minute held the trim at +0.17%,
past the beat's 0.09%, so the grid was drifting the other way and each
step put the window back in front of the take point.  The direction now
comes from the trimmed period, which makes the steps self-correcting (a
late step drains what an early one queued).  A floor of 30 frames
between steps keeps any other mistake from becoming a display lock.

pacing_clock_test: a grid slower than the panel (a 0.15% slow sound
card) went from 5224 steps and 0 repeats (a lock) to 4 repeats and 5
steps in 120 s; a 9 ms window: 6-7 events in 120 s.

Refs #967

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 395e255)

* docs(app): VJ_PERF_TRACE summary script, --pace-steer in the README (#967)

Refs #967

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 1cbe57b)

* fix(app): steer only where the grid creeps along the take grid (#967)

At ratios off a whole number (50.08 Hz PAL on 60 Hz, 60.05 on 75 or 144
Hz) successive frames land ~3 ms apart on the take grid, so some frame's
end window always holds a take point: that is 5:6-style judder, not a
slow crossing.  pacing_clock_test showed 99-119 steps a minute there and
the rate pulled off the audio clock.  Steering now needs the untrimmed
creep to be under 0.2 ms a frame (60.05 Hz on 60/120/59.94 Hz: 15-32 us):
0 steps, no underrun, rate kept at all three ratios.

Refs #967

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 9adae8e)

* test(app): perf_trace_summary.py computes event -> present exactly from the trace (#967)

~11,000 virtual events per run instead of 120 injected keys, whose run
averages moved by up to 3.4 ms between identical runs.

Refs #967

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 469cf23)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķorks (#1003) (#1006)

* fix(app): register the loaded CD with the core's disk control so the disc menu works (#1003)

The core starts its disk-control image list empty even after it booted a CD
(libretro.c: "nothing to insert (image list is empty)") and has no .m3u
loader, so the app's Open/Close Tray item, File > Disc and Cmd::DiscTray /
Cmd::DiscSelect were unreachable for every title.

- CoreHost::loadContent appends the CD it loaded (cue/cdi/chd/iso only: the
  core registers disk control for a cartridge too, and closing the tray would
  try to mount the cartridge as a disc), and only while the core's list is empty.
- CoreHost::discAppend: RetroArch's "Disk Image Append", owner-only.
- File > Add disc image... (Cmd::DiscAdd, ImGui and native menus): a file
  picker; the image joins the list and is selected, so a closed tray swaps at
  once.  Inside EmuThread::call like the other disc commands (an insert can
  re-stage the CD BIOS, #967).
- Headless --disc-swap IMAGE drives addDisc, Open tray, File > Disc and Close
  tray, reading the disc list the menus read (the pinned snapshot when
  threaded).  smoke_disc_swap (inline) and smoke_disc_swap_threaded
  (--emu-thread on, label emu_thread) swap synth_jagcd.chd ->
  synth_jagcd_multi.chd -> synth_jagcd.chd; with the registration removed both
  fail with "the loaded disc is not in the disc list (0 image(s))".

No core change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0033152)

* test(app): disc swap threaded in real-BIOS boot mode, where each insert re-stages the CD BIOS (#1003)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 98599c6)

* fix(app): disc labels keep only the file name on Windows too (#1003)

The core's disk_get_image_label splits the path on '/' only, so on Windows
File > Disc listed whole 'D:\...\Disc.chd' paths, and smoke_disc_swap failed
on the MinGW runner.  The label now loses everything up to the last '/' or
'\\' app-side; the core is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 08b340f)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķan be inserted (#1007) (#1009)

File > Boot BIOS > CD BIOS (no disc) left the disc section hidden.
discInfo().available meant count > 0, and that boot registers disk control
with an empty list. So the boot that exists to take a disc offered no way
to insert one.

CoreHost::loadNoContent now latches emptyCdTray_ when the core booted the
CD BIOS, meaning it was not told VJ_BOOT_CART_BOOT_ROM. available is then
count > 0 || emptyCdTray_. "Registered" alone would not do: the core
registers disk control for cartridges too.

With an empty list, only Add disc image... shows (ImGui and native menus).
The tray toggle is hidden because Close tray would have nothing to insert.
No app-side eject is needed. The core's set_image_index with the tray
closed already runs eject + insert itself, so addDisc's append + select
boots the disc.

Headless --disc-insert IMAGE taps the core log during the add and quotes
its "disk control: inserted" line in the PASS line.
- smoke_disc_insert_bios runs inline, and _threaded runs with
  --emu-thread on.
- smoke_disc_insert_cart_hidden is the negative control: the cart boot ROM
  keeps the menu hidden.
- With the gate reverted, both insert tests fail.

Core untouched.


(cherry picked from commit 8a8e323)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķn the opened disc (#1010) (#1011)

* fix(app): follow a disc swap in the content identity; keep the .srm on the opened disc

After File > Disc / Add disc image... booted another disc, contentPath()/contentName() and everything keyed on them
still named the first disc.

CoreHost now keeps two identities:
- contentPath()/contentName(): the disc in the tray.  It moves only after an insert that worked and left the tray
  closed (followMountedDisc).  The core moves its image index before it tries the insert and keeps it when the insert
  fails, so the index alone is not enough.  Off the owner thread these answer from the snapshot, since
  discSetEject/discSetIndex may run as queued commands.
- loadedPath()/loadedName(): what was opened.  The .srm (and the second-copy fallback) is named after it, and Restart
  reopens it.  The core keeps SAVE_RAM (the EEPROM pair and the Memory Track) across an insert and never reloads it,
  so re-keying the file would write disc A's Memory Track into B's .srm.

App::followDiscSwap (main loop, addDisc, the headless check) catches up on a swap:
- save-state slots and thumbnails follow the tray (statePath reads contentName): the core refuses a state taken on a
  different disc (savestate v14 disc identity);
- achievements use rc_client_begin_identify_and_change_media, so the session and hardcore survive a disc of the same
  game; an unidentified first disc gets a fresh identify instead;
- cheats re-key (the core re-reads the per-title row on insert, titledb_probe_disc);
- a queued state load and the resume offer for the previous disc are dropped.
The window title and Discord read contentPath every frame, so they follow without changes.  Recents, the library's
recently played and the per-game control overrides stay with the opened disc: reopening must start from its .srm.

--disc-swap now checks after each step that the content and the state slot name the disc in the tray while
loadedPath and saveRamPath stay on the opened disc, and saves a state on the second disc.  The three disc-swap tests
get their own --data-dir so that state lands in the build tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b96ce11)

* docs(app): the content identity a disc swap moves is read from the snapshot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c7e7dfc)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķtrols (#1019)

* fix(app): add the Pro Controller (6-button) device with its own X Y Z L R controls

Settings > Input now offers "Pro Controller (6-button)" on both Jaguar ports.
The core has no RETRO_DEVICE id for it, only virtualjaguar_pN_device =
pad_pro, and the app locks that option to auto, so the core's
pro_controller[] flag could never turn on from the app. The option lock now
takes the Pro ports into account (lockedCoreOptionFor), and
CoreHost::refreshLocks() hands a changed value to the core through
GET_VARIABLE_UPDATE. retro_run() runs check_variables() before
update_input(), so a switch takes effect on the next frame. The app stores
a private id for the device in the joypad class and gives the core a
standard pad for it.

New controls X / Y / Z / L / R (Pro) are appended after C_TVMENU, so saved
ids keep their meaning. On a Pro port the Router presses them as RetroPad
X / L2 / R2 / L / R, and the core's Pro path turns those into keypad
9 / 8 / 7 / 4 / 6. They are never synthesised as keypad digits.

Gamepad defaults on a Pro port, Genesis 6-button style: A / B / C = East /
South / West, X / Y / Z = Left Shoulder / North / Right Shoulder, L / R =
the triggers. Keypad 0-4 have no pad button there and are reached through
the keypad overlay (Share). 5 / 6 stay on the stick clicks, 7 / 8 / 9 / *
on the right stick and # on Guide. The standard-pad defaults are unchanged:
a new remap_unit digest, frozen from the tree before this change, covers
every control that already existed. The keyboard has no Pro bindings,
because A S D / Q E already press keypad 9 8 7 / 4 6, the same wires.

The cheat sheet, the controls overlay and the remap table show the Pro
buttons for a Pro port.

Closes #1017

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): keep the Pro Controller lock on the emulation thread's owner

After #1000 the option state CoreHost keeps for the core (locked_, the
session values) belongs to the emulation thread, and the UI reads a
published snapshot of it. Three changes follow:

- refreshLocks() queues itself through `post` when it is called off the
  owner. On the owner it bumps the option serial and publishes, so the
  UI's copy of the locked values follows.
- App::proPorts is atomic, because the option lock reads it on the
  emulation thread.
- applyPortDevices() works out each port's kind from the lock's own
  value, not the UI snapshot, which lags until the queued refresh has
  run and been pinned.

With --emu-thread on, limitFeaturesForEmuThread() replaces the option
lock. Its lock called the 2-argument lockedCoreOption(), so pad_pro
never reached the core in that mode. It now uses lockedCoreOptionFor()
with the Pro ports, as wireHost()'s lock does.

Tests:
- core_host_thread: a lock value moved from the UI thread reaches the
  core before the next frame's input (RetroPad X reads as keypad 9 under
  pad_pro, keypad 0 without), the UI snapshot shows it after a pin, and
  no owner violations occur.
- pro_controller_option_on / _off: the app itself hands the core pad_pro
  for a config naming the Pro Controller, with the emulation thread on
  and off. The _on test fails when limitFeaturesForEmuThread() keeps the
  old lock.

Refs #1017

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķ1008) (#1012)

Neither the core nor the app read .m3u.  The app now does (app/src/m3u.h, RetroArch's
format: one image per line, relative to the playlist or absolute; '#' lines, a BOM,
CRLF and a "|label" suffix are ignored).

- CoreHost::loadContent: an .m3u boots its first disc and appends every disc to the
  core's disc list (owner thread, inside the load).  Each entry must exist and be a
  cue/cdi/chd/iso, or the load fails with the entry named: a missing disc would still
  register and only fail when picked.
- Identity, on #1011's model: the playlist is what was opened (loadedPath/loadedName:
  one .srm per set, Restart, recents, the library's recently played, per-game
  controls); the disc in the tray is the content (contentPath/contentName: the window
  title, save-state slots, achievements, cheats) -- the first disc at boot, then
  whatever a swap boots.  finishLoad takes the opened path for this.
- File > Open filter takes .m3u; the headless load check, tv_script's load and the
  library's "load this state" compare against loadedPath.
- Library: isDiscExt includes m3u; the playlist claims the discs it lists (they leave
  the grid) and is identified by its first disc's track-1 CRC.
- Headless --expect-discs N: the disc list the menus read holds N images; at boot the
  content is disc 1 and the .srm / recents are the playlist's; after Open tray, File >
  Disc > last, Close tray the content and state slot are the last disc's while the
  .srm stays the playlist's.  smoke_m3u_playlist (inline) and
  smoke_m3u_playlist_threaded (--emu-thread on, emu_thread label) load
  tests/fixtures/synth_jagcd_set.m3u (../-relative entries to
  test/roms/synth_jagcd{,_multi}.chd).
- library_unit: parser cases and the scan dedupe / hash.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
â€Ķbelled as the TV-mode toggle (#1020) (#1024)

A fixed (not rebindable) Ctrl+Shift+T / Cmd+Shift+T toggles TV mode with or
without a game running (the game keeps running, the in-game menu stays shut).
It is wired into App::handleHotkey, the ImGui View menu and the native View >
TV Mode item, and is ignored while a text field has the keyboard or a binding
is being captured. The C_TVMENU action is relabelled "TV menu / TV mode" and
the cheat sheet lists the shortcut. The headless TV script's `key` verb takes
ctrl+shift+ modifiers; tv_menu_headless and platform_test cover the shortcut.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķait (#967) (#1023)

The emulation thread woke ~2 ms (p99 ~3 ms) after each frame's due time
on the Mac Studio: a default-QoS std::thread waiting on a condition
deadline gets timer slack.  It now runs at user-interactive QoS on Apple,
and its condition wait ends 1 ms before the due time, with short sleeps
covering the rest.  --emu-wake plain restores the old wait for A/B.

VJ_PERF_TRACE's UI rows gain the late latch wait's input events and its
worst overshoot of one blocking call (SDL_WaitEventTimeout or
SDL_DelayPrecise): waits overran 1-18 ms with no event to handle, and
each one missed its vblank.

Refs #967

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(app): Neuro API client, so an AI can play as Player 1

Settings > Neuro (or --neuro ws://HOST:PORT) connects the app to a Neuro game API
server (https://github.com/VedalAI/neuro-sdk): Neuro-sama, or any backend that
speaks the API (Randy, Tony, Gary), plays through registered actions.

- Controller actions: press_buttons, button_sequence, hold_buttons, release_all;
  every schema-valid input fits the 30 s queue.
- Game profiles (built-in Tempest 2000, or <data>/neuro/*.json): named button
  macros, an "about" text sent as context, and RAM watches in the state text.
- Forces the next move whenever the last input has played out (never while
  paused); results are sent on validation, as the API asks.
- WebSocket client on a worker thread (no TLS, no new dependencies), with
  reconnect; MSG_NOSIGNAL / SO_NOSIGPIPE so a dropped server cannot kill the app.
- Headless --neuro runs in real time.
- Tests: neuro_unit and neuro_loopback (a WebSocket server on a free port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: Neuro API guide (actions, profile format, testing with Randy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(app): Neuro API client in app/src/neuro/, behind VJ_APP_NEURO

The neuro_* sources move to app/src/neuro/. The CMake option VJ_APP_NEURO
(ON by default) builds them, defines VJ_HAVE_NEURO and adds the neuro_*
tests; with it OFF the app has no Settings > Neuro tab and no --neuro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): NOMINMAX before winsock2.h in neuro_ws.cpp (MSVC std::min)

windows.h's min/max macros broke std::min on MSVC; discord_ipc.cpp guards
the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķcore block, indicators, privacy (#1025) (#1027)

- Agent: a dropped or failed link clears the queue and any hold_buttons (a held
  button no longer outlives the server); stop() clears stale events.
- WsClient: the name lookup runs on an abandoned-on-stop helper thread, so stop()
  never waits on getaddrinfo; reject masked server frames and reserved bits;
  1 MiB message cap; byte caps on the event and send queues; jittered backoff;
  a "://" in the URL path is not a scheme.
- RetroAchievements hardcore blocks Neuro (disconnect + release, Settings notice,
  toast); connects again when hardcore ends.
- Toasts on connect/disconnect and an "AI plays P1" chip on the background draw
  list; the server is told (and actions fail) while the in-game menu has the pads
  or P1 is a mouse.
- Settings > Neuro: URL field follows the config and applies on click-away; warns
  on a non-loopback ws:// server; settings are re-read 4x a second, not per frame.
- JSON: a lone high surrogate no longer swallows the pair behind it.
- privacy page row, docs/neuro.md (hardcore, indicators, emulation-thread note).
- tests: wrong Accept, oversize/masked/reserved frames, flood cap, hold released on
  disconnect, hardcore no-connect/release/reconnect, input gating, surrogates, URLs.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
â€Ķily and custom button profiles (#1021) (#1030)

* docs(app): controller navigation of the desktop library, design (#1021)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(app): controller navigation of the desktop library, plan (#1021)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): NavInput per-pad sources, supplied intents and long-press Accept (#1021)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): controller-family menu-button profiles with a per-model custom layer (#1021)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): a menu-button family change clears the model's overrides; ASCII minus label (#1021)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): DesktopNav focus model for the desktop library (#1021)

Pure focus over the library's regions (Favorites strip, Recents strip, grid,
or the States list), reusing tv::LinearFocus/GridFocus/FocusMemory/jumpInitial.
Maps pad intents to Activate/OpenMenu/ToggleFavorite/TabPrev/TabNext/
FocusSearch/Back commands, follows the focused item by key when lists change,
remembers focus per tab and region, and raises a one-shot scroll flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): TV mode reads each pad's menu-button profile (#1021)

The shell asks the host what a pad's button or trigger means (the pad's controller-family profile plus the model's custom
layer) instead of the fixed Xbox table; hints name the profile's binding. Search and AcceptLong are the desktop
library's, so a button mapped to one is neither fed to the TV screens nor consumed. A removed pad drops only its own holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): route pads to the desktop library when it owns the input (#1021)

DesktopPad (tv::NavInput with long-press Accept + DesktopNav + pending commands)
and deskPadActive/HandleEvent/Frame per design section 2: TV off, no running game
(or a paused one with the library window open), home library or window showing, no
capture or other desktop window.  Pad buttons and triggers go through the pad's
NavProfile, the left stick through padAxis, the right stick Y is kept for scrolling;
a mapped press is consumed.  Start, unmapped buttons and the keyboard fall through.
Every edge of active / library window / tab / pad popup resets the nav and the queue.
Script: expect desk-pad and desk-pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): zero the desktop nav's right-stick scroll on ownership edges and focus loss (#1021)

A right stick let go while the library did not own the pad left scrollY set, so the
grid would scroll on once it did.  Zero it on every edge of active / library window /
tab / pad popup and on window focus loss.  Script: new 'axis NAME VALUE' verb and
'expect desk-scroll', plus a Settings open/close line for desk-pad (Ctrl+,).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): pad focus, ring and actions in the desktop library (#1021)

The library publishes its Favorites / Recently played strips, grid and State shots to the desktop nav each frame, draws a
focus ring (theme text colour, soft shadow) on the focused tile while the pad drives, scrolls it into view only when it is
not fully visible, scrolls with the right stick, and executes the queued commands: launch (arming the release guard),
the tile menu and BIOS popup anchored at the tile, favorite, tab switch, search focus and Back. A pad press in the
search box leaves it. The status line shows the last-used pad's button hints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): pad scroll only for the pad, stick scroll hands focus on, shared button labels (#1021)

Bring-into-view scrolling acts only while the pad drives (the mouse's view no longer snaps on a scan, search or tab click). When the right stick scrolls the focused grid tile out of view, focus moves to the nearest fully visible tile of its column (pure deskNearestVisibleIndex, DesktopNav::focusIndex without a scroll flag). The face-label logic moves into padButtonLabel (pad_profile) and is used by TV hints and the desktop hint line. The hint rides the status line (no line comes and goes) and says only what works on the States tab. A stale queued tile command is dropped, the redundant second command pass goes, and the design doc's search-box rule says any pad press leaves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): drive library popups with the pad through ImGui's gamepad nav (#1021)

Pad-opened popups (tile menu, BIOS popup, delete modal) get ImGui's own gamepad
navigation fed with our intents (directions, Accept -> GamepadFaceDown, Back ->
GamepadFaceRight), so the Switch confirm/back swap holds. The SDL3 backend is in
Manual gamepad mode; HasGamepad is set between its NewFrame and ImGui's, only
while the popup is open. Accept acts on press inside popups. tv_ui_smoke guards
that gamepad nav does not make ImGui want the keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(app): edit a controller model's menu buttons in the controls screen (#1021)

Settings > Controls gets a Menu buttons section for one connected controller
model: a layout combo (Auto shows the detected family), a Set / Default row per
menu intent and Reset menu buttons. With every gamepad selected it shows the
last-used pad's family table read-only. InputManager::beginNavCapture reuses the
bind capture, writes no game binding, refuses the pad's TV-menu buttons, D-pad
and sticks, and reports Swapped / Reserved. The headless tv script gains
capture nav:INTENT and a {padguid} placeholder in expect cfg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): menu-button Default swaps the partner back; swap note ignores hold flag (#1021)

Default on a menu intent now goes through setNavBinding with the family's
button, so a swapped partner gets its old button back and no two intents share
one. The Swapped note compares kind/code/dir like setNavBinding. The combo is
labelled Family. Script: navdefault INTENT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): pad nav review fixes: visible popup cursor, Play via the pad path, no pad under foreign popups (#1021)

- A popup the pad opened shows its focused first row at once (ConfigNavCursorVisibleAlways while its nav is on), since Accept acts on press.
- The tile menu's Play row goes through activateEntry, so a pad user gets no mouse-only resume prompt and the confirm press is guarded.
- The library does not own the pad while an ImGui popup the pad did not open is up (right-click or menu-bar menu, Lists popup, combo, modal).
- Remove the unreachable pad hook on the delete-state modal.
- Menu-button capture also refuses the pad's controls-overlay (C_HELP) button; resetNavBinding bounds-checks the intent; picking the family a pad already has no longer wipes its menu-button changes (items that do say so).
- DeskLists are built and published only while the pad drives the library, and moved into the nav; Pad::nav defaults to the Xbox map; tv_host.h comment says SDL_gamepad.h is for enums only.
- Docs: README and TV-mode design updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
â€Ķ1029)

Bumps [signpath/github-action-submit-signing-request](https://github.com/signpath/github-action-submit-signing-request) from 2 to 3.
- [Release notes](https://github.com/signpath/github-action-submit-signing-request/releases)
- [Commits](SignPath/github-action-submit-signing-request@v2...v3)

---
updated-dependencies:
- dependency-name: signpath/github-action-submit-signing-request
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
â€Ķ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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
* fix(app): order frame advances after queued commands (#967)

* test(app): make wait-hook checks tolerate scheduler delays (#967)
Copilot AI balanced review requested due to automatic review settings October 20, 2026 04:26
@github-actions github-actions Bot added gpu TOM GPU (graphics RISC) blitter TOM blitter (incl. SIMD paths) bios Embedded BIOS / boot stubs / HLE BIOS libretro-glue libretro.c API surface + core options tests Test harnesses, probes, regression baselines labels Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

App packages

Built from 304867b (workflow run).
Shared release-candidate packages (prerelease filenames). Package verification: success.

Platform Download Size
Linux x86_64: Flatpak bundle (.flatpak) app-package-flatpak 19.3 MB
Linux aarch64: .AppImage + .deb app-package-linux-aarch64 44.5 MB
Linux x86_64: .AppImage + .deb app-package-linux-x86_64 46.0 MB
macOS (universal): .zip app-package-macos-universal 30.4 MB
Windows ARM64: portable .zip + -setup.exe app-package-windows-arm64 41.8 MB
Windows x64: portable .zip + -setup.exe app-package-windows-x64 45.5 MB

Downloading an artifact needs a GitHub login. macOS: Developer ID signed and notarized (PRs from forks get
no signing secrets and stay ad-hoc signed: right-click > Open, or xattr -dr com.apple.quarantine "Virtual Jaguar.app").
Windows: not code-signed yet, SmartScreen may warn. Linux AppImage: chmod +x it.

Updated by CI at 2026-10-21T00:28:23.082Z

@github-actions

Copy link
Copy Markdown

🌙 Kimi review

Verdict: one real defect in the core changes — the new MEMCON1 open-bus readback in TOMReadable() is unreachable dead code due to an address-masking bug; everything else shown looks consistent with its stated intent.

Findings

src/tom/tom.c:~2052 — TOMReadable() masks with 0xFFFE, so the MEMCON1 fallback can never fire. TOMReadByte() receives the full address (0xF00050 etc. — that's what the PIT/MEMCON1 special cases above it compare against, and it's why the old TOMReadWord had to apply its own offset &= 0x3FFF). Inside TOMReadable(), word = offset & 0xFFFE therefore retains the 0xF00000 base for every TOM-space access. All the decoded-range tests (word <= 0x000A, word == 0x0020, 0x0050, 0x00E0â€Ķ) can never match, and the final word >= 0x2000 is true for the entire TOM register file. So every write-only register read falls through to tomRam8[offset & 0x3FFF] and returns the stored byte — exactly the old behavior — while the return tomRam8[MEMCON1 + (offset & 1)] branch is dead. The mask presumably wants to be offset & 0x3FFE (TOM's 16 KB window). This contradicts the release's own claim of "matching MEMCON1 values" from #1047: with this bug, a game polling MEMCON1 through an undecoded address gets stale register data, not the live MEMCON1 float — worth re-running whichever test purported to cover that path, because the code as written cannot produce it.

Notes on the rest (shown portion)

  • MMULT changes (gpu.c, dsp.c): the 0xF03000 | (x & 0xFFC) / 0xF1B000 | (x & 0xFFC) wrap and the writeback of the running counter are internally consistent and idempotent across the write path (GPUWriteLong case 0x08) and both DSP MMULT implementations. The stated grounding (SYSTOLIC.NET MACOUNT as a live 10-bit counter) is the right kind of evidence; I have no basis to dispute it, and per the JTRM the persistence-across-instructions behavior is the previously-missing half.
  • Blitter DSTA2 lane selection: picks destination phrase mode and destination X consistently in both the strip-init and lane-advance sites. C89-clean (declarations sit at the top of their blocks).
  • PIT readback: TOMReadPIT() derives counters from the scheduled callback expiration without advancing time, which matches the stated constraint. One thing I can't verify from the diff: that eventTime in the event list is remaining-time (decremented by SubtractEventTimes) rather than absolute — if it were absolute, the reads would return garbage. GetTimeToNextEvent's existing semantics suggest remaining, so likely fine. Note prescaler == 0 returns 0 for both counters; if that's meant to encode "timer disabled," it's a hardware claim the diff doesn't substantiate, but it matches the old "read back the written value" behavior closely enough not to be a regression.
  • TOMReadWord de-masking: removing offset &= 0x3FFF changes word reads at 0xF000E0/E1 (now consistent with the byte path: 0 and IRQ control) — appears deliberate and harmless, but it's a behavior change riding along silently.
  • event.c GetCallbackTime: C89-clean, no interaction with list ordering. Fine.
  • jaguar.c/dac.c accessor swaps are behavior-neutral (same underlying word reads).

Coverage caveat

197 of the changed files were truncated from the diff — everything under app/ beyond the headers shown, plus tests. The headers shown (EmuThread, FrameExchange, InputSnapshot, LateLatch, PacingClock, the desktop/TV nav headers) are desktop-app C++, outside the core constraints, and I saw nothing in them I'd confidently call a defect from declarations alone — but I have not reviewed their implementations, the savestate-format claims (v17 "carries over"), or the playlist/state-resume logic those headers advertise. The finding above is limited to the core emulation sources that were shown.

4 generated/vendored file(s) excluded from review.

Diff truncated before review — large PR. 197 changed file(s) not reviewed: app/src/pad_profile.h, app/src/perf.h, app/src/perf_trace.h, app/src/remap.h, app/src/video.h, app/src/window_chrome.h, app/src/debug/audio_ring.h, app/src/debug/debug_session.h, app/src/neuro/neuro_actions.h, app/src/neuro/neuro_agent.h +187 more.

@github-actions

Copy link
Copy Markdown

🌙 Kimi review

Verdict: no confident defects in the emulation changes shown; the core C is C89-clean and the MMULT/blitter/PIT logic is internally consistent. Two items deserve a confirmation before merge, plus the usual scope caveat for the truncated app-side files.

Findings / verification points, most significant first:

  1. src/tom/tom.c:~2040 — TOMReadPIT returns 0 whenever tomTimerPrescaler == 0, but prescaler 0 (period 1) is a legal running configuration on hardware — there is no "prescaler zero disables the PIT" rule in the JTRM. This guard is only harmless if the PIT callback is never scheduled when the prescaler is 0 (i.e., the write path refuses to schedule). The scheduling code is in the truncated portion of the diff, so I can't confirm. If TOMPITCallback can be live with prescaler 0, reads of PIT0/PIT1 will report 0 while the timer runs. Worth one line of confirmation, or drop the !tomTimerPrescaler term and rely on the usecs < 0.0 (not-queued) check alone.

  2. src/core/state.h (not in diff) — dsp_pointer_to_matrix / gpu_pointer_to_matrix are now live architectural state that persists across instructions (SYSTOLIC MACOUNT), not just a write-only register shadow. If they were previously omitted from the savestate region on the assumption that games always write the pointer before MMULT, that assumption is now false: MMULT → save → load → MMULT without a rewrite would resume at a stale address — exactly the state-outside-a-saved-region defect class that broke run-ahead. The release notes say "savestate checks now pass," which suggests this is covered, but the field-level confirmation isn't visible in this diff.

Notes on things I checked and am satisfied with:

  • C89: all new declarations (list/count/i in GetCallbackTime, usecs/tick/clocks/period in TOMReadPIT, dst_phrase_mode, the two dst_x locals) sit at the top of their blocks. The declaration-after-statement in HalflineCallback is pre-existing context, not introduced here.
  • TOMGetVP()/TOMGetVI() substitutions (jaguar.c, dac.c) are required by the tom.c change — after the IODEC float-through, a bus read of VP/VI returns MEMCON1 — and are value-equivalent to the old masked bus read. One thing to grep before merge: that no other internal core code (OP, video, tests) still reads write-only video registers via TOMReadWord, since those now silently return MEMCON1 bytes. The two fixed call sites suggest you already swept this.
  • dac.c change is read-path-only (same stored VP value, different accessor), and the validation lists both the clipping pair (Skyhammer, IS2) and the IS1 presence test, which satisfies the both-tests rule.
  • MMULT wrap arithmetic (0xF1B000 | ((addr + 4) & 0xFFC), GPU equivalent) is self-consistent between the register write handler (gpu.c 0x08 case) and the opcode, and the persisted full address re-masks correctly on the next MMULT. The DSP-side register write handler isn't visible in the diff — if it still stores the raw written value while the GPU side now stores the masked full address, readback semantics differ between the two cores; likely intentional/harmless, but it's the one asymmetry I couldn't close out.
  • TOMReadWord general path dropping offset &= 0x3FFF is safe: TOMReadByte masks internally and its special cases expect full addresses.
  • Blitter DSTA2 fix selects the destination phrase mode/x consistently at both the lane-init and lane-advance sites; px_phrase is initialized (0) so the advance block can't read it uninitialized when the gouraud flags are off.

Scope caveat: 197 of the changed files (the entire app/ C++ side — EmuThread, FrameExchange, LateLatch, PacingClock, playlists, TV mode, Neuro) were truncated out of this review. The findings above cover only the core emulation sources, the acid runner, and the headers shown; nothing in the app layer has been reviewed.

4 generated/vendored file(s) excluded from review.

Diff truncated before review — large PR. 197 changed file(s) not reviewed: app/src/pad_profile.h, app/src/perf.h, app/src/perf_trace.h, app/src/remap.h, app/src/video.h, app/src/window_chrome.h, app/src/debug/audio_ring.h, app/src/debug/debug_session.h, app/src/neuro/neuro_actions.h, app/src/neuro/neuro_agent.h +187 more.

@JoeMatt

JoeMatt commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Checked the findings in the earlier review and the follow-up review against release head 2165519e, with a fresh build and build-identity verification.

  • MEMCON1 masking finding is invalid. 0xFFFE clears all bits above bit 15: 0xF00026 & 0xFFFE == 0x0026, not 0xF00026. Thus TOMReadable() returns false for that write-only offset and the MEMCON1 fallback is reachable. Existing test/test_tom_readback.c::memcon_fallback exercises full 0xF00000 + offset addresses across nine undecoded/write-only offsets, high/low bytes, words, longwords, and changing MEMCON1. All five TOM readback test groups passed, zero failures/skips.
  • Zero-prescaler concern is invalid. Read directly from the primary Technical Reference Manual v8 PDF, page 16, PIT[0-1]: “If the first register contains zero the timer is disabled.” Existing TOMResetPIT() also only schedules a callback for nonzero tomTimerPrescaler; the read guard matches that existing scheduling behavior. The full-width/disabled-counter test passed.
  • Event times are remaining times. SubtractEventTimes() subtracts elapsed time from every eventTime; GetCallbackTime() reads that remaining value.
  • MMULT matrix counters are serialized. GPUStateSave/GPUStateLoad and DSPStateSave/DSPStateLoad explicitly save/load gpu_pointer_to_matrix and dsp_pointer_to_matrix. All eight MMULT regression tests passed, including consecutive multiplication, row/column modes, wrap and address-counter save/load round trips. DSP MTXA writes already use 0xF1B000 | (data & 0x000FFC).
  • Internal video reads were checked. VP/VI use the new internal accessors. The remaining fixed-address internal TOMReadWord(0xF00006, ...) reads VC, which is readable; other call sites are generic bus dispatch. No additional fixed-address internal read of a write-only video register was found.

No emulation source change is justified by these findings. Logs: vj1042-tom-review-test.log and vj1042-mmult-review-test.log retained locally. The app-file truncation remains a real coverage limitation of these bot reviews; the separately documented app tests/reviews are separate evidence, not a claim that this bot reviewed those implementations.

@JoeMatt

JoeMatt commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Completed a separate review of the app areas omitted from the truncated bot review: emulation-thread ownership/lifecycle and frame handoff, audio/snapshots, playlist/disc/state ownership, achievement callbacks and event lifetimes, Neuro transport/actions/profiles, controller holds and release guards, desktop/TV focus and library launches, pacing/late latch, and shared window/build changes.

Found and fixed: parseHex converted profile addresses/CRCs to 32 bits before checking their range. A watch at 0x100000100 silently became RAM address 0x100; fractional numeric addresses were accepted too. Commit 444ee32 rejects non-integer, non-finite, negative and overflowing values before conversion. Four new assertions failed against the previous parser; the fixed Neuro unit and loopback tests pass. No other demonstrated app defect was found in this review.

Validation: fresh app build completed; the full CTest run passed 159/160, with the sole Neuro failure traced to an old parser object compiled during the initial build before the fix. After rebuilding the parser in both the app and test targets, all three Neuro tests passed. All 160 distinct app checks are validated. The core/harness inputs are unchanged, so the previously refreshed private-ROM matrices remain applicable.

CI: the reported package-template failure was ruby: command not found on the temporary runner. Ruby is installed there, and b8164d8 makes the dependency explicit for future runners. Both Homebrew files are now checked separately (ruby -c file1 file2 only checked the first). The same commit consolidates four lightweight source-validation jobs onto one runner, retaining all ten original validation/install commands and independent failure reporting. Platform build/test coverage is unchanged. Workflow validation and locally rendered manifest checks pass.

The app-review hold is cleared. Merge still waits for all fresh applicable PR and Release checks on b8164d8 plus any new review feedback. No final tag/publication is authorized.

@JoeMatt

JoeMatt commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated duplicate app-package builds in 2e46ca4.

For a same-repository release/** PR whose merge tree exactly equals its head tree, the PR now verifies and links the Release push workflow's package set. Other PRs, forks, manual app builds, and merge trees containing different content keep independent package builds.

The shared PR gate requires the newest exact-head/branch/repository Release push run, all twelve package/runtime jobs successful, and all six nonempty, unexpired package artifact groups. Pending/failing/cancelled/skipped jobs and missing artifacts cannot pass. It rechecks live head/tree and rerun attempts. The waiting gate uses a hosted runner so it cannot occupy the Studio runner needed by Release metadata jobs.

Native app tests/TSan, all Release packaging tests, signing, artifact names/14-day retention, and release artifact/checksum collection are preserved. The sticky PR download comment points directly to the canonical artifacts (shared filenames use prerelease). This replaces twelve duplicate package jobs with a planning job and one waiting verifier: ten fewer runner allocations per eligible PR.

Validation: 17 safety tests pass, Node syntax and actionlint structural/expression checks pass. A read-only live API check confirmed #1042's matching tree and correct canonical-run selection; it correctly refused readiness while package jobs remained pending. Fresh CI is now required on 2e46ca44; merge remains gated on the complete latest PR and Release results. No new builds were dispatched manually.

@JoeMatt

JoeMatt commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Production core CI consolidation is now pushed in 3c68316.

For same-repository release PRs whose synthetic merge tree exactly matches the head tree, C/C++ CI now reuses ten compile-only Release targets: macOS Intel, WASM, four Android ABIs, iOS, tvOS, Vita, and Switch. This saves eight runner allocations per eligible PR. Other PRs, forks, manual runs, and differing merge trees retain independent builds.

The seven native compiler/test rows and their production → TEST_EXPORTS → production transitions remain unchanged, as do MSVC, sanitizers, coverage, clang-tidy, and SwiftPM. Android SIMD assertions and Mach-O production export checks also run on the canonical Release builds. A workflow contract checks matching compiler/SDK settings before sharing is allowed.

The new Verify and retain shared production cores check must pass: it verifies the exact-head Release push run, successful canonical jobs, all ten raw and ten shipped artifact groups, and downloaded binary provenance/hash/production ABI metadata. It then uploads the original ten C/C++ artifact names. Final Release archives, split-debug artifacts, and checksums are preserved; raw intermediates are excluded from final release assets. The existing shared app-package verification remains required too.

Validation: 30 Node safety tests pass, the Ruby workflow/toolchain contract passes, workflow structural/expression lint and shell checks pass, and the original compiler matrix/native test steps were compared against the prior version. No emulator source or boot-matrix inputs changed. Fresh PR and Release CI must finish before merge.

@JoeMatt JoeMatt self-assigned this Oct 21, 2026
@JoeMatt
JoeMatt merged commit e7db536 into master Oct 21, 2026
86 checks passed
@JoeMatt
JoeMatt deleted the release/v3.10.0 branch October 21, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bios Embedded BIOS / boot stubs / HLE BIOS blitter TOM blitter (incl. SIMD paths) build Makefile / Makefile.common / scripts/ ci CI, workflows, release automation core Core orchestration (jaguar.c, event, memory, settings) documentation Docs, site, wiki, release notes dsp JERRY DSP (audio coprocessor) gpu TOM GPU (graphics RISC) libretro-glue libretro.c API surface + core options release Release cut, tagging, or publishing tests Test harnesses, probes, regression baselines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Release v3.10.0 — desktop feature release

3 participants