7.6 KiB
Simultaneous stereo rendering
The paired API is an opt-in enhancement alongside temporal render passes. It uses the same frozen-time checkpoint, store policy, watchdog, VRAM journal and restore. It does not require interpolation, a FLIP source, a temporal plan or Q16 phase labels. Each eye callback receives LEFT=0 or RIGHT=1 explicitly.
Call psx_mod_render_stereo(cpu, frame, callback, user) from a game's measured
draw boundary outside interrupts or active GPU DMA. The callback must rebuild
and submit geometry, without waiting for VBlank or advancing game logic. The
rect declares the capture region. Restore completes after each eye before the
next one starts. Successful eye textures remain private until both transactions
and capture/restore verification succeed. Failure retains the preceding complete
pair. Host callback state is the plugin's responsibility, including selectors
that must be cleared after watchdog rollback.
period_vblanks describes the game's draw cadence (1..8). An EMA of complete-pair
host cost is compared with 80% of that cadence, using the runtime's live NTSC/PAL
period, unless PSX_STEREO_SHED_BUDGET_MS supplies the real headset deadline (the
VBlank cadence is a guest-budget fallback, not an OpenXR display deadline).
Expensive work is shed as a complete pair, never as a single eye, and the previous
complete pair is retained. Shedding is bounded both by wall clock
(PSX_STEREO_SHED_PROBE_MS, default 250 ms) and by a consecutive count
(PSX_STEREO_SHED_MAX_STREAK, default 8), so a stale estimate cannot suppress
indefinitely: whichever bound trips first forces a probe, and the probe's
measurement replaces the estimate rather than blending into it, so a single spike
ends after one bounded streak. Recovery uses a hysteresis band
(PSX_STEREO_SHED_HYSTERESIS, default 0.9) and scene/mode transitions reset the
estimate. PSX_STEREO_SHED=0 disables shedding entirely for a baseline
diagnostic. Allocating or slow initial pairs may exceed the budget; completed
guest work cannot be preempted by a host-time budget. Guest-cycle watchdogs remain
the runaway bound.
OpenGL GPU-authoritative 15-bit rendering is required. CPU-authoritative dual raster, windowed high-resolution tiles, depth24 and native-wide rendering are initially unsupported. Session/resimulation/rewind/fast-forward and nesting gates remain. Faithful defaults do not call this API or enable stereo presentation.
psx_mod_set_stereo_presentation(1) opts into side-by-side output: one atomic pair
per swap, LEFT on the left, RIGHT on the right, with each eye using the configured
display aspect. Zero disables it. The first implementation uses ordinary game
VBlank presentation cadence; it does not submit to OpenXR or create headset poses.
Stereo and temporal interpolation should be configured separately; SBS takes
precedence when a compatible complete pair is available. A pair is shown only while
the plugin keeps submitting: it expires 24 VBlanks of guest time after its capture
cycle (3x the slowest 8-VBlank cadence) and the presenter falls back to the normal
flat image (menus, same-size FMV, a plugin that stopped submitting). Savestate/rollback loads and
session resets drop the pair immediately.
Inside an eye callback, psx_mod_render_view_offset(x,y,z) replaces a scoped
camera-space translation. RTPS and RTPT add it alongside TR before projection;
the guest translation registers remain unchanged. Repeated transformations do
not accumulate the offset. Normal returns and watchdog rollback restore the
previous host value. Calls outside a render transaction are refused. The GTE
test compares this seam against explicit pre-divide translation and measures
12 pixels at Z=800 versus 3 pixels at Z=3200 for X=24 and H=400.
psx_mod_render_view (full pose) is an explicit per-eye projection. With
projection=1 it REPLACES the guest X/Y projection entirely: guest H, the
widescreen/dome squash and the [video] fov_scale scaled-H do not feed it, and the
guest-formula PGXP shadows are skipped for those vertices. The only coupling is
projection_h_ref: when non-zero the focal lengths are multiplied by (fov-scaled
guest H) / projection_h_ref, so a plugin can follow per-draw H changes. SZ, the
depth cue and divide flags still come from the guest division. The pose exists only
inside a render transaction and is restored with it, so nothing outside the sandbox
sees it. test_render_pose covers the override together with fov_scale.
TCP inspection:
stereo_stats: pair outcomes, eye entry cycles/state hashes under VERIFY, sampled view offsets, latched failed attempt/eye/retained pair, published pair metadata, and the shedding policy (shedding,shed_streak,shed_streak_ms,shed_streak_max,stale_frames,budget_resets,last_pair_age_ms).render_pass_statscontinues to expose shared transaction checks, aborts, leaks and dropped stores; its eye transactions also increment pass counters.stereo_dump path=<absolute-directory> count=N: arm dumps of the next N complete published pairs, with left/right PNGs, a side-by-side composite sharing one pair ID. Pair/cycle/view metadata is inspected through TCPstereo_stats.captures, retained for the current capture request (maximum 100 records); the runtime writes no diagnostic JSON files. A capture client can usetools/stereo_capture.py::write_capture_manifeststo write manifests after confirming all three PNGs for each returned ID. Arming another capture or resetting the session clears the metadata. It captures no scene on its own. Failed staging eyes never appear as published pairs.
Validation, source revisions and live limitations are recorded in the feature provenance record. The game's VR execution plan records historical alpha evidence. Projection scale and eye separation remain game-specific and must be calibrated independently. This API also works without a headset backend; it does not enable a headset mode by default.
The optional headset backend is described in OPENXR_RENDERING.md.
Production startup and performance receipts
PSX_OPENXR_READY_FILE=<fresh-path> requests one JSON startup receipt, including
in builds with PSX_DEBUG_TOOLS=OFF. Main publishes through a same-directory
temporary and atomic replacement, so partial JSON is never visible. An existing
destination is refused at startup; the caller owns a fresh path and removes it
afterward. Schema 2 records XR session generations, counters and the savestate
receipt. When PSX_LOAD_SLOT is set, success requires that load to settle
successfully and a later submission from the current XR session. A failed load
publishes ready:false with state_load_failed; session re-init publishes a
new receipt after its first current-session submission. An invalid, refused, or
netplay-disabled load request also publishes ready:false with a specific error
instead of being treated as if no load were requested. A game-session reboot
starts a fresh load lifecycle, so an earlier success cannot authorize the new
session; the newly staged PSX_LOAD_SLOT must settle before readiness can be
published. Without the variable the reporter immediately becomes a no-op.
PSX_RUNTIME_PERF_DIAG=1 also prints VR pair/shed/empty/failure totals, the pair
EMA/last milliseconds and rollback verification totals at its existing configured
interval (PSX_RUNTIME_PERF_DIAG_MS). This exposes production behavior without
per-instruction debug tracing. The pair average is an EMA, not a percentile.
PSX_FRAME_REPORT_MS remains the production guest-frame cadence measurement.