* 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]>
Netplay: the option route (typed host and port as per-process overrides) is the
primary path for every instance, so the per-instance VJ_NETLINK_HOST/PORT
environment route from #902 is removed (the core would otherwise let the env
override the option); instances still never write a shared vj_netlink.txt and
children still do not inherit VJ_NETLINK_*.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Settings: options that take a number or address (link port/host, GDB port and
halt timeout, sensitivities, axis tuning, clock scales) show the core's presets
plus Custom... with a validated inline box (typed_options.h / typed_ui.h).
CoreHost accepts saved and --option values that pass the key's validator; list
options stay strict.
Netplay: the port box takes any port 1-65535; Join sets the typed host straight
on the host option (vj_netlink.txt kept as a fallback); the VJ_NETLINK_PORT
environment route is gone.
CoreHost answers the core's private HELLO and forwards events; Netplay consumes
LINK_UP/DOWN/STATS/WIRE_SPEED instead of scraping log text (log parsing remains
the fallback), and a crash-watchdog event shows an on-screen message.
Tests: typed_options (validators, CoreHost, real core reads a custom port, host
and clock scale), netplay_unit covers event-only, log-fallback and both join
routes, headless netplay fails if the link came up without a LINK_UP event.
Refs #833 Refs #888
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
File > New window (Ctrl/Cmd+N) starts another copy: `open -n` on a macOS bundle,
the executable elsewhere ($APPIMAGE on AppImage). Copies take a number from an OS
file lock in the data folder (instance-N.lock); the first behaves exactly as before.
Instance 2+ never rewrites the shared config.ini (own config-N.ini holds only window
size and controller choice), keeps game saves in saves/instance-N/ (first run reads
the shared save), uses imgui-N.ini, and hands the netplay address to the core through
VJ_NETLINK_HOST instead of the one shared vj_netlink.txt. Atomic writes now use a
per-process temp name. Settings > Input pins a window to one controller (or keyboard
only) and keeps reading it while another window has focus.
Refs #833
Refs #888
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
SDL_CreateProcess hands children SDL's own snapshot of the environment, so a later setenv() in the driver would not reach them.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
A beacon sent straight to the core's discovery port exercises the real SET_CORE_OPTIONS_V2 re-push and Netplay::discovered(). A peer on this machine (127.0.0.1) is no longer hidden as the preset of the same value.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The core only logs link UP/DOWN on an edge, so switching straight from one connected link to another never reported the new one.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
A friendly front end on the core's existing Network Link (TCP host/client,
JagLink / Voice Modem) -- not rollback netplay (#864).
- Netplay window + menu: host a game (port, this machine's LAN addresses),
join (LAN-discovered hosts + typed address), device choice, disconnect,
live status and link stats, a plain latency note, and the titles that use
the link.
- Settings go through the core options. Mode/host/port are session overrides
(never saved, so relaunching does not start listening); the address uses the
core's vj_netlink.txt route (the host option is an enumeration), an out-of-list
port uses VJ_NETLINK_PORT. Changing host/port while linked bounces the mode
through "disabled" because the core only re-opens on a mode change.
- Status and stats: CoreHost::onLog taps the core's [NETLINK] lines (link
UP/DOWN, "link stats"); discovered hosts are read back from the host option's
value list the core re-pushes with SET_CORE_OPTIONS_V2.
- CLI twin (--netplay host|join|join-discovered|browse|loopback, --netplay-*)
and ctest: parsers, the real core in-process, two app processes on localhost,
a no-partner control, and a discovery test that skips where broadcast fails.
Refs #833
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>