The post-core_run product block called into four translation units
every frame to find out nothing needed doing: cheat_manager_apply_
retro_cheats() loads a cold global to see there is no cheat list,
presence_update() calls discord_update() and steam_update_presence()
which each compare a status word and return, the camera poll reads
settings_t for camera_allow before the caps check, and
bsv_movie_next_frame() pulled two settings through config_get_ptr()
before checking whether a movie handle exists at all.
Give each an up/down owner and a bit on runloop_state_t::frame_work,
raised and dropped by the product itself through
runloop_frame_work_set():
RUNLOOP_WORK_CHEATS - cheat_manager_new/realloc raise it when a
list is allocated, cheat_manager_free drops it
RUNLOOP_WORK_PRESENCE - raised with Discord init and with Steam
(mist) init; dropped with whichever of the
two is the last compiled-in sink
RUNLOOP_WORK_CAMERA - driver_camera_start raises it when the core's
start succeeds, driver_camera_stop and the
driver teardown drop it. The remaining
camera_allow/caps/driver checks stay behind
the bit; a camera the core registered but
never started is no longer polled.
BSV needs no bit: the movie handles live on input_st, which the frame
already has in cache, so the iterate tests the pointers directly and
bsv_movie_next_frame() checks its handle before touching settings.
Even throttled, the iterate reached the Discord pump by calling into
discord.c and loading a cold global to learn that Discord was not in
use. A product that is compiled in but idle should cost the frame one
bit test on a word the loop already has in cache, not a cross-TU call.
Add runloop_state_t::frame_work, an unsigned in the hot front of the
struct next to pace, with one bit per optional product that hooks the
iterate. Discord is the first: retroarch_main_init() raises
RUNLOOP_WORK_DISCORD when CMD_EVENT_DISCORD_INIT succeeds (which it
does not when discord_enable is off) and retroarch_main_quit() drops
it with inited. The iterate tests the bit and only then calls
discord_poll(). The other per-frame products (presence, cheats, BSV,
camera) can move behind the same word one at a time.
runloop_iterate() called Discord_RunCallbacks() and
Discord_UpdateConnection() at the top of every visible frame whenever
Discord was inited. UpdateConnection does a non-blocking read on the
RPC pipe (and a clock read plus reconnect backoff check while it is
not open) - a syscall on the emulation thread, every frame, for
callbacks that arrive a handful of times per session.
Add discord_poll(now_us), which runs both at most every 100 ms using
the frame's existing timestamp, and call that from the iterate
instead. Presence changes are queued by discord_update() and flushed
by the next pump, so nothing is lost; join/spectate callbacks arrive
within 100 ms, which is far inside what Discord itself tolerates. The
shutdown path in retroarch.c still calls Discord_UpdateConnection()
directly so the final ClearPresence goes out unthrottled.
A muted task or failed final title allocation can suppress its last progress push, leaving a widget linked to a freed task. Send an empty completion message when frontend data is attached. The runloop updates widget lifetime state without forwarding empty text to notifications or speech.
Cover muted and missing-title completions, callbacks outside the property lock, and real task retirement with an attached widget.
Replace concurrent live-SRAM reads and the uninitialized frame sequence with worker-requested snapshots. Preserve nested netplay scopes, service paused iterations, and keep disk I/O outside the publication lock. Retain failed writes for retry instead of advancing the saved-image baseline.
Add regression coverage for snapshot ownership during slow I/O, nested scopes, paused checks, unchanged data, write failures, compressed routing and shutdown.
A driver that lost its GPU device set VIDEO_FLAG_GPU_DEVICE_LOST and
the runloop rebuilt the video driver at once, every time. That is the
right answer to a device lost once - a TDR, a driver update - which
comes back on the first rebuild. A device that is lost again straight
after being rebuilt is a failing driver or GPU, and rebuilding on it
every frame was a busy loop of full driver reinitialisations, each of
which failed the same way.
The first loss is still rebuilt immediately. A loss following it is
retried with a doubling delay, from one second up to sixteen, and
after six in a row the rebuild is abandoned with an on-screen message
until a minute has passed. A loss a minute or more clear of the
previous one is a fresh incident and starts the count again.
Falling back to a different video driver is left as a policy choice:
this only bounds the retry loop.
drivers_init enumerated and opened the MIDI devices on every start,
whether or not a core would ever use them. With the default WinMM
driver and "Microsoft GS Wavetable Synth" output that means a MIDI
stream for every Windows user, and on Windows 11 the first WinMM MIDI
call after boot starts the Windows MIDI Service and blocks until it is
up. That is the slow first launch in #19247.
Keep driver selection in drivers_init, and enumerate and open the
devices when a core calls GET_MIDI_INTERFACE. The request survives
driver reinits (SET_SYSTEM_AV_INFO, audio latency changes) and is
dropped when the core is unloaded. The menu device lists are filled
on demand without opening a device.
Frame Delay no longer relies on the MIDI stream holding the timer
period down since 5c30a403be, and the WASAPI workaround for it was
removed in 8d4bccac55.
Fixes#19247
Use Display Peak, off by default, makes the peak luminance the display
reports stand in for Peak Brightness wherever a peak is used: what
cores are told through GET_HDR_MAX_NITS, and the HDR metadata the
drivers send - DXGI's for D3D11 and D3D12, Vulkan's vkSetHdrMetadataEXT
(which reaches the compositor under Wayland), and the KMS blob. Off, or
with no reported peak, every value stays as it was: the setting for
cores, each driver's fixed 1000 nits for its metadata.
Windows reports the panel's peak through DXGI_OUTPUT_DESC1, read where
the HDR check already reads it and passed on only when it changes, and
Android through HdrCapabilities (API 24), queried once by its display
server; Wayland and KMS already did. Apple's EDR APIs give a headroom
over the current SDR white, never an absolute peak, so macOS and iOS
report none.
runloop_msg_queue_off_main() is built only with HAVE_THREADS, like its
callers. WASAPI's error-string helper is built only with
HAVE_MICROPHONE, the one path that calls it, and the driver casts
GetProcAddress straight to its function-pointer types, so it builds in
the Windows C89_BUILD lane. The ffmpeg core's slot helpers for the GL
path are built only with OpenGL, and its channel-layout declaration
sits at the top of its block. The IIR filter's RIAA CD case marks its
fall into the high shelf, and the ffmpeg_live sample's GL frame
callback has the libretro signature, which GCC 14 and later require.
The compile matrix installs mingw-w64, which turns its Direct3D lanes
on, and compiles WASAPI with the C89 flags both with and without the
microphone, treating unused functions as errors. That lane compiles
for real rather than with -fsyntax-only, which never reports unused
static functions.
input_overlay_set_scale_factor and input_overlay_auto_rotate_ take the
packed output size their callers already hold, and
task_overlay_load_desc takes the base image's size packed once by the
loader.
runloop_check_state() read video_driver_get_output_dims() separately
in the overlay block and the ASPECT_RATIO_FULL block. Read it once
into the existing output_dims snapshot (now declared unconditionally)
and use it in both.
Resolve the TODO/FIXME above the FULL aspect block by documenting why
the rest of it stays as is: each block keeps its own last_dims since
they are gated independently, and the check is polled from the run
loop because video_driver_set_output_dims() runs on the video thread
under threaded video, where the blocking driver pokes done by
video_driver_set_aspect_ratio() cannot be issued.
No functional change.
video_driver_cached_frame_info, the cached_frame_read callback and
video_driver_cached_frame_publish pass the frame's size as the packed
word the cache already stores. The reinit snapshot keeps it as one
word from read to republish, and the screenshot copy carries it the
same way.
render, osk_ptr_at_pos and osk_pointer_over_textbox receive the output
size as one packed word, and the menu drivers pass it on whole: to the
screensaver, to materialui's entries box and to xmb's layout choice.
The menu drivers' init reads the output word without unpacking it, and
the pointer DPI cache keys on the same word.
The ozone_color_theme_defer sample hands the frame info's size word to
render.
gfx_widgets_iterate, iterate_layout, the per-widget iterate callback,
gfx_widgets_draw_text and gfx_widgets_flush_text all carry the output
size as one word. The two scale caches compare one word, and the layout
check compares against last_video_dims directly.
gfx_animation_update, gfx_animation_update_widgets, the update_time_cb
the menu drivers set, and gfx_display_get_dpi_scale all take both axes
in one word. The scale caches compare one word where they compared two,
and the widget path hands its packed size straight through.
'Auto' derives the swap interval as the whole multiple the display rate
is of the content rate, up to MAXIMUM_SWAP_INTERVAL, which the manual
setting's range reads too so the two cannot drift apart. A 480 Hz panel
running 60 fps content holds each frame for eight display frames;
driver_adjust_system_rates() then divides the target by that interval
and the audio rate follows the display.
The multiple has to be one the driver can present: the D3D APIs carry
the interval as a present parameter that stops at four, so those drivers
report that bound through the new get_swap_interval_cap poke and the
ceiling comes down to it. A multiple past the cap falls back to 1 rather
than clamping short, since a short multiple paces the loop fast.
The decision itself moves to runloop_video_swap_interval_for() in
runloop.h, next to the other pure pacing decisions, and
samples/runloop/pacing covers the multiples, the driver cap, the ratios
that are not whole multiples, and the degenerate rates.
video_driver_get_output_dims() hands back the word the state already
holds and video_driver_set_output_dims() takes one, so a reader gets a
coherent pair from a single atomic load rather than two halves split
out of it. VIDEO_DRIVER_OUTPUT_WIDTH/HEIGHT were aliases for
VIDEO_SCALE_W/H and go with them.
The window check hands its size back the same way: check_window on the
context-driver vtable, win32_check_window and x11_check_window each
take one unsigned* where they took two, and a driver passes the field
itself - d3d10, d3d11 and d3d12 give &d3dN->vp.full_dims straight to
win32_check_window. The drivers' own size locals travel packed
alongside, and the runloop's two aspect checks compare one word
against the size they last laid out for.
The message is built from the task's title, progress and flags under the
lock that guards them, and the push that carries it to the frontend runs
with that lock released. msg_push() inserts into the widget queue and, on
a build with accessibility enabled, reaches a speak backend that forks;
a worker's task_set_progress() has no business waiting behind that.
The push therefore carries the formatted string and nothing the lock
guards, which makes the title and mute tests in
runloop_task_msg_queue_push() the caller's to make: re-reading the title
there would be reading a pointer a worker is free to free, so they go.
The widgets' one remaining read, ALTERNATIVE_LOOK, is set while the task
is built and never changed after, and says so where it is read.
progress_cb() stays under the lock - it reads the task's progress and
finished flag, and pokes the display server rather than going through the
message path.
samples/tasks/progress_push holds the contract. msg_push() there calls
task_get_progress(), which takes that same non-recursive lock, so a push
made under it deadlocks and a watchdog names the phase; the worker churns
the title free-then-set throughout, so a push reading task->title is a
race TSan reports against task_free_title(). Both halves were checked by
injecting each defect. Clean under ASan, ASan+UBSan and TSan; the thread
read audit stays at 0 findings.
runloop_iterate() already carries the Android work that must happen
outside the core, next to the APP_CMD_PAUSE save that defers for the
same reason. Dispatch the keypress haptic there instead of from
retroarch.c's outer loop, which is compiled out under HAVE_MAIN.
Sixteen #if/#endif pairs across twelve files enclose nothing, so they
select no code in any configuration. Removing them leaves preprocessor
output identical.
The contract went in with 357a049. This is the frontend's half of it.
Nothing changes for any core until one asks: version 2 is handed out
only to a core that negotiated it, and every existing core still gets
version 1 - under threaded video, the deferred context behind the proxy,
exactly as before.
Negotiation. GET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_SUPPORT answers
version 1 for the D3D11 type, and
d3d11_hw_interface_negotiated_version() (gfx/common/d3d11_hw_interface.h)
is where the driver and the ring both learn what the core asked for.
The lock. A critical section in the driver, recursive, which is what the
contract needs: with no wrapper in front the driver's frame runs inside
the video_refresh the core called with the lock held, and the frame
calls itself for black frame insertion. Only two of the driver's entry
points can run while the core is running - the frame, and the repeat of
the last one - so those two take it; everything else reaches the video
thread as a command the main thread waits for, and the main thread is
not inside the core while it waits. Taking it marks the context as used
by the frontend, which is what lock_context reports to the core and
clears.
The frame. With no wrapper, set_texture's texture is what the frame
reads, in place of whatever is bound at pixel shader slot 0. Under the
threaded ring the core gets the immediate context itself - no deferred
context, no proxy, no command list to replay - and at video_refresh the
driver copies the named texture into the slot's own: one call on the
context the core's thread holds the lock for, ahead of anything the core
does next. The slot's texture is replaced (a change of frame size) and
the video thread takes its reference to it under that same lock.
The header gains one paragraph: video_refresh is the frontend's use of
the context too, so a core that carries on after it within the same lock
treats the state as disturbed.
d3d11.c, video_thread_hw.c and runloop.c pass tools/mingw_syntax_check.sh
(win32 gnu99 and the C89 lane). Not run: it needs a core that asks for
version 2, which is the next change, and a Windows GPU.
samples/gfx/d3d11_hw_interface_v2 still runs the contract itself.
The contract went in with 75f55ed. This is the frontend's half of it.
Nothing changes for any core until one asks: version 2 is handed out
only to a core that negotiated it, and every existing core still gets
version 1 and the copy that goes with it.
Negotiation. GET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_SUPPORT answers
version 1 for the D3D12 type. d3d12_hw_interface_negotiated_version()
(gfx/common/d3d12_hw_interface.h) reads what the core then set and gives
min(what it asked for, 2), or 1; the driver and the ring both ask there,
so they cannot disagree.
The driver, with no wrapper in front. Its frame runs inside
video_refresh and reads the core's texture there, so there is one sync
index. set_texture_fenced queues a Wait on the core's fence ahead of the
frame's command list - a wait on the GPU's timeline, none on the CPU -
and a fence signalled behind that list is what wait_sync_index waits.
The threaded ring. A version 2 slot is the core's own texture: publish
records and submits nothing on the core's thread, which is where version
1's cost was (a command list, two barriers, a copy, a submit and a
signal, every frame). The video thread gives the driver the texture and
the core's fence through hw_ring_install, which D3D12 now implements;
the driver queues the Wait ahead of its frame and reads the texture
where it used to read the slot's copy. The slot holds a reference to the
texture and the fence until it is handed over again. A core that calls
version 1's set_texture on a version 2 interface gets version 1's path
for that frame.
One thing version 2 cannot share with the copy path. A slot's fence says
the video thread has driven the driver with the slot and the GPU is
done; it says nothing while a frame is queued and not yet claimed, or
claimed and still being recorded. With a copy per slot that window is
harmless - the copy is replaced by a newer one. With version 2 the slot
is the core's texture, and a core let back in then would draw into what
the frame about to be recorded reads. So for version 2 slots the wait
also covers the window: until no queued frame names the slot, on the
condition the video thread already broadcasts when it claims a frame and
when it finishes one. Other APIs' waits are untouched.
d3d12.c, video_thread_hw.c and runloop.c pass tools/mingw_syntax_check.sh
(win32 gnu99 and the C89 lane). Not run: it needs a core that asks for
version 2, which is the next change, and a Windows GPU to run it on.
samples/gfx/d3d12_hw_interface_v2 still runs the contract itself.
* fix achievement badge display
* fix linux build
* cheevos badges: any thread may ask, the main thread does the work
Under the threaded video wrapper the achievement widgets are iterated
and drawn on the video thread, and the badge cache answered 0 to
anything but the main thread, so their badges never arrived. Letting
the video thread in whenever video is threaded brings them back, but
the slot table is then shared with the main thread's task callbacks
with nothing between them, and the file check and the download
request run on the video thread.
rcheevos_get_badge_texture() now only looks the badge up: a ready
handle is handed over, anything else answers 0, and a badge not in the
table is written into it as a request. That is all an asking thread
does, under a lock word held for a 17-slot scan, so any thread may ask
and the video_driver_is_threaded() test is gone. The main thread does
the rest in rcheevos_badge_cache_service() - the file check, the
download request, the image task - at once when it is the asker, from
the runloop otherwise, where it costs one atomic load with nothing
asked for. Texture unloads happen outside the lock.
Also:
- a reset keeps the slots' sequence numbers, so a load in flight
across it is unloaded instead of landing in whatever reuses its slot
- a failed load stays failed until the slot is reused or the file is
downloaded again; a missing file asked for without a download can
still be revived by an asker that wants one, once
- a full table forgets a failed slot before one that is loading
- the default badge's shared slot is only reached through
rcheevos_get_default_badge_texture(), whose handle is lent; asking
for "00000" by name gives an owned handle like any other, as the
widgets, which unload what they are given, expect
- the badge download helpers move to cheevos_client.c, so
samples/cheevos/badge_cache links again; that file includes the
compat/strl.h it was already using
badge_cache_test covers the new contract, and builds with HAVE_THREADS
so the lock is compiled in.
* cheevos menu: borrow the default badge, load an entry's badge when drawn
An entry without its own badge yet shows the server default badge.
That handle is the cache's: the entry marks itself pending, never
unloads it, and keeps asking for its own badge each time it is drawn.
Before, the pending mark was set only when the default badge was not
there either, so an entry that did get it stopped asking, and later
unloaded a handle other entries were still showing.
Achievement entries are marked pending when the list is built rather
than asked for there: a set has far more achievements than fit on
screen or in the cache, and the menu drivers only ask for the entries
they draw.
---------
Core options were only written automatically at the very end of core
teardown, in runloop_deinit_core_options(): after the auto save-state,
retro_unload_game(), retro_deinit() and dylib_close(). A crash anywhere
in that sequence, or the process being killed before it, silently lost
every option change made during the session. Per-game "Save Game
Options" was unaffected because it writes immediately.
- Split the write logic out of core_options_flush() into a quiet helper,
runloop_core_options_save(), which writes to whichever options file
is active without freeing anything or showing a notification.
- Call it at the start of CMD_EVENT_UNLOAD_CORE (Close Content) and
CMD_EVENT_CORE_DEINIT (Quit), before anything calls back into the
core. The late save in runloop_deinit_core_options() is kept; it is
a no-op unless the core changed an option during teardown, since
config_file_write() only writes a modified config and clears the
flag afterwards.
- Call it from android_input_flush_pending_state(), next to the SRAM
and config flush, so option changes survive Android killing the
process in the background (e.g. swiped away from Recents). Not gated
on config_save_on_exit, matching the unload-time save.
Verified with a test core that crashes in retro_unload_game(): an
option change made at runtime is lost before this change and saved
after it. Normal (non-crashing) shutdown behaviour is unchanged.
Fixes#19215
VIDEO_FLAG_GPU_DEVICE_LOST existed, the D3D9 drivers set it, and
nothing read it, so a device lost to a TDR or a GPU reset left the
frontend running against a dead device: every submission failing,
the window showing whatever the last good present left, and the core
- on the same device - emulating on with sound and input. The runloop
now acts on the flag once per iteration, on the thread that owns the
drivers: it reinitialises the video, input and menu drivers, which
brings the context back on the recovered device and gives a hardware
core its context_reset.
The Vulkan driver reports the loss where it sees it: the frame
submissions, the present and the acquire, once, and from whichever
thread runs them - the flag is atomic, and under the wrapper that is
the video thread, which can only ask.
The Apple idle_wait shipped on a false premise. rarch_draw_observer
calls CFRunLoopWakeUp after every iteration unconditionally on macOS
and tvOS, so the run loop between callbacks is not a wait: the
caller's ten-millisecond sleep was the only thing pacing a paused
RetroArch on a non-Qt macOS build, and returning true without
waiting turned pause into a spin of the main thread. A nested
CFRunLoopRunInMode from inside the observer is no answer either -
it re-enters the observer and recurses. The OS-driven builds now
return false and keep the sleep; the HAVE_QT owned loop keeps its
pump, which runs from plain C and nests in nothing. The driver's
comment names where the real Apple wait belongs: an observer in
cocoa_common.m that does not wake the loop while paused, which
wants a Mac to verify.
UICO_ST_FLAG_IS_ON_FOREGROUND is removed. Its setters went in 2022
as companion-UI remnants and no reader was updated, so the flag has
been false ever since; each remaining test is folded to what it
has evaluated to for four years - focus follows pause_nonactive
and is_focused alone, the companion's render_messagebox and the
resume-time companion toggle were never reached, and the touch
build's frame limiter never skipped pacing - so behavior is
unchanged and the code says what it does.
Verified: the full linux build with the declaration gate on both
touched C files and the ELF audit steady, the mingw build and link,
and the 91-lane matrix. The Apple driver is inspection-class here.
runloop_idle_wait had grown a platform ladder - Win32 message queue,
X11 socket, Android looper - inside runloop.c, and Apple had no
entry because runloop.c cannot know which of Apple's two loop
models it is running under. The wait source is a property of the
windowing system, so it becomes a method of the display server,
which already has one driver per windowing system: idle_wait(data,
ms) blocks until the event transport is readable or ms pass,
dispatches nothing, and returns false when the server has no
waitable source, in which case runloop sleeps as before. runloop.c
keeps one call and no ifdefs.
Win32 waits on the calling thread's message queue. X11 polls the
connection fd, moved here from x11_common. Android polls the
ident-based looper. SDL2 and SDL3 use SDL_WaitEventTimeout with a
NULL event, which removes nothing. Apple: under HAVE_QT RetroArch
owns the main loop and the wait runs the default CFRunLoop mode
until a source is handled or the deadline; on every other build the
OS drives runloop_iterate from an observer or a display link and
the run loop between callbacks is the wait, so the method returns
at once - except a backgrounded iOS or tvOS app, which keeps the
sleep so a suspended loop cannot become a spin. The COCOATOUCH
branch at the call site goes with it. Wayland stays NULL, and the
driver says why: this server holds its own wl_display, input rides
the video context's, and the real wait needs that display plus the
prepare-read pairing verified against a live compositor. KMS has no
window; UWP awaits a CoreDispatcher wait in a later round.
Two latent header dependencies surfaced by the new lane are fixed:
platform_unix.h sizes struct android_app by DEFAULT_MAX_PADS without
including input_driver.h, and dispserv_android.c uses PROP_VALUE_MAX
without sys/system_properties.h - both hidden until now by griffin's
include order. dispserv_android.c now compiles in the matrix under
-DANDROID against a JNI stub carrying the interface slots RetroArch
calls; the lane was shown failing on a misspelled looper call before
this commit.
Verified: the 91-lane matrix; the linux build compiling the x11,
wayland, kms and sdl2 servers with the declaration gate on every
touched C file and the ELF audit steady; the mingw build compiling
the win32 server and linking with the PE audit steady. The Apple
driver has no toolchain here and is inspection-class, and says so.
runloop_idle_wait had every desktop branch and no Android one, on
the platform that reaches the fallback most: unfocused but
foreground. It now polls the thread's looper for ten milliseconds.
The looper is ident-based - prepared with
ALOOPER_PREPARE_ALLOW_NON_CALLBACKS - so the poll reports readiness
and consumes nothing; the wake returns to the loop and the next
android_input_poll dispatches the ident as it always did. Input,
app commands and the sensor queue all arrive through that one
object. The IDLE case is unchanged: the input poll itself blocks
there and the call site exempts it.
The wait stays in runloop rather than folding into
android_input_poll, whose own timeout is input_block_timeout - a
running-latency setting unrelated to pause. The comment at the
branch records the reasoning.
runloop.c now compiles in the matrix under -DANDROID against
hermetic NDK stubs (jni, looper, native_activity, sensor,
configuration, window) with the host identity shed; the lane was
shown failing on a misspelled looper call before this commit.
Verified: the 90-lane matrix, the linux build with the declaration
gate and the ELF audit steady, and the mingw build and link.
The last runloop sleep the audit still calls a poll interval. What
POLLED_AND_SLEEP waits for is user input - the pause key, focus
coming back - and a fixed ten-millisecond sleep answered it five
milliseconds late on average while waking the process a hundred
times a second for nothing. Where the platform has a waitable
source of input, the wait moves there, with the same ten
milliseconds as the bound rather than the schedule; the state
machine above is untouched, so the worst case is exactly the old
behavior and the common case is a wake at the event.
Windows blocks on the calling thread's own message queue -
MsgWaitForMultipleObjectsEx, QS_ALLINPUT, removing nothing, so the
normal poll consumes as before. X11 blocks on readiness of the
connection socket: a plain poll() on the fd, implemented in
x11_common.c where Xlib's headers belong (runloop.c repeats the
one declaration rather than inheriting Status, None and Bool), no
Xlib call in the path, so it is safe against a threaded video
context using the same Display - and events a dispatcher already
drained were someone's to act on. Wayland's fd is only safely
watchable through the prepare-read protocol, so it keeps the
sleep rather than gaining a deadlock; so does every platform
without a source. The Android looper exemption, the Cocoa
foreground gate, and the emscripten deferred path stay exactly as
they were.
Verified: full linux build with the standing gates and the ELF
audit at its numbers, the declaration gate on both touched files
with their real compile lines, the mingw build and link with the
PE audit steady, and the full 80-lane matrix.
The audit's longest-standing non-audio entry: the limiter slept a
relative interval short of its deadline and spun the rest, so two
things it never asked for landed in its adaptive margin - the time
between reading the clock and entering the kernel, and whatever an
early or interrupted wake left over. The margin ate both, which is
why it worked, and why it was bigger than it needed to be: every
microsecond of margin is a microsecond of spin, every frame.
libretro-common grows retro_sleep_until_us(deadline), an absolute
wait on cpu_features_get_time_usec()'s own clock. On Linux and
Android that is clock_nanosleep with TIMER_ABSTIME on
CLOCK_MONOTONIC - the exact tool, and EINTR re-arms against the
unchanged deadline by definition. On Darwin it is mach_wait_until
through one paired read, absolute in Mach terms so nothing
accumulates across a wake. Everywhere else it re-arms the
platform's best relative wait against the deadline until the clock
agrees - on desktop Windows that wait is rtime.c's own per-thread
high-resolution timer, so each lap is microsecond-grained, and on
a platform whose sleep rounds up the loop ends one lap past the
deadline, no worse than the relative call was. Never early against
that clock, on any backend; a new libretro-common sample proves
exactly that - twenty laps at four durations, an early return
fails at any load - and measures 149 us worst overshoot on this
container.
The limiter now sleeps until deadline minus margin, and its margin
update measures the distance past that instant - purely the
kernel's own overshoot now, which is what the margin was for. The
spin tail stays, doing only its real job, and shrinks as the
margin converges to the smaller number. The emscripten branch, the
Cocoa background exemption, and the schedule arithmetic are
untouched.
Verified: full linux build with the standing gates and the ELF
audit at its numbers, the declaration gate on runloop.c and
rtime.c with their real compile lines, the mingw build and link
with the PE audit steady, the new sample green, and the full
80-lane matrix.
The rewind/runahead ownership sweep, the auditor's last unswept
territory. Its verdict: both are main-confined by construction -
the rewind ring is touched only from check_rewind on the main
thread (no task ever reaches state_manager state), and runahead's
only threading primitives are acquire-side consumers of the
already-atomic video flags - with exactly one real crossing.
state_manager_frame_is_reversed() read the FRAME_IS_REVERSED bit
straight off the rewind flags word. Its writer is check_rewind on
the main thread, per frame while rewinding; its readers include
every FrameDirection uniform - gl3, d3d12, gx2, metal, glsl, cg,
all on the video thread per frame - and task_save on the worker.
A plain uint8_t bit with no ordering in either direction, invisible
to the thread-read audit because the accessor reads the runloop
global directly rather than through a tracked getter.
The flags word stays main-only rewind logic; a retro_atomic mirror
alongside it is release-stored at all five transitions - the two
set sites (rewind pop and the bsv-movie hold), the per-frame clear,
and both deinit clears - and the accessor acquire-loads the mirror.
No caller changes anywhere: every reader already goes through the
accessor.
Verified: build-flags C89 gate on state_manager.c and runloop.c,
full build clean, the standing thread-read gate at its numbers,
playlist_nav green, hot-function budgets, and the 74-lane compile
matrix including the no-threads lanes the new retro_atomic include
must survive.
Two results from triaging the frame-context audit's rotation class.
First, a live bug the triage walked into: the SET_ROTATION
environment handler passed the core's raw rotation to
video_driver_set_rotation, while the menu path and
retroarch_get_rotation() itself compose it with the user's
configured rotation. A core rotating with video_rotation set
silently dropped the config offset until the user next touched the
rotation setting, and the driver's stored rotation disagreed with
retroarch_get_rotation until then. The env path now passes the same
(core + config) % 4 sum the menu path always has - which is also
what makes the drivers' stored rotation trustworthy as a latch.
Second, the frame-side hygiene that latch enables: gl3 and vulkan
keep the raw 0-3 value alongside their transformed degrees, written
only inside blocking wrapper commands (set_rotation, and a seed in
init), and their frame paths read the latch instead of calling
retroarch_get_rotation() - a config_get_ptr read - from the video
thread. The filter chains' pass-sizing code reads the rotation the
chain already stores per pass (pushed to it each frame) instead of
reaching for the global.
The audit's three rotation findings do not clear yet, and the
commit does not claim they do: the surviving edges run through the
shared helper tier in video_driver.c - get_core_aspect,
set_aspect_ratio, update_viewport, init_internal and
video_driver_frame itself all call retroarch_get_rotation, and the
frame closures reach them. That tier is where the update_viewport
settings-read finding lives too; converting it (latch or frame-info
snapshot) is the next triage target, with the five sites now
enumerated. glsl and cg still read the global for their rotation
uniforms - moving them onto the latch needs an optional
set_rotation member on the shader backend vtable, deferred with
this note rather than half-done.
Verified: full build clean, flagless audit at its standing 51/0,
C89 gate on runloop.c, hot-function budgets, and the 74-lane
compile matrix.
What context_lock still covered: video_driver_is_hw_context()
reading hw_render.context_type against video_driver_free_hw_context()
running context_destroy() and zeroing the struct - one word of
protection, while every other hw_render field read in the tree goes
unlocked as main-thread state. The race window is real
(core_unload_game frees the hw context while threaded video still
runs), so the word's coherence and its ordering keep mattering; the
lock does not.
hw_context_type is that word as an atomic mirror. Both writers run
on the main thread and store-release it after their edit lands -
SET_HW_RENDER after copying the callback in,
video_driver_free_hw_context() after the destroy and the memset -
so an acquire reader observing RETRO_HW_CONTEXT_NONE is guaranteed
the teardown completed, exactly the ordering the lock's reader got.
video_driver_is_hw_context() is one acquire load on every backend
and build, threaded or not. The lock's field, lifecycle and both
teardown sites go, along with the stale lock-ordering notes in gl1,
gdi, d3d9hlsl and d3d9cg - the last still naming both dead locks.
Also swept up: runloop_task_msg_queue_push() held a runloop_st
local whose last use left with RUNLOOP_MSG_QUEUE_LOCK; widgets
builds now compile it warning-free.
samples/gfx/hw_context_mirror holds the ordering claim across 3000
set/free cycles with a mid-memset stall: a reader that observes
NONE finds every struct byte zero, clean under ThreadSanitizer
through the release/acquire pair; releasing the mirror before the
memset instead is caught at 53 dirty observations. Clean
C89_BUILD=1 rebuild, all five backends over video_driver.c and
runloop.c, the 74-lane matrix, all four budgets, the runloop and
gfx suites, and the fifty-entry audit zero all hold.
CODING-GUIDELINES line 275: explain how the code works moving
forward, not how it worked historically - and the rule applies to
code comments. A sweep of this engagement's comments for
diff-narration - 'used to', 'old code', 'is gone', 'moved out of',
'has been removed' - rewritten as what each site must do and what
breaks otherwise, with the measurements and failure modes kept:
the invariant is the point, the archaeology is not. Touches
video_driver, retroarch, runloop, the save/database/updater/backup/
image tasks, the companion latch, the widgets producer, gl1's
swizzle staging and the audit allowlist. Lifecycle wording that
happens to read as history ('the driver itself is gone', 'no
longer valid') describes present teardown states and stands.
Comments only; the build, the fifty-entry audit zero and every
touched file's syntax check all hold.
What display_lock still guarded after the title left it: the
VIDEO_FLAG_* word, and the overlay viewport tuple. Both are atomic
now, and the lock object survives only on the one backend without
pointer atomics, purely as the window-title fallback's lock, gated
with the field itself.
The flags word becomes a retro_atomic int. Every site outside the
funnel was already a snapshot read - those become relaxed loads,
across the context drivers, the frontend and the companion - and
video_driver_modify_disp_flags keeps its combined clear-then-set
indivisible with a CAS loop, falling back to two RMWs only on the
single-core volatile backend where the seam cannot be observed.
This also closes a set of latent plain-access races the lock never
covered: the wgl, x11 and sdl3 context drivers read the word on
the video thread without ever taking it. get_disp_flags is one
acquire load; the threaded branch that took the lock for the same
read is gone, as is the lock-ordering note in gl1, gdi and d3d9hlsl.
The overlay viewport crosses as float bits in atomic ints under a
generation counter: bumped odd before a rewrite, even after, with
readers retrying while it is odd or changed across the copy - and
the read loop shaped so no comparison ever sees an unset counter,
the same -Wmaybe-uninitialized shape task_save just taught.
samples/gfx/overlay_vp_seqlock holds the protocol across 4000
rounds with a deliberately stalled writer mid-tuple: no torn
snapshot (round-encoded fields must agree), progress, clean under
ThreadSanitizer since every shared field is an atomic int; with the
counter sabotaged away the stall hands readers 174 torn fields and
the lane fails as designed.
All five backends compile video_driver.c - VOLATILE keeps the
title lock and the two-step funnel - C89 holds, the 74-lane
matrix, all four budgets, the title mailbox harness and the
fifty-entry audit zero all pass.
The guard on core_status_msg compared against a priority that was
only ever zeroed, so it admitted every message - preserved
deliberately through the lock removal so that change stayed
threading-only, and flagged then as a separate question. Answered:
the set arm now stores the message's priority, on the direct path
and in the deferral drain identically, so a held status keeps its
priority for its lifetime and lower-priority updates - clears
included - bounce until it expires or a same-or-higher write
lands. Every clear path already zeroed the held priority, so an
expired slot accepts anything.
The core_status_defer harness's shared rule gains the same store,
keeping its drain-vs-oracle agreement meaningful, and a
hand-computed lane pins the new semantics step by step: a priority-3
status survives a priority-1 update and a priority-1 clear, falls
to an equal-priority clear, and the emptied slot takes a
priority-0 message. Clean C89_BUILD=1 rebuild, all five backends,
the 74-lane matrix, budgets, pacing and content-closing suites,
and the audit's fifty-entry zero all hold.
RUNLOOP_MSG_QUEUE_LOCK is gone. The queue, core_status_msg and
their decay were already main-thread-only in every path but one:
off-main runloop_msg_queue_push callers ride the mpsc deferral, the
task system's progress pushes run at gather and retirement on the
main thread, and video_driver_frame's pulls, extraction and status
decay run there too. The one remaining producer that could arrive
on another thread was a rogue core calling SET_MESSAGE_EXT(STATUS)
off retro_run's thread - the libretro API forbids it, cores do it
anyway - and that case now rides the same deferral: the update is
captured whole into a node flagged core_status, and the drain at
the top of the next iterate applies it under the direct path's own
overwrite rule, mirrored exactly - including that path's quirk of
never storing a priority, kept deliberately so this change is
threading only. With every toucher on the main thread by
construction, the lock serialized nothing, and the seven lock
sites, the field and the lifecycle go; the macro block in runloop.h
becomes the invariant statement, and the stale claims in the win32
companion, gfx_widgets.h and the widgets race sample - all written
against the world where any thread pushed under this lock - are
brought up to date. The widget ring's own msg_queue_lock is
untouched: it never depended on the runloop's.
The deferral struct and its off-main test move above the
environment callback, their new first user - defined before use,
the lesson the mailbox gate taught positionally last week.
samples/runloop/core_status_defer reproduces the STATUS deferral
and holds three claims across 4000 posted updates from a worker,
scripted clears included: applied exactly once, whole and in
order; the drained final status agrees byte for byte with an
oracle running the direct path's rule over the same script; every
node freed exactly once. Clean under ASan/UBSan and Clang
ThreadSanitizer. A clean C89_BUILD=1 rebuild passes, all five
atomic backends compile runloop.c and the gfx lane, threads-off
builds hold, the 74-lane matrix and all four hot-function budgets
pass, the runloop pacing and content-closing suites pass, and the
thread-read audit stays at zero.
Two lanes the deferred-message push broke, both mine to have run.
The C89 lane: three functions grew statements ahead of declarations
- the off-main check now sits after the declaration block and the
settings hoists it must precede became assignments, the iterate's
drain moved below its declarations, and the deinit's cleanup sits
under the function's own declaration. make C89_BUILD=1 links from
clean.
The atomic-backend lane: mpsc_stack.h used the pointer atomics
unconditionally, and the volatile fallback backend omits them by
design - the gate is RETRO_ATOMIC_HAS_PTR, as retro_atomic.h's own
contract says. Without it the stack keeps its shape with plain
volatile operations and the guarantees that backend itself makes:
none beyond the single-context targets it exists for. All five
forced backends build the gfx sources and the task queue; the mpsc
lane and its TSan run stay green.
The deferred-message list took the message queue lock to push and,
worse, probed its head with a plain read every iterate - a data race
against the workers' locked writes, benign in effect and illegal in
form. The list is now queues/mpsc_stack.h, a many-producer
single-consumer intrusive stack in libretro-common: a worker's push
is a compare-exchange loop that never touches the message queue
lock, the iterate's emptiness probe is an acquire load, and the
drain takes the whole chain in one exchange. No ABA arises in this
shape - nodes are only pushed and the consumer takes everything -
and message frequency never made the lock slow; what the stack buys
is a legal probe and workers that cannot contend with the main
thread's own message work.
The primitive carries its own lane, mpsc_stack_test in the
libretro-common samples: four producers against a draining consumer,
every push drained exactly once with per-producer order and payload
held, clean under TSan and ASan - and a push weakened to a blind
store fails it under TSan on both the race report and the count. The
samples binary ignore no longer swallows new *_test directories, so
the lane is trackable.
runloop_msg_queue_push() renders widgets, measures text, speaks the
accessibility line and reads the settings - the main thread's work,
and six of the threaded task handlers were doing it on the worker.
An off-main caller now queues the message, whole, under the message
queue lock, and the iterate's drain replays it on the main thread a
frame late at most - the cadence messages show at anyway. The
threaded task worker also stops pushing progress after a handler:
the gather pushes for every running task each check and retirement
pushes the final state, both on the main thread, so the worker-side
push was a duplicate doing widget work from the wrong thread through
the queue's message callback - a function pointer the audit cannot
see, which is worth remembering.
The audit learns what this fix is: a boundary - a function that
defers off-main callers before touching anything - declared as
@boundary in tools/thread_read_allow.list and held by review, where
the walk stops. runloop_msg_queue_push is the first.
tools/thread_read_audit.py --binary retroarch: 23 findings become
11, across every handler that showed messages; the remaining 11 are
direct reads. All twenty-one task sample suites pass.
* audio: average the 1.0x flush interval in the fast-forward estimate
The speed estimate divided a sixteen-flush average of the flush interval
by the interval this flush's audio takes at 1.0x. Cores whose batch size
varies from flush to flush - mupen64plus_next alternates 185 and 551
frames - then get a multiplier that swings with the batch size, and the
pitch-preserving stretch runs some 6% short of real time, opening gaps in
every long fast-forward hold.
Average the expected interval the same way, so the ratio compares like
with like. The integer average of the measured interval is kept as it is;
the threaded producer's harness depends on its rounding.
* audio: seed the fast-forward speed estimate at the configured ratio
The first flush of a hold seeded the estimate at 1.0x, and the average
took some fifty flushes to reach the real speed. The frame limiter has the
core at the configured ratio within a frame, so for that long the audio
was compressed too little and ran up to three times real time into a
device the blocking 1x writes had left full. With the non-blocking driver
fast-forward uses, 100-250 ms was dropped at every press, and a 150 ms
hold lost more audio than plain discard does.
Seed at the fast-forward ratio and return its multiplier at once; the
measured speed takes over from there. An uncapped ratio still starts at
1.0x. Measured with 150 ms presses at 3x: 4.0 s dropped in 12.8 s of
fast-forward became 0.06 s on OoT, 5.7 s became 0.05 s on FF7.
* audio: let pitch preservation follow fast-forward speed on its own
Pitch-Preserving Speed Changes only measured the fast-forward speed when
Fast-Forward Audio Speedup was on; with it off the stretch stayed at unity
and the flush fell back to discarding samples, so the setting appeared to
do nothing for fast-forward.
Follow the measured speed when Speedup asks for it or when pitch
preservation owns the transport, on both pipelines. The filter-only
transport keeps the Speedup-or-discard behaviour, since it has no way to
hold pitch.
* audio: widen the WSOLA search to four hops
The similarity search reached half a hop either side of the nominal
position: 64 samples at 48 kHz. A splice can only land on matching phase
if the search reaches a full period, so nothing below about 375 Hz was
being preserved. At 3x a 55 Hz tone came out at 165 Hz with 98% amplitude
modulation, 110 Hz at 143 Hz, 220 Hz at 285 Hz; 440 Hz and up were clean.
Bass lines and low mids in a game's music were transposed and wobbling
while the rest kept its pitch.
Four hops (512 samples at 48 kHz) cover fundamentals down to about 47 Hz
and keep every test tone within 6% of steady amplitude. The search costs
four times as much correlation, under 1% of a desktop core at 3x, and the
ring and its retained lookahead grow with the radius: the harness bounds,
drain scenarios and README figures follow.
* audio: keep the inline stretch active at unity once it has started
The inline transport returned to raw copying whenever the speed came
back to 1.0x. Leaving the engine drains the source lookahead its search
kept, up to twice the radius, as one flush onto a device the hold left
full, and with the VRR runloop's non-blocking frames after a release most
of it is dropped: 2-4 ms per release at the old radius, 10-40 ms at four
hops. The next engage then pauses output while the lookahead fills
again. Leaving the lookahead behind instead shorts the device by as much
and rapid presses run it dry.
Once the engine has started, keep it active at unity. Unity synthesis
lands every splice on its own continuation, or a period of it in a
stationary passage, so the audio passes through all but unchanged; the
cost is the lookahead as holdback, 16 ms at 48 kHz, from the first speed
change until the transport is reset. Measured with 150 ms presses at 3x:
0.5 s dropped per 40 presses became 7 ms.
* audio: end the stream on a ramp at a pause, and bring it back on one
Opening the menu pauses the core, and the audio stream ended wherever
the waveform happened to be: a step at both ends, heard as a click. And
between the last write of the pause and the first of resumed core audio
the runloop hands nothing over, ~33 ms with the menu, so the device
drains through the hole and rate control refills it at half a percent
of real time: PipeWire's pulls came up short for seconds after every
close, 10-48 ms of fabricated silence after a single close and 50-138 ms
per close when tapping the menu key, which is heard as a crackle.
Every frame has already gone to the device by the time the pause is
known, so a plain ramp has only the last sample to decay. Keep a copy of
the last frames of output instead, find the period that best matches the
last few milliseconds, and repeat it under a raised cosine, as a packet-
loss concealer does. The period is scored by distance, since correlation
is blind to level. The tail is written straight to the device under the
state lock, ahead of what the resampler and the stretch still hold:
pause_mute_frames drops the resampler's leftovers, and the inline
stretch is reset so its lookahead does not come out behind the tail.
The ramp back up is owed to the core's first audio after the resume,
not to the menu's silence in between, so every publish says whether it
is the core's own and the ramp is armed on the first that is. The same
flush first fills the device with silence up to write_avail(): silence
is free, the device has been playing it, and the surplus is what rate
control already takes out. It has to be the first flush rather than the
resume itself, since a top-up at the resume is undone by a pull landing
inside the hole.
A core with its own audio thread kept handing frames over after the
tail, since menu_pause_libretro never sets RUNLOOP_FLAG_PAUSED.
audio_driver_pause_fade() publishes core_silenced, which gates the
core's input paths and only those.
Under the threaded pipeline the ring behind the tail still held frames
of the core's audio, and the stretch stage its lookahead. The pause
records the ring's head, and the consumer takes the ring out up to it
and drops what the stage holds; the resume marks where the core's audio
starts, and the consumer never takes a chunk across that point and arms
the ramp on reaching it.
The ramp hangs off retroarch_menu_running() and its finished
counterpart, which the menu hotkey reaches where CMD_EVENT_MENU_TOGGLE
does not, and follows what the runloop will do rather than the setting
alone: under netplay the core keeps running behind the menu. The pause
hotkey, the setting toggled from inside the menu, an audio reinit while
held, the paused rewind step, the BSV checkpoint seek and a netplay
session starting or ending behind the menu go through the same call.
write_raw drivers get the resume ramp and the mute but no tail: they
hand the driver the core's buffer at the input rate, so there is nothing
at the output rate to keep as history.
Measured headless against PipeWire, with 8 toggles of 1.5 s and 12 of
0.3 s on mupen64plus_next and swanstation: the RMS of the 3 ms at each
edge against the 15-25 ms beside it went from 1.0 (a hard cut) to 0.1
(the ramp) at both edges, and the silence fabricated across a run from
64-285 ms to none, on the inline and the threaded pipeline.
* audio: bracket the frontend's state jumps with the pause fade
Loading a state splices the game's own audio, and the frontend does the
splicing on top of a device that may already have run dry: the undo
backup and the core's unserialize both run on the main thread inside the
load task's callback, for a main-loop gap that can exceed the device's
buffer on a large state.
Use the pause tail and the resume ramp the menu already uses, so the
splice and whatever gap the stall leaves both land in silence. The other
three ways the frontend throws the game's audio away mid-waveform get
the same bracket: a core reset re-initialises the machine, an undo pays
the same two serialisations as the load it is undoing, and a load from
the RAM slot is a load. All run on the thread that would otherwise be
feeding the device.
audio_driver_jump_fade_begin/_end name the bracket rather than repeating
its invariant at four call sites - the stream may already be down,
because the jump was made from the menu or while paused, and then the
ramp back up has to stay with whatever took it down.
core_reset() is bracketed at the CMD_EVENT_RESET site rather than inside
it, so netplay's own reset path is untouched.
* runloop: publish the audio snapshot before the frame runs
The snapshot the audio producers read is refreshed at frame end, after
retro_run(), while the runloop flips fast-forward, slow motion and pause
before it. The first frame after every toggle flushed on the previous
state: at a fast-forward press, a full 1x block into a device already
non-blocking and full, 7 ms refused per press.
* audio: wait for device room on the pitch-preserving path
Fast-forward writes the device without blocking, and a write the ring
cannot take is cut short. The discard path means to lose the rest; the
pitch-preserving stream is continuous, so the cut is a gap. At 1x the
blocking writes leave the ring at the brim, and the first stretch output
after a press lands before the device has pulled again: 3-9 ms refused at
every press that follows a second or more at 1x, heard as a click.
Complete a short write with one blocking write for the remainder, on the
inline pitch-preserving path only; the wait is bounded by the remainder.
Measured on the desktop over nine presses: 0 ms lost, 84 ms recovered.
* audio: the fast-forward seed reads the published ratio
The seed of the speed estimate read the fastmotion override and the
fast-forward ratio setting inside the flush - the submit path, which
is the audio thread when the core owns its audio callback, and the
override is rewritten by the environment call at exactly the moments
the seed fires. The publisher resolves the override against the
setting where both belong to the main thread and publishes the result
as a float bit pattern beside the slowmotion ratio; the seed reads
that, and the ratio-seed fixtures publish after setting the ratio,
as fixtures do for everything the audio side consults.
* samples: the pause fade holds its promises under test
The inline half in ff_speedup: the pause tail opens where the stream
stopped and only the join correction can carry that level - the
fixture's history steps 0.3 above its cycles at the end, the way real
content moves - the envelope only decays and reaches silence, keeps
the waveform's pitch, and fits the room a full non-blocking device
has left; the mute drops the resampler's leftovers; the resume ramp
waits for the core's first audio, opens from silence, stands halfway
at its middle and hands over at full level; the jump bracket leaves a
stream someone else took down alone. The test object now depends on
the audio_driver.c it includes: without that an edited driver leaves
a stale binary that silently keeps passing.
The threaded half in pipeline_wakeups, as check-pause: through the
wrapper's parked control transactions the pause lands with the ring
provably holding unplayed source, and the device tap shows none of it
is ever played while the resumed stream reaches the device under the
ramp, opening below 2% of its level and climbing. Sequenced against
the real consumer thread and clean under TSan; the load sweep's
conversion-volume assertion stays with the load sweep.
---------
Co-authored-by: LibretroAdmin <[email protected]>
The iteration's measured period and the frame limiter's schedule both want
the instant the iteration ended, and everything between them - the pace
gather, the source decision and the no-window test - is a set of flag tests.
runloop_iterate() takes the reading once into pace_now and the limiter
schedules against it.
runloop_state_t::frame_limit_last_time is written on content load, on the
quit path and once per iteration inside the timer pacing block, and is read
nowhere - the nanosecond anchor carries the schedule on its own. The field
goes, and with it the divide the pacing path did to feed it.
cheat_manager_apply_rumble() takes the timestamp from
cheat_manager_apply_retro_cheats() rather than reading the clock itself. It
runs once per cheat per frame and returns immediately for every entry whose
rumble type is disabled, so one reading now serves the whole pass and every
cheat in a frame is applied at the same instant. The function is file-local
and is marked static.
main_flags says of itself that only the main thread writes and reads it,
and one bit of it broke that: video_context_driver_get_refresh_rate()
tests VIDEO_FLAG_CRT_SWITCHING_ACTIVE, and the threaded wrapper calls it
from the video thread through the driver's get_refresh_rate. A
read-modify-write of that word on the main thread - setting the widgets'
rewinding bit, once a frame - races the read.
It becomes a retro_atomic_int_t of its own, written at the two places
that turn CRT switching on and off and read through an acquire
wherever it is asked about, so main_flags is what its comment claims
again.
config_file_write() reports failure properly - a write error, a full
disk, a dump that could not allocate its staging buffer - but that is
only worth something where the caller looks. Four call sites did not.
Two of them are the core options save on deinit, and both logged
"Saved core options file to X" immediately *before* the write, then
discarded its result. So a failed save told the user their options
were saved, in the same words a successful one uses, and the changes
were gone at next launch with nothing in the log to say why. The log
line now follows the write and reports what actually happened.
frontend_salamander.c had the same shape: it wrote its config and
then unconditionally logged that the file had been written. A failure
there means the core it just located is not recorded, so the next
boot has to find one again - worth an error rather than a debug line
claiming success.
tasks/task_autodetect.c dropped the result of its controller profile
index write. That one is genuinely not fatal, since a missing index
just means the next connect falls back to the full directory scan,
but rebuilding it on every boot because the write keeps failing
should not be silent either. A warning, not an error.
No behaviour change on the success path in any of the four.
The three ride in one word: the slot index is a subsystem content
slot, the runtime status has three values, and the thumbnail flags
are five bits. struct playlist_entry goes from 120 bytes to 112, so
a large playlist sheds another eighty kilobytes on top of what the
timestamps returned.
The JSON reader gains one more name for the slot, since a packed
field has no address to hand it.
A reader of the cached frame samples a generation counter, snapshots
the (data, dims) tuple, arms a hazard slot with the pointer it is
about to touch, then re-validates the generation before running its
callback outside the tuple lock. Teardown paths call
video_driver_cached_frame_retire(), which bumps the generation and
waits until no hazard slot names memory it is about to release, so
publishing a frame never waits on a reader. Targets without a
lock-free pointer CAS hold the tuple lock across the callback
instead.
Swapchain texture deinit and recreation, the d3d12 software
framebuffer paths, oga_free() and driver_uninit() all retire, the
last of them before video_driver_free_internal() releases memory a
lent software framebuffer may still be cached from. Core unload,
core deinit and core reset retire as well; sites that release
nothing keep video_driver_cached_frame_invalidate(). Retiring is
correct whoever owns the buffer, so the per-driver
invalidate_if(predicate) hook is dropped.