Files
psxrecomp/docs/RENDER_THREAD.md
T
Shokunin 2b517f3a05 HD + render thread: keep the A0 hold across HD activation and savestate loads
Track an open GP0(A0) upload in the renderer whatever the HD state
(facade gr_vram_upload_begin/commit/set_open): HD activation mid-A0
waits for the commit instead of reading the FBO over received words,
and a full-VRAM load no longer ends the hold; gpu.c restores it from
gp0_state on load and ends it on GP1(01h). Sync the render thread
before flushing dumps or reading HD diagnostics, suppress dumps from
generated frames, use _putenv on MinGW, drop a stale log. Adversarial
fixtures for both cases in full-VRAM and high-resolution-window modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:41:54 -07:00

413 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Render thread (`[video] render_thread`)
Opt-in, OpenGL only, off by default. With it off nothing in this document
runs: `gl_backend_get()` returns the existing table and the GL renderer is
the synchronous path every title already uses.
```toml
[video]
render_thread = true # game.toml; PSX_RENDER_THREAD=0/1 overrides
```
`PSX_RENDER_THREAD_FRAMES=N` (default 2) bounds the closed frames in flight.
**Player settings.** `render_thread`, `present_thread` and `frame_generation`
are also read from the player's `settings.toml` `[video]`, which wins over the
game.toml default; the `PSX_*` env vars override both for one run and are
never saved. The launcher shows them as Settings → Display rows (Render
thread, Present thread, Smooth motion (frame generation); recomp-ui
`RECOMP_LAUNCHER_HAS_RENDER_PIPELINE`, OpenGL only, the latter two disabled
while Render thread is off). The pipeline starts once at boot, so a change
from the in-game launcher is saved and applies at next launch.
**HD textures.** Texture replacements and dumping run on the render thread.
Residency notes (upload, invalidate, copy) are recorded in command order and
applied against the render thread's VRAM copy. Under HD authority native VRAM
is CPU-rasterized on that copy and published to gpu.c's array whenever the
emulation thread takes the context and at stop. A GP0(A0) header takes the
context and keeps it until the payload commits, so streamed words, mask
checks and partial readbacks are exact. The open upload is tracked whatever
the HD state, so HD turning on mid-payload, or a savestate restoring gp0
mid-A0 (after its full-VRAM load), keeps the hold too. Generated frames do
not dump. Smooth motion runs with HD on.
## Why
Before this change one thread ran the recompiled guest, turned every GP0
command into GL calls (`gpu_gl_renderer.c`), presented, and paid every
Apple GL-on-Metal stall: command-buffer waits, uploads, readbacks, the
swap. A frame cost `emu + GL`. With the render thread a frame costs
`max(emu, GL)`, and the guest waits for rendering only at the sync points
below.
## Model
```
emulation thread render thread (owns the GL context)
---------------- -----------------------------------
guest + gpu.c (GP0/GP1, VRAM array, replays records in order through the
status, timing) unchanged glb_* functions; presents
gr_* -> GL_RT_BACKEND (record) ----> ring (32 MiB, SPSC) ----> exec()
present (record + overlay copy)
vblank: frame boundary --------------> frame marker; <= N closed frames
sync point: drain, take context <---- release context, park
... direct GL calls for the rest of the frame ...
frame boundary: hand context back ---> make current, continue
```
Pieces, with file:line on `feat/render-thread`:
- **Core** — `runtime/src/render_thread.c`, `runtime/include/render_thread.h`.
Single-producer/single-consumer byte ring with monotonic 64-bit positions.
A record never splits across the ring end (a pad record fills the tail);
payloads are copied, and space is freed only after `exec()` returns. No
lock is held while a record executes; a mutex and two condition variables
guard sleeping only (each side raises its `*_sleeping` flag under the lock
before re-checking, the waker publishes its position before reading the
flag). The consumer spins briefly before sleeping; the producer wakes a
sleeping consumer every 128 KiB / 256 records and always at a frame end,
drain or sync point. On macOS the thread is created with
`QOS_CLASS_USER_INTERACTIVE`: with the default QoS a busy host scheduled
it onto efficiency cores and it fell behind the emulation thread (R4 4K
race on a host at load average ~40: 40 Hz with the default class, 60 Hz
with this one).
- **Frame bound** — `rt_frame_end()` blocks while more than N closed frames
are unconsumed (backpressure). `rt_frames_ahead()` tells the replaying
thread how many complete frames are queued behind the current one.
- **Sync point** — `rt_acquire()`: drain, have the render thread release the
context (`glFlush`, `SDL_GL_MakeCurrent(win, NULL)`), make it current on
the emulation thread. The emulation thread then *holds* the context and
calls the backend directly, exactly as with no render thread, until the
next frame boundary hands it back (`rt_release()`). So a sync point costs
at most one hand-off per frame, and code that is not converted to
recording stays correct.
- **Recording table** — `GL_RT_BACKEND` (`gpu_gl_renderer.c:8469`), selected
by `gl_backend_get()` while the thread runs (`gr_refresh_backend()` in
`gpu_render.c`). Every state call, primitive, fill, copy, CPU->VRAM upload,
single-pixel write and native-wide call is recorded by value; queries are
answered from emulation-side mirrors or are sync points (table below).
- **Hand-off rules** — `gl_renderer_render_thread_frame_boundary()`
(`:8653`), called from `sdl_vblank_present()` after the present and before
wall-clock pacing (`main.cpp:8650`). It releases a held context when the
frame may run asynchronously (`gl_rth_eligible`, `:8173`), takes it when
it may not, then closes the frame.
### Guest-visible VRAM stays on the emulation thread
The GL backend is GPU-authoritative: `s_hr_fbo` holds VRAM, gpu.c's array is
refreshed only by readbacks (`ensure_cpu`, `:2395`), and pending uploads
stage from that same array (`flush_cpu_upload`, `:838`). gpu.c writes the
array directly while a GP0(A0) payload streams in (`gpu.c:6912`) and hands
the facade a copy at commit (`gpu.c:3156`). If the render thread staged from
the live array it would upload pixels of a later transfer (and its
`sw_vram_transfer_in` would write stale payloads back over the guest's
array).
So the backend's `s_vram` is gpu.c's array whenever the emulation thread
runs GL, and a private copy while the render thread does
(`gl_rth_acquire`/`gl_rth_release`, `:8181`/`:8188`; the SW rasterizer's
pointer moves with it, `sw_renderer_rebind_vram`). The private copy gets
each upload payload in command order; a release first copies gpu.c's array
into it, so it matches the guest at every hand-off. Every readback runs on
the emulation thread under a sync point and writes gpu.c's array, as before.
### Guest state the backend reads while drawing
| State | Read by | Under the render thread |
|---|---|---|
| per-prim widescreen tags (`psx_ws_prim_in_backdrop`, `psx_ws_prim_is_tagged`) | `bd_prim_gate` `:2533` | captured per primitive in the record flags (`rth_prim_flags`) when native-wide and the backdrop stretch are on; replay reads them via `ctx_prim_*` (`:379`) |
| native-wide fast-path latch (`gpu_ws_background_requires_full_composite`, `gpu_ws_netplay_local_viewport_width`), flat backdrop | `wide_fast_center_valid` `:2569` (also at batch flush and present) | sent as an `RTH_STATE` record before the first call that would see a change |
| depth24 display (`gpu_display_is_depth24`) | `depth24_upload_policy` `:4096` and the depth24 helpers | a depth24 display is a sync point (`rth_record_mode`, `:8202`); the render thread only replays 15-bit frames, so `ctx_depth24()` is 0 there |
| host overlays (OSD, volume, rewind strip, savestate menu) | `gl_swap_with_osd` `:7543` | rasterized and copied at record time (`rth_record_present`, `:8216`); `host_osd_present_done` runs at record time |
| present-shot request | `gl_swap_with_osd` | already mutex-guarded (`main.cpp` `present_shot_take`) |
Emulation-side answers without a sync point: `gl_renderer_projective_supported`
(`:3854`) and `gl_renderer_present_wide_fbo`'s return value (`:7981`) from a
mirror of the wide-surface bookkeeping (`rth_mirror_wide_for`, `:8121`, the
same lookup, limit check and slot allocation `wide_fbo_for` does), and
`gr_get_draw_area` from the recorded draw area. `gr_scale`,
`gr_texture_filter` and `gl_renderer_fit_wide_aspect` read values that only
change while the emulation thread holds the context. The mirrors are re-read
from the backend at every release.
## Sync points found in the code
Guest-visible:
| Where | Why |
|---|---|
| GPUREAD `gpu_read_gpuread` (`gpu.c:3743`) -> `gr_vram_read` | VRAM->CPU transfer reads the FBO (`glb_vram_read` -> `ensure_cpu`) |
| GP0(A0) with mask check (`gpu.c:6912`) | per-pixel mask test reads VRAM |
| `gpu_vram_peek` (`gpu.c:6889`) | debug/mod VRAM reads |
| savestate / rewind save (`boot_state.c:148,159,189,356`) | `gr_vram_transfer_out` of all VRAM |
| savestate load (`gl_renderer_restage_vram_after_savestate`, `:5112`) | full restage from gpu.c's array |
| depth24 (FMV) display | the CPU mirror is presented; the emulation thread draws those frames |
Not guest-visible but GL-owned (each is `GL_RT_SYNC` at the top of the entry
point): `gr_render_display*`, `gr_render_wide_display`, `gr_wide_dump_full`
(screenshots, CPU present fallback), every `gl_renderer_pass_*`/`stereo_*`
(render passes), `psx_mod_openxr_*`, texture-bank selection, settings
(scanlines, aspect, gamma, bezel, swap interval, interpolation), the CPU
present / blank / hold-last presents (pause, rewind, netplay hold), and the
debug-server diagnostics (`gl_*` rings, `frame_perf`, `gl_fbo_peek`,
`gl_vram_diff`). Two per-frame debug captures are queued instead of being
sync points: the display ring's readback (`gl_renderer_fbo_peek_deferred`)
and the `--headless-opengl` presented-image capture
(`gl_renderer_ring_capture`); their ring readers sync first
(`gl_renderer_render_thread_sync`). `ensure_cpu` refuses to run on the render
thread: a readback there would land in the private upload source and mark the
CPU side current without gpu.c's array seeing the pixels.
What is *not* a sync point: a frame of primitives, fills, copies, uploads,
native-wide draws and the 15-bit present (`gl_renderer_present_vram` /
`present_wide_fbo`). A Ridge Racer Type 4 race loaded from a savestate takes
one sync point (the load) and then none.
## Frames, backpressure, stale presents
At most N closed frames wait in the ring; past that the emulation thread
blocks in `rt_frame_end`. The render thread must replay every frame's
drawing: later frames sample what earlier frames rendered, so drawing cannot
be skipped. Only the present can. When two newer frames are already recorded
(the emulation thread is at the bound, waiting), the present of the frame
being replayed is skipped (`rth_replay_present`, `:8250`), never twice in a
row, so a saturated render thread still shows at least every other frame.
Consequence: the render thread takes the GL cost off the guest's frame, but
if GL alone exceeds the frame budget the guest is still held back by the
bound. Choosing an internal resolution that fits is the job of dynamic
resolution (below), not of this layer.
## Interoperation
| Feature | Behaviour |
|---|---|
| savestates / rewind | load and save are sync points; the frame stays synchronous, the next frame boundary releases |
| netplay / rollback resimulation | `psx_netplay_active()` or dual-raster makes every frame ineligible: the emulation thread holds the context (synchronous path). Not started when netplay is configured at boot |
| render passes | first `gl_renderer_pass_*` call in a frame is a sync point; the rest of that frame is synchronous. Recommended: leave `render_thread` off with the frame-rate mod |
| frame interpolation | ineligible (held) while enabled; not started when it is on at boot |
| HD texture replacements / texture dumping | eligible; held only from a GP0(A0) header to its commit |
| OpenXR | ineligible while a session is active |
| Smooth motion (frame generation) | `[video] frame_generation`: in-between frames drawn by the render thread from recorded lists, docs/FRAME_GENERATION.md |
| native-wide / widescreen | recorded; tags, latch and wide-surface mirror as above |
| internal resolution changes | `gr_set_scale` and every resolution entry point are sync points; dynamic-resolution level steps are recorded (below) |
| screenshots / debug captures | `screenshot*` sync; `present_shot` is fulfilled on the render thread |
| headless | `--headless` (software) never starts it; `--headless-opengl` runs it on the hidden context (no present; the frame boundary still closes every frame), which is what `fp_identity` exercises |
| Vulkan / software | never started (log line says why) |
## Present thread (`[video] present_thread`)
Opt-in, under the render thread, off by default (`present_thread = true`;
`PSX_PRESENT_THREAD=0/1`, `PSX_PRESENT_THREAD_SLOTS=2..4`, default 3). On
macOS a swap on the render thread cost 6-9 ms of waiting on the window
compositor (`[NSOpenGLContext flushBuffer]` on GL-on-Metal) in every frame,
real or generated; with this it costs a fence and a hand-off.
- **Slots.** While it runs, framebuffer 0 of the composing context (the
render thread's, or the emulation thread's while it holds the context at a
sync point) is an offscreen slot: every `glBindFramebuffer(..., 0)` goes
through `gl_bind_fb_redirect` and lands on the current slot, an RGBA8 (RGB8
if the window has no alpha) texture plus depth-stencil at the window's
drawable size, reallocated when the window changes. Everything that used
to be drawn into the back buffer (display quad, OSD, hold-last captures,
`present_shot`, frame generation's kept real frame) is drawn into it
unchanged; the two `glReadBuffer(GL_BACK)` reads read its attachment.
- **Swap.** `gl_present_swap`: a fence (`glFenceSync`), `glFlush`, queue the
slot to the present thread, take the next free slot (waiting only while
every other slot is queued or on screen: the display is the bottleneck;
that wait is what the dynamic-resolution and frame-generation ledgers count
as the swap), and `glWaitSync` on the fence the present thread left after
its last copy out of that slot.
- **Present thread** (`present_thread.c`, backend-neutral core; GL callbacks
in `gpu_gl_renderer.c`): owns a second context created on the same window
with `SDL_GL_SHARE_WITH_CURRENT_CONTEXT` (textures and syncs are shared;
FBOs are per context, so it keeps its own read FBO per slot). Per slot, in
submission order: `glWaitSync` on the ready fence, a 1:1 `GL_NEAREST`
blit to the real framebuffer 0 (scaled only if the window changed since
the frame was composed), a done fence, the swap. The swap interval is
applied on its context. `QOS_CLASS_USER_INTERACTIVE` on macOS, above-normal
priority on Windows.
- **Fallback.** No sync objects, no shared context or a failed start: a log
line and direct swaps, as without it. It starts and stops with the render
thread (stop presents what is queued first).
- **Pixels.** The window shows exactly the composed image (gl_frame_gen_test
reads the window back after each copy). Compared with composing straight
into the window, the composed image is bit-identical at 1x and for every
native-wide present; on Apple GL-on-Metal a VRAM present resolved down
into a smaller window (area resolve) rounded 1 pixel by 1 LSB in 2 of 24
frames: rasterizing into a texture instead of the drawable. The test
allows at most 1 LSB on 0.05 % of the pixels and reports it.
- **Latency.** Up to `slots - 1` composed frames can wait for the display.
Frame generation already paces presents to the refresh, so the queue
normally holds at most one.
- **R4** (Match display 10x ceiling, dynres, Fit widescreen, 120 Hz panel,
host at load average 22-26): the render thread's swap went from 4.5-7.8 ms
to 0.02-0.08 ms; the present thread's swap takes 8-10 ms. 2P VS guest
56.6-57.6 → 57.2-58.6 Hz; with generation forced (test knob) 1P guest
25 → 41-44 Hz and presented 50 → 78-85 Hz.
`{"cmd":"render_thread"}` carries `present_thread`: active, slots, submits,
presents, waits and wait time (composing side blocked), swap time (avg/max,
on the present thread), queue high water.
## Dynamic resolution (`[video] dynamic_resolution`)
Opt-in as before (`dynamic_resolution = true`, `dynamic_resolution_min`;
`PSX_DYNRES=0/1`, `PSX_DYNRES_MIN` for one run); no new keys. The level
steps between the configured internal resolution (the ceiling, e.g. Match
display, which the GPU limit and memory budget are checked against) and the
minimum. Every surface (hr VRAM and native-wide) is allocated at the current
level and reallocated at each step: the hr surface is reseeded into the new
allocation from the 1x image plus the displayed rect and draw area at full
detail, the wide surfaces' presented rows and margins are rescaled into
theirs. On Apple's GL-on-Metal every render pass into an attachment loads and
stores the whole attachment, so surfaces held at the ceiling cost the
ceiling's bandwidth at every level; with a 10x ceiling, R4's 2P VS start
(which needs about 4x) ran at 35-40 Hz for its first seconds at any level.
If a new allocation fails, a step down rescales in place inside the old
allocation and a step up past it is refused. With the render
thread off nothing changes: the emulation thread's wall-time controller
(`dynres_*` in `dynamic_resolution.c`) decides. With it on, the guest's
frame no longer pays for GL, so its wall time says nothing about whether the
resolution fits; a second controller (`dynrt_*`, same file) takes over when
the render thread starts and is fed what the render thread measured. The one
target is every guest frame at the guest's rate (60 on NTSC); resolution is
the only lever.
**Cost.** Per replayed guest frame, between its first record and an
`RTH_FRAME` marker the frame boundary records: the render thread's CPU time
(wall time minus its idle waits for records and its time in the swap, the
display's vsync) and the GPU time of the same span (a `GL_TIME_ELAPSED`
query, read back when available, never waited on; `GL_TIMESTAMP` counters
read 0 on macOS). cost = max(CPU, GPU), published as running totals
(`gl_renderer_render_thread_costs`). A frame during which the context went
to the emulation thread is dropped (its span holds the emulation thread's
drawing). While this measures, `frame_perf`'s own TIME_ELAPSED brackets
stand aside (queries of one target cannot nest). `PSX_DYNRES_GPU_TIMER=0`
leaves CPU time only.
**Decisions** (0.25 s windows of guest time; `dynamic_resolution.h` has the
full rules and `dynrt_default_params` the numbers):
- load = mean cost / the guest's nominal interval (one display refresh at
60 Hz). Budget 0.85 (15 % margin).
- Down, one level, after two consecutive windows over budget, or one window
in which the emulation thread spent 5 % or more of the time blocked on the
queue bound (backpressure: the render thread is behind) or the cost was
over the whole interval. Never below the minimum.
- Guest-bound: when the guest ran below its rate, never waited on the queue
and the render thread had slack in the interval it was given, the guest is
the limit and no down step is taken.
- Judged: the window after a step settles (the cost lags the queue and the
query readback), the next one judges it. A down step that removed less than
30 % of the predicted cost (and did not end the backpressure) is a strike;
a second in a row means the cost is not the pixels: both steps are undone
and down steps are blocked for 20 s, doubling to 320 s.
- Fast first descent: armed when the controller starts (session start,
render-thread start, a new resolution) and on a savestate load, game entry
or window resize, for 6 s from the first judged window. While armed, a
window at or over the whole interval, or two over budget, jumps straight
to the highest level whose pure-area prediction (load × (S′/S)²) is at or
below 0.78; the jump is judged like any step and, if still over, jumps
again from the new measurement. A fixed part of the cost only makes the
target dearer than predicted, so a jump never lands below the level that
fits, and the level above it is predicted over the 0.70 up threshold, so no
up step follows. A fit after a step, an up step or a failed judgement
disarm it; single steps fine-tune. R4 2P VS from a 10x ceiling (busy
host): 10→5 or 10→6, then 4-3; guest at 59 Hz or more from 1.2-2.3 s
after the load (one level per judged window: 3.3 s).
- Up, one level, after 3 s of windows with no backpressure and the next level
predicted at 0.70 or less, 2 s after the last step, never above the
ceiling. The prediction (load × ((1−f) + f·(S′/S)²)) learns f from each
judged step. A level reached by an up step and left within 10 s is blocked
for up steps, 10 s doubling to 160 s.
- Holds as before (FMV, fast-forward, overlay compiles, resim, resizes,
boot); the savestate-load and game-entry tails, which only covered the
wall-time model's settling, are cut to 0.5 s.
**Steps** are recorded (`RTH_DYN_STEP`) after the frame just closed, with the
displayed rect captured at record time, and applied by the render thread in
stream order (`dyn_apply`, the same reseed as without the thread). The
emulation side's level (`gr_scale`, the wide-surface mirror,
`gl_renderer_dynres_stats`) moves at record time. A frame the emulation
thread holds steps directly, as without the thread.
**Telemetry.** `{"cmd":"dynres"}` reports `"mode":"render_thread"`, level,
load, budget, `bp_share`, `guest_hz`, `cpu_ms`/`gpu_ms` (last window's
means), `guest_bound`, the step counters and `up_blocked`.
`PSX_DYNRES_TRACE=<csv>` writes one line per window
(`t_s,level,load,bp_share,guest_hz,cpu_ms,gpu_ms,guest_bound,decision`).
## Out of scope
- Vulkan (the same split would apply; its backend has its own readbacks).
- Converting the remaining sync points to recorded commands (texture banks,
render passes, CPU/hold presents). Each is correct as a sync point.
- Making replay cheaper (batching across records, fewer GL state changes).
- Diagnostic rings written by both threads (`latency_ring`, `gpu_timeline`)
can interleave entries; they are diagnostics only.
## Tests
- `render_thread_test` (`runtime/tests/test_render_thread.c`): the core
against a fake executor and context token — ordering and payload integrity
over 400 frames of random-size records through ring wraps, oversize
refusal, the in-flight bound under a slow consumer (and that the one- and
two-frames-behind cases both occur), 300 randomized acquire/release rounds
with the context never current on two threads, stop while held. Clean
under ThreadSanitizer.
- `gl_render_thread_test` (`runtime/tests/run_gl_render_thread.py`,
`test_gl_render_thread.c`, label `gpu;opengl;hardware`): a real hidden GL
context, a scripted stream through the facade, render thread off vs on at
1x and 4x; the values the guest read back, native VRAM, the frame at S and
the native-wide surface must be identical. The stream has mask set/check,
a mask-checked upload (per-pixel readback sync point), back-to-back
uploads of one rect with a catch-up between gpu.c's writes and its commit,
more than the ring's size recorded, readback frames, presents with a slow
render-thread present, and widescreen tags that change per primitive and
are poisoned after each call. It fails for each of these deliberate
mistakes: replay reading the tags live, staging uploads from the guest
array, dropping the stream state.
- `video_enhancement_settings_test`: the keys (`render_thread`,
`present_thread`, `frame_generation`) default off and parse, and the
settings.toml copies parse and round-trip through `save_user_settings`.
- `present_thread_test` (`test_present_thread.c`): the present core against
a fake presenter: presentation order is submission order over 600 frames
with 2-4 slots, a slot is never composed while queued or on screen, each
done token reaches the composer that next takes its slot, a slow display
blocks the composer, stop presents the queue and disposes leftover tokens,
a context failure fails the start. Clean under ThreadSanitizer.
- `gl_frame_gen_test` present-thread runs: generation off and on, VRAM and
native-wide, flip and late timing: the window shows every composed image,
in order, bit for bit; real frames match the direct-swap run (see Pixels).
- Windows (MinGW): the runtime builds with the Win32 path of
`render_thread.c` (SRW lock, condition variable, `CreateThread`), and
`render_thread_test` and `gl_render_thread_test` run there (the GL test
exits 77, a CTest skip, on a host with no GL 3.3 context).
- `dynamic_resolution_rt_test` (`test_dynamic_resolution_rt.c`): the
render-thread controller against a synthetic two-stage pipeline (guest E,
render a + b·S², two frames in flight, costs four frames late): overrun ->
one level at a time to the highest level that fits, then no oscillation;
headroom -> up only after the window; the hysteresis band; guest-bound ->
no step; a cost that does not scale -> two strikes, undo, back-off; queue
full with an under-reading meter -> down; floor and ceiling; a heavy
stretch whose first window is mixed -> one strike, not an undo; holds;
thin windows; pins; fast descent (10x→5x in one jump in 0.68 s vs 3.88 s
one level at a time, no overshoot, no step in the next 120 s; re-armed by
`dynrt_arm_descent`, single steps when not armed; light windows first do
not use it up; a flat cost still ends in the undo; the target function).
- `gl_render_thread_test` dynres mode: the same level steps between frames
with the thread off (applied at once) and on (recorded, and directly in
held frames) give identical readbacks, VRAM, frame and wide surface.
- In game (R4, see the PR): `tools/fp_identity.py` guest identity render
thread off vs on under `--headless-opengl`, and A/B frame rates from a race
savestate at Native, 4K and Match display, 4:3 and widescreen.
## Debug server
`{"cmd":"render_thread"}` — active, held, max_frames, records, bytes,
frames produced/consumed, presents and `presents_stale` (skipped), acquires
(sync points) with the 16 most recent reasons and wait times,
backpressure / ring-full waits and time, render busy/idle time, ring high
water. Reading it is not a sync point.