Bound stereo shedding and recover at the display deadline

Co-authored-by: Yves <calibratedbeats@gmail.com>
This commit is contained in:
Yves
2026-10-23 03:24:01 +08:00
parent 8400b55dc0
commit 0cdf95957c
13 changed files with 660 additions and 21 deletions
+16 -5
View File
@@ -16,9 +16,18 @@ 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. Expensive work is shed as a complete pair; every thirtieth request retries
to recover a stale estimate. This is a conservative cadence budget, not a measured
headset idle budget. Allocating or slow initial pairs may exceed it; completed
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.
@@ -59,8 +68,10 @@ 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, and published
pair metadata. `render_pass_stats` continues to
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_stats` continues 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
@@ -0,0 +1,17 @@
# Bound stereo shedding and recover at the display deadline
Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp).
- [cae62891](https://github.com/FractalEngineer/psxrecomp/commit/cae62891e9236e9252e43b80aa9dd47f7554aea4)
- [b6a671c1](https://github.com/FractalEngineer/psxrecomp/commit/b6a671c1db4ba15d02369ce5561754d485881a55)
Original author: Yves <calibratedbeats@gmail.com>; retained as commit author
and Co-authored-by on this reconstructed port.
Adapted: this feature and its tests. Excluded: other VR features, game policy,
assets and chronological investigation notes.
Base: `8400b55dc02d3fa787df1280a9fb474901adfcf4` (current upstream master or the preceding feature).
Validation: Windows/GCC 13.2 Release: policy, sandbox and abort tests pass (3/3). Sustained overload remains expensive; the MoH launcher disables shedding by default.
Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace).
Requires a framework pin bump and rebuild; no game/BIOS regeneration.
+8
View File
@@ -1634,6 +1634,12 @@ if(BUILD_TESTING)
add_executable(frame_trace_test tests/test_frame_trace.c src/frame_trace.c)
target_include_directories(frame_trace_test PRIVATE include)
add_test(NAME frame_trace_test COMMAND frame_trace_test)
# Stereo shedding/recovery policy (docs/STEREO_RENDERING.md): bounded streaks,
# authoritative probes, hysteresis, pair age separate from the estimate.
add_executable(stereo_shed_policy_test tests/test_stereo_shed_policy.c
src/stereo_shed_policy.c)
target_include_directories(stereo_shed_policy_test PRIVATE include)
add_test(NAME stereo_shed_policy_test COMMAND stereo_shed_policy_test)
# Render thread core ([video] render_thread, docs/RENDER_THREAD.md):
# ordering across ring wraps, the frame bound, stale-frame detection and
# the drain + context hand-off sync point, against a fake executor.
@@ -1780,6 +1786,7 @@ if(BUILD_TESTING)
tests/test_render_pass_abort.c
src/render_pass.c
src/frame_trace.c
src/stereo_shed_policy.c
src/psx_cycles.c)
target_include_directories(render_pass_abort_test PRIVATE include)
add_test(NAME render_pass_abort_test COMMAND render_pass_abort_test)
@@ -1792,6 +1799,7 @@ if(BUILD_TESTING)
tests/test_render_pass_sandbox.c
src/render_pass.c
src/frame_trace.c
src/stereo_shed_policy.c
src/render_pass_plan.c
src/psx_cycles.c
src/timers.c
+10
View File
@@ -752,6 +752,16 @@ void psx_mod_frame_trace_note(const char *tag);
* inside the guest traversal it is part of (game_select in game_traverse). */
void psx_mod_frame_trace_stage_begin(uint32_t stage);
void psx_mod_frame_trace_stage_end(uint32_t stage);
/* Drop the stereo shedding policy's pair-time estimate and derived budget. Call
* on a scene/mode transition so a new scene does not inherit the previous one's
* timing; cheap and idempotent. Shedding itself is unchanged. */
void psx_mod_stereo_reset_budget(void);
/* Set the stereo pair budget from a real headset deadline in milliseconds,
* pushed by the OpenXR layer from the target display period (11.1 ms at 90 Hz,
* 8.3 ms at 120 Hz). 0 clears it. The guest-VBlank-derived value is only a
* fallback: a PSX redraw budget is not an OpenXR display deadline, and the two
* disagree on every non-60 Hz headset. PSX_STEREO_SHED_BUDGET_MS still wins. */
void psx_mod_stereo_set_budget_ms(double ms);
/* 0 disables stereo output; 1 shows a complete pair side by side. Capture
* itself does not enable presentation. Default 0; applies to this session. */
int psx_mod_set_stereo_presentation(uint32_t mode);
+11
View File
@@ -91,6 +91,17 @@ typedef struct RenderStereoStats {
uint64_t last_failed_attempt, retained_pair_id;
int last_failed_eye;
const char *last_failed_reason; /* latched across later successes */
/* Shedding/recovery policy (stereo_shed_policy.c). A shed streak is bounded
* by time and count; these report how the last one ended and how stale the
* retained pair got. */
uint64_t shed_streak, shed_streak_max; /* consecutive sheds now / worst seen */
uint32_t shed_streak_ms; /* wall clock of the current streak */
uint64_t stale_frames; /* frames that published no new pair */
uint64_t budget_resets; /* estimates reset on scene/mode change */
double budget_ms; /* pair budget actually in use */
uint32_t budget_source; /* 0 vblank (fallback), 1 headset, 2 env */
uint64_t last_pair_age_ms; /* age of the retained pair, this frame */
int shedding; /* policy is currently suppressing */
} RenderStereoStats;
void render_stereo_get_stats(RenderStereoStats *out);
int render_pass_netplay_enabled(void);
+95
View File
@@ -0,0 +1,95 @@
#ifndef PSX_STEREO_SHED_POLICY_H
#define PSX_STEREO_SHED_POLICY_H
/* Whole-pair stereo shedding policy, extracted so it can be reasoned about and
* tested without a GPU, a headset or a guest.
*
* WHY THIS EXISTS
* ---------------
* The original rule was `avg_pair_ms > budget_ms && ++retry < 30` — an
* attempt counter with no notion of time. One short overload latched the
* average above budget and then silently suppressed the next 29 pair requests:
* a single heavy frame produced a long run of stale frames. There was also no
* way to say "stop shedding and just measure", and no bound on how long
* suppression could last.
*
* THIS POLICY
* -----------
* * Enters shedding when the pair-time EMA is over budget, and leaves it only
* when a fresh measurement is clearly under `hysteresis * budget` (so noise
* at the boundary does not flap).
* * While shedding, suppression lasts at most `probe_ms` of wall clock and at
* most `max_streak` consecutive sheds, whichever comes first. Whichever way
* it ends, the next frame is rendered, which takes a fresh measurement.
* A stale average can therefore never suppress indefinitely: recovery is
* bounded in TIME, not in attempt count.
* * A forced probe is authoritative: it REPLACES the running estimate rather
* than blending into it. That is what makes a one-off spike cheap — one
* good probe ends the streak, instead of the EMA having to decay over
* several probes while the headset keeps showing stale pairs.
* * `enabled = 0` disables shedding entirely (baseline diagnostic: measure
* what happens with no suppression at all).
*
* The policy decides only whether to skip a pair. It never decides which eyes
* to publish; the caller keeps whole-pair publish (a stale frame retains the
* previous complete pair, never an unmatched eye).
*
* All time is passed in by the caller (host monotonic ms), so there is no clock
* dependency here and the interesting cases are unit-testable.
*/
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct StereoShedPolicy {
/* configuration */
int enabled; /* 0 = never shed (diagnostic) */
double budget_ms; /* per-pair budget; set each frame from the cadence */
double probe_ms; /* bound on one shedding streak (wall clock) */
uint32_t max_streak; /* bound on one shedding streak (count) */
double hysteresis; /* resume threshold as a fraction of budget (0..1) */
/* state */
double avg_ms; /* EMA of measured pair time */
int shedding; /* currently suppressing */
int probing; /* the last decision forced a probe; the next
* measurement is authoritative */
uint32_t streak; /* consecutive sheds in this streak */
uint64_t streak_start_ms;/* when this streak began */
uint64_t last_pair_ms; /* when the last pair was published */
int have_pair;
uint64_t pairs_seen; /* measurements folded into the average */
uint64_t shed_total; /* pairs skipped since reset */
} StereoShedPolicy;
void stereo_shed_policy_init(StereoShedPolicy *p);
/* Reads PSX_STEREO_SHED (0 disables), PSX_STEREO_SHED_PROBE_MS,
* PSX_STEREO_SHED_MAX_STREAK and PSX_STEREO_SHED_HYSTERESIS, clamping each to a
* safe range. Called once per session. */
void stereo_shed_policy_configure_from_env(StereoShedPolicy *p);
void stereo_shed_policy_set_budget(StereoShedPolicy *p, double budget_ms);
/* Clears the timing estimate and streak but keeps configuration. Call on a
* scene/mode transition: a new scene must not inherit the previous one's
* estimate, and a cadence change invalidates the budget. */
void stereo_shed_policy_reset(StereoShedPolicy *p);
/* 1 = skip this frame's pair, 0 = render it. */
int stereo_shed_policy_should_shed(StereoShedPolicy *p, uint64_t now_ms);
/* Fold a measured pair attempt into the estimate. `ok` = a complete pair was
* published (so the retained pair is now fresh). */
void stereo_shed_policy_note_pair(StereoShedPolicy *p, double pair_ms,
uint64_t now_ms, int ok);
uint32_t stereo_shed_policy_streak_sheds(const StereoShedPolicy *p);
uint32_t stereo_shed_policy_streak_ms(const StereoShedPolicy *p, uint64_t now_ms);
double stereo_shed_policy_avg_ms(const StereoShedPolicy *p);
/* Age of the retained pair, or 0 when no pair has been published yet. */
uint64_t stereo_shed_policy_pair_age_ms(const StereoShedPolicy *p, uint64_t now_ms);
#ifdef __cplusplus
}
#endif
#endif /* PSX_STEREO_SHED_POLICY_H */
+1
View File
@@ -487,6 +487,7 @@ set(PSXRECOMP_RUNTIME_SOURCES
${PSXRECOMP_ROOT}/runtime/src/guest_cycle_profile.c
${PSXRECOMP_ROOT}/runtime/src/latency_ring.c
${PSXRECOMP_ROOT}/runtime/src/frame_trace.c
${PSXRECOMP_ROOT}/runtime/src/stereo_shed_policy.c
${PSXRECOMP_ROOT}/runtime/src/present_image_ring.c
${PSXRECOMP_ROOT}/runtime/src/gpu_timeline.c
${PSXRECOMP_ROOT}/runtime/src/data_shards.c
+8
View File
@@ -9292,6 +9292,10 @@ static void handle_stereo_stats(int id, const char *json) {
"\"last_eye\":%d,\"last_failure\":\"%s\","
"\"last_failed_attempt\":%llu,\"last_failed_eye\":%d,"
"\"last_failed_reason\":\"%s\",\"retained_pair_id\":%llu,"
"\"shedding\":%d,\"shed_streak\":%llu,\"shed_streak_ms\":%u,"
"\"shed_streak_max\":%llu,\"stale_frames\":%llu,\"budget_resets\":%llu,"
"\"budget_ms\":%.3f,\"budget_source\":%u,"
"\"last_pair_age_ms\":%llu,"
"\"last_pair_ms\":%.3f,\"avg_pair_ms\":%.3f,"
"\"published\":{\"valid\":%u,\"pair_id\":%llu,\"guest_cycle\":%llu,"
"\"width\":%u,\"height\":%u,\"staged_mask\":%u,\"mode\":%u,\"presents\":%llu},\"captures\":%s}",
@@ -9307,6 +9311,10 @@ static void handle_stereo_stats(int id, const char *json) {
(unsigned long long)s.last_failed_attempt, s.last_failed_eye,
s.last_failed_reason ? s.last_failed_reason : "",
(unsigned long long)s.retained_pair_id,
s.shedding, (unsigned long long)s.shed_streak, s.shed_streak_ms,
(unsigned long long)s.shed_streak_max, (unsigned long long)s.stale_frames,
(unsigned long long)s.budget_resets, s.budget_ms, s.budget_source,
(unsigned long long)s.last_pair_age_ms,
s.last_pair_ms, s.avg_pair_ms, g.valid, (unsigned long long)g.pair_id,
(unsigned long long)g.guest_cycle, g.width, g.height, g.staged_mask,
g.mode, (unsigned long long)g.presents, captures);
+10
View File
@@ -581,6 +581,10 @@ int psx_openxr_begin(int width,int height,double units) {
XrFrameWaitInfo wait={XR_TYPE_FRAME_WAIT_INFO};XrFrameState fs={XR_TYPE_FRAME_STATE};
if(!check(xrWaitFrame(s_session,&wait,&fs),"wait_frame")){
psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
/* Session-level submission failure: the stereo cadence context is gone,
* so drop any stale pair-time estimate rather than carrying it into the
* recovery attempt. */
psx_mod_stereo_reset_budget();
(void)service_loss();psx_frame_trace_commit();return 0;}
psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
s_stats.waits++;s_time=fs.predictedDisplayTime;s_stats.predicted_time=(uint64_t)s_time;
@@ -594,12 +598,18 @@ int psx_openxr_begin(int width,int height,double units) {
}
s_prev_display_time=s_time;s_prev_display_valid=1;
/* Record the runtime display period with its name for cadence analysis. */
/* What the runtime wants us to hit is the only true deadline for the pair.
* Hand it to stereo shedding (PSX VBlank is a guest redraw budget, not a
* compositor deadline) and record it with the runtime name so a trace can be
* read without guessing the headset's refresh. */
psx_mod_stereo_set_budget_ms(fs.predictedDisplayPeriod>0?(double)fs.predictedDisplayPeriod/1e6:0.0);
psx_frame_trace_session(s_stats.runtime,
fs.predictedDisplayPeriod>0?(uint32_t)(fs.predictedDisplayPeriod/1000):0u);
trace_t = gl_renderer_perf_ticks();
XrFrameBeginInfo begin={XR_TYPE_FRAME_BEGIN_INFO};
if(!check(xrBeginFrame(s_session,&begin),"begin_frame")){
psx_frame_trace_add(PSX_TRACE_XR_BEGIN_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
psx_mod_stereo_reset_budget();
(void)service_loss();psx_frame_trace_commit();return 0;}
psx_frame_trace_add(PSX_TRACE_XR_BEGIN_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t));
s_stats.frame_open=1;s_stats.tracking=0;
+112 -16
View File
@@ -38,6 +38,7 @@
#include "timers.h"
#include "psx_video_timing.h"
#include "gte_view.h"
#include "stereo_shed_policy.h"
extern uint8_t *memory_get_ram_ptr(void);
extern uint8_t *memory_get_scratchpad_ptr(void);
@@ -283,7 +284,16 @@ static RenderPassFailure s_attempt;
static const char *s_checkpoint_failure;
static RenderStereoStats s_stereo;
static int s_pair_busy;
static uint32_t s_pair_retry;
/* Whole-pair shedding/recovery policy (bounded in time and count; see
* stereo_shed_policy.c). Replaces the old attempt counter, which could suppress
* many pairs after a single over-budget frame. */
static StereoShedPolicy s_shed;
/* Headset deadline pushed by the OpenXR layer (target display period, ms).
* 0 = no headset: fall back to the guest-VBlank redraw budget. */
static double s_shed_xr_budget_ms;
static uint32_t s_shed_period;
static int32_t s_shed_w, s_shed_h;
static int s_shed_geom_valid;
/* A HUD-only capture transaction reuses eye 0's slot but is not an eye draw;
* frame tracing must not attribute its cost to the left eye. */
static int s_capture_only;
@@ -322,6 +332,32 @@ static int verify_on(void) {
return s_verify;
}
/* PSX_STEREO_SHED_BUDGET_MS, when set, replaces the guest-VBlank-derived budget
* with a real headset deadline (e.g. 11.1 at 90 Hz, 8.3 at 120 Hz). The VBlank
* cadence is only a fallback: it is a guest redraw budget, not an OpenXR
* display deadline, and conflating the two is wrong on every non-60 Hz headset.
* Returns 0 when unset so the caller derives the fallback. */
static double shed_budget_override_ms(void) {
static double v = -1.0;
if (v < 0.0) {
const char *e = getenv("PSX_STEREO_SHED_BUDGET_MS");
double d = e ? atof(e) : 0.0;
v = (d > 0.0 && d < 1000.0) ? d : 0.0;
}
return v;
}
/* The pair budget actually used, in priority order: the explicit env override,
* the headset display deadline the OpenXR layer pushed, then the guest-VBlank
* redraw budget as a last resort. */
static double shed_budget_ms(uint32_t period_vblanks, uint32_t *source) {
double ov = shed_budget_override_ms();
if (ov > 0.0) { if (source) *source = 2; return ov; }
if (s_shed_xr_budget_ms > 0.0) { if (source) *source = 1; return s_shed_xr_budget_ms; }
if (source) *source = 0;
return period_vblanks * (double)g_psx_vblank_cycles / 33868.8 * 0.8;
}
void render_pass_get_stats(RenderPassStats *out) {
if (!out) return;
*out = s_stats;
@@ -342,7 +378,12 @@ void render_pass_reset_session(void) {
memset(&s_stereo, 0, sizeof s_stereo);
s_stereo.last_eye = -1;
s_pair_busy = 0;
s_pair_retry = 0;
/* Shedding policy: defaults, then env overrides (PSX_STEREO_SHED=0 gives the
* baseline diagnostic with shedding fully disabled). */
stereo_shed_policy_init(&s_shed);
stereo_shed_policy_configure_from_env(&s_shed);
stereo_shed_policy_reset(&s_shed);
s_shed_geom_valid = 0;
/* Frame tracing is opt-in per session: re-read the env, install the host
* clock, and clear any previous session's records. */
psx_frame_trace_configure_from_env();
@@ -1029,27 +1070,55 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame,
* previous traced frame. */
int trace_on = psx_frame_trace_enabled();
int trace_opened = trace_on ? psx_frame_trace_open() : 0;
uint64_t now_ms = psx_host_mono_ms();
if (trace_on) {
static uint64_t prev_ms;
static int prev_valid;
uint64_t now_ms = psx_host_mono_ms();
if (prev_valid)
psx_frame_trace_add(PSX_TRACE_GUEST_SIM, (now_ms - prev_ms) * 1000u);
prev_ms = now_ms; prev_valid = 1;
psx_frame_trace_attempt(s_stereo.attempts, 0, 0);
}
budget_ms = frame->period_vblanks * (double)g_psx_vblank_cycles /
33868.8 * 0.8;
if (s_stereo.avg_pair_ms > budget_ms && ++s_pair_retry < 30u) {
s_stereo.shed++;
if (trace_on) {
psx_frame_trace_attempt(s_stereo.attempts, s_pair_retry, 0);
psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR);
if (trace_opened) psx_frame_trace_commit();
}
return 0;
budget_ms = shed_budget_ms(frame->period_vblanks, &s_stereo.budget_source);
s_stereo.budget_ms = budget_ms;
/* A scene/mode transition changes cadence and workload, so the previous
* average and budget do not carry over. */
if (!s_shed_geom_valid || frame->period_vblanks != s_shed_period ||
(int32_t)frame->w != s_shed_w || (int32_t)frame->h != s_shed_h) {
if (s_shed_geom_valid) s_stereo.budget_resets++;
s_shed_period = frame->period_vblanks;
s_shed_w = (int32_t)frame->w; s_shed_h = (int32_t)frame->h;
s_shed_geom_valid = 1;
stereo_shed_policy_reset(&s_shed);
}
s_pair_retry = 0;
stereo_shed_policy_set_budget(&s_shed, budget_ms);
{
int was_shedding = s_shed.shedding;
if (stereo_shed_policy_should_shed(&s_shed, now_ms)) {
s_stereo.shed++;
s_stereo.stale_frames++;
s_stereo.shed_streak = stereo_shed_policy_streak_sheds(&s_shed);
if (s_stereo.shed_streak > s_stereo.shed_streak_max)
s_stereo.shed_streak_max = s_stereo.shed_streak;
s_stereo.shed_streak_ms = stereo_shed_policy_streak_ms(&s_shed, now_ms);
s_stereo.shedding = 1;
if (trace_on) {
psx_frame_trace_attempt(s_stereo.attempts, s_stereo.shed_streak,
s_stereo.shed_streak_ms);
psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR | PSX_FRAME_TRACE_SHED);
if (trace_opened) psx_frame_trace_commit();
}
return 0;
}
/* Log once per streak (not per frame), with both the skipped count and
* the elapsed wall clock the old attempt counter never tracked. */
if (was_shedding)
fprintf(stderr, "psxrecomp: stereo shed streak ended after %llu "
"skipped, %u ms\n",
(unsigned long long)s_stereo.shed_streak,
s_stereo.shed_streak_ms);
}
s_stereo.shedding = s_shed.shedding;
if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles();
cycle = psx_cycle_count; stores = g_guest_store_count;
mismatches = s_stats.verify_mismatch; aborted = s_stats.aborted;
@@ -1085,8 +1154,6 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame,
elapsed = (double)(gl_renderer_perf_ticks() - t0) * 1000.0 /
(double)gl_renderer_perf_frequency();
s_stereo.last_pair_ms = elapsed;
s_stereo.avg_pair_ms = s_stereo.avg_pair_ms > 0.0
? s_stereo.avg_pair_ms * 0.9 + elapsed * 0.1 : elapsed;
if (ok && !gl_renderer_stereo_publish(s_stereo.attempts, cycle, s_stereo.eye_view)) {
ok = 0; s_stereo.last_failure = "pair_publish";
}
@@ -1102,6 +1169,17 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame,
s_stereo.retained_pair_id = s_stereo.last_pair_id;
gl_renderer_stereo_stage_reset();
}
/* Fold the attempt into the policy's estimate (it owns the EMA now). A
* failed or aborted attempt still counts as a measurement, so a stale high
* average cannot outlive the condition that produced it; only a published
* pair refreshes the retained-pair age. Pair age is tracked separately from
* the compute-time estimate so "how old is the picture" never hides behind
* "how fast was the last attempt". */
stereo_shed_policy_note_pair(&s_shed, elapsed, now_ms, ok);
s_stereo.avg_pair_ms = stereo_shed_policy_avg_ms(&s_shed);
s_stereo.last_pair_age_ms = stereo_shed_policy_pair_age_ms(&s_shed, now_ms);
s_stereo.shedding = s_shed.shedding;
if (!ok) s_stereo.stale_frames++;
if (trace_on) {
psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, (uint64_t)(elapsed * 1000.0));
psx_frame_trace_result(ok, ok ? s_stereo.last_pair_id : 0,
@@ -1113,6 +1191,24 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame,
return ok;
}
/* Game hook: a scene or mode transition invalidates the pair-time estimate and
* the derived budget, so the new scene does not inherit the old one's. Cheap
* and idempotent; safe to call on every transition. */
void psx_mod_stereo_reset_budget(void) {
stereo_shed_policy_reset(&s_shed);
s_shed_geom_valid = 0;
/* The deadline came from a session that is now gone or whose cadence just
* changed; the OpenXR layer re-pushes it on the next frame it runs. */
s_shed_xr_budget_ms = 0.0;
s_stereo.budget_resets++;
}
void psx_mod_stereo_set_budget_ms(double ms) {
/* Push the headset deadline. Ignore nonsense rather than letting a bad
* value widen or collapse the budget. */
s_shed_xr_budget_ms = (ms > 0.0 && ms < 1000.0) ? ms : 0.0;
}
int psx_mod_render_capture_rgba(CPUState *cpu,const PSXModRenderPass *rect,
PSXModRenderPassFn fn,void *user,
uint8_t *rgba,uint32_t bytes) {
+145
View File
@@ -0,0 +1,145 @@
/* stereo_shed_policy.c — bounded whole-pair shedding. See stereo_shed_policy.h. */
#include "stereo_shed_policy.h"
#include <stdlib.h>
#include <string.h>
#define SHED_EMA_ALPHA 0.1
#define SHED_DEFAULT_PROBE_MS 250.0
#define SHED_DEFAULT_MAX_STREAK 8u
#define SHED_DEFAULT_HYSTERESIS 0.9
static double clamp_double(double v, double lo, double hi) {
if (!(v == v)) return lo; /* NaN */
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
void stereo_shed_policy_init(StereoShedPolicy *p) {
if (!p) return;
memset(p, 0, sizeof *p);
p->enabled = 1;
p->probe_ms = SHED_DEFAULT_PROBE_MS;
p->max_streak = SHED_DEFAULT_MAX_STREAK;
p->hysteresis = SHED_DEFAULT_HYSTERESIS;
}
void stereo_shed_policy_configure_from_env(StereoShedPolicy *p) {
const char *e;
if (!p) return;
e = getenv("PSX_STEREO_SHED");
if (e) p->enabled = (e[0] && e[0] != '0') ? 1 : 0;
e = getenv("PSX_STEREO_SHED_PROBE_MS");
if (e) p->probe_ms = clamp_double(strtod(e, NULL), 10.0, 5000.0);
e = getenv("PSX_STEREO_SHED_MAX_STREAK");
if (e) {
long v = strtol(e, NULL, 0);
p->max_streak = (uint32_t)(v < 1 ? 1 : (v > 240 ? 240 : v));
}
e = getenv("PSX_STEREO_SHED_HYSTERESIS");
if (e) p->hysteresis = clamp_double(strtod(e, NULL), 0.5, 1.0);
}
void stereo_shed_policy_set_budget(StereoShedPolicy *p, double budget_ms) {
if (!p) return;
p->budget_ms = budget_ms > 0.0 ? budget_ms : 0.0;
}
void stereo_shed_policy_reset(StereoShedPolicy *p) {
if (!p) return;
p->avg_ms = 0.0;
p->shedding = 0;
p->probing = 0;
p->streak = 0;
p->streak_start_ms = 0;
p->pairs_seen = 0;
}
int stereo_shed_policy_should_shed(StereoShedPolicy *p, uint64_t now_ms) {
if (!p || !p->enabled) return 0;
if (p->avg_ms > p->budget_ms) {
if (!p->shedding) { /* enter shedding at the first over-budget sample */
p->shedding = 1;
p->streak = 0;
p->streak_start_ms = now_ms;
}
} else if (p->avg_ms < p->budget_ms * p->hysteresis) {
p->shedding = 0; /* clearly recovered */
p->streak = 0;
} else {
/* hysteresis band: hold whatever state we are in */
}
if (!p->shedding) return 0;
/* Bounded recovery: neither an elapsed-time nor an attempt-count rule may
* suppress indefinitely. Whichever bound trips first forces a probe, whose
* result note_pair() treats as authoritative. The streak's timer restarts
* here so a persistent overload probes again after the same bound rather
* than every frame. */
uint64_t elapsed = now_ms >= p->streak_start_ms ? now_ms - p->streak_start_ms : 0;
if (p->streak >= p->max_streak || elapsed >= (uint64_t)p->probe_ms) {
p->probing = 1;
p->streak = 0;
p->streak_start_ms = now_ms;
return 0; /* render this one: take a fresh sample */
}
p->streak++;
p->shed_total++;
return 1;
}
void stereo_shed_policy_note_pair(StereoShedPolicy *p, double pair_ms,
uint64_t now_ms, int ok) {
if (!p) return;
if (pair_ms < 0.0) pair_ms = 0.0;
if (p->probing) {
/* A forced probe is a deliberate fresh measurement: use it directly
* instead of blending it away. Otherwise one spike would keep the
* estimate high for many probes, and the headset would keep showing
* stale pairs long after the spike ended. */
p->probing = 0;
p->avg_ms = pair_ms;
/* Exit on the deadline itself, not on the hysteresis threshold. A pair
* time that settles between the two (here 10.0 to 11.111 ms) would
* otherwise sit in the band forever: the band holds whatever state it
* is in, so a streak entered during a heavy moment never ends and
* shedding starves rendering while the pair is in fact meeting its
* deadline. A latched streak is bounded by the probe interval, which is
* still the recovery guarantee this policy promises. */
if (pair_ms <= p->budget_ms) {
p->shedding = 0; /* the overload is over */
p->streak = 0;
}
} else {
p->avg_ms = p->pairs_seen ? p->avg_ms * (1.0 - SHED_EMA_ALPHA) + pair_ms * SHED_EMA_ALPHA
: pair_ms;
}
p->pairs_seen++;
if (ok) {
p->last_pair_ms = now_ms;
p->have_pair = 1;
}
}
uint32_t stereo_shed_policy_streak_sheds(const StereoShedPolicy *p) {
return p ? p->streak : 0;
}
uint32_t stereo_shed_policy_streak_ms(const StereoShedPolicy *p, uint64_t now_ms) {
if (!p || !p->shedding || now_ms < p->streak_start_ms) return 0;
uint64_t d = now_ms - p->streak_start_ms;
return d > 0xFFFFFFFFu ? 0xFFFFFFFFu : (uint32_t)d;
}
double stereo_shed_policy_avg_ms(const StereoShedPolicy *p) {
return p ? p->avg_ms : 0.0;
}
uint64_t stereo_shed_policy_pair_age_ms(const StereoShedPolicy *p, uint64_t now_ms) {
if (!p || !p->have_pair || now_ms < p->last_pair_ms) return 0;
return now_ms - p->last_pair_ms;
}
+17
View File
@@ -780,6 +780,23 @@ static void test_stereo(void) {
f.period_vblanks = 0;
CHECK(!psx_mod_render_stereo(&cpu, &f, pair_draw, NULL), "invalid cadence refused");
/* The headset deadline the OpenXR layer pushes outranks the guest-VBlank
* fallback, and dropping it restores the fallback. */
f.period_vblanks = 2;
psx_mod_stereo_reset_budget();
psx_mod_stereo_set_budget_ms(11.1);
CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1,
"pair renders under a headset deadline");
render_stereo_get_stats(&stats);
CHECK(stats.budget_source == 1 && stats.budget_ms > 11.0 && stats.budget_ms < 11.2,
"headset deadline drives the pair budget");
psx_mod_stereo_reset_budget();
CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1,
"pair renders after the deadline is dropped");
render_stereo_get_stats(&stats);
CHECK(stats.budget_source == 0 && stats.budget_ms > 0.0,
"guest-VBlank fallback restored when no headset deadline is set");
/* Tracing is observation only: the same pair must publish the same id and
* reach the same guest-state hashes with capture on as with it off. */
{
+210
View File
@@ -0,0 +1,210 @@
/* test_stereo_shed_policy.c — PR2 shedding/recovery policy.
*
* Covers the plan's PR2 test list at the policy level: short spike, persistent
* overload, alternating load (hysteresis), stale estimate, no unbounded streak,
* transient measurement gaps, and the disable switch. Both-eye integrity and
* guest-cycle accounting are structural (the policy has no eye or guest state);
* the render-pass side already asserts eye-cycle equality.
*/
#include "stereo_shed_policy.h"
#include <stdio.h>
#include <string.h>
static int g_fail;
#define CHECK(cond) do { \
if (!(cond)) { printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); g_fail = 1; } \
} while (0)
/* Drive `budget`/`probe`/`streak` config the way a session would, without env. */
static void configure(StereoShedPolicy *p, double avg_ms, uint64_t probe_ms,
uint32_t max_streak, double hysteresis) {
stereo_shed_policy_init(p);
p->budget_ms = 16.0;
p->probe_ms = (double)probe_ms;
p->max_streak = max_streak;
p->hysteresis = hysteresis;
if (avg_ms > 0.0) { p->avg_ms = avg_ms; p->pairs_seen = 1; }
}
/* 1: disabled means never shed, whatever the timing. */
static void test_disabled(void) {
StereoShedPolicy p;
configure(&p, 40.0, 250, 8, 0.9);
p.enabled = 0;
for (uint64_t t = 0; t < 2000; t += 8)
CHECK(stereo_shed_policy_should_shed(&p, t) == 0);
}
/* 2: a single spike sheds, but the streak is bounded and one good probe ends it
* (the probe replaces the estimate instead of decaying into it). */
static void test_short_spike_bounded(void) {
StereoShedPolicy p;
uint32_t sheds = 0;
configure(&p, 0.0, 250, 8, 0.9);
uint64_t t = 0;
for (int i = 0; i < 20; i++) { /* establish a fast baseline (~12 ms) */
stereo_shed_policy_note_pair(&p, 12.0, t, 1);
t += 16;
CHECK(stereo_shed_policy_should_shed(&p, t) == 0);
}
stereo_shed_policy_note_pair(&p, 80.0, t, 1); /* one heavy frame */
for (int i = 0; i < 200; i++) {
if (stereo_shed_policy_should_shed(&p, t)) { sheds++; t += 16; continue; }
stereo_shed_policy_note_pair(&p, 12.0, t, 1); /* the probe measures fast again */
t += 16;
}
CHECK(sheds > 0); /* the spike did suppress something */
CHECK(sheds <= 8u); /* ...but never more than one bounded streak */
CHECK(stereo_shed_policy_avg_ms(&p) < 16.0); /* and recovery was immediate */
}
/* 3: sustained overload keeps shedding but must still probe periodically (so a
* stale average can never suppress forever), and each streak stays bounded. */
static void test_persistent_overload_probes(void) {
StereoShedPolicy p;
uint32_t sheds = 0, attempts = 0;
uint32_t run = 0, max_run = 0;
configure(&p, 30.0, 250, 8, 0.9);
uint64_t t = 0;
for (int i = 0; i < 500; i++) { /* 500 * 8 ms = 4 s of sustained overload */
if (stereo_shed_policy_should_shed(&p, t)) { sheds++; run++; }
else { attempts++; if (run > max_run) max_run = run; run = 0;
stereo_shed_policy_note_pair(&p, 30.0, t, 1); } /* still slow */
t += 8;
}
CHECK(sheds > 0);
CHECK(attempts > 0); /* overload does not suppress everything */
CHECK(max_run <= 8u); /* every streak bounded by max_streak */
CHECK(sheds <= 500u - attempts);
}
/* 4: alternating load must not flap: a value inside the hysteresis band holds
* the current state instead of toggling every frame. */
static void test_alternating_load_hysteresis(void) {
StereoShedPolicy p;
int transitions = 0, prev = 0;
configure(&p, 20.0, 100000, 240, 0.9); /* wide bounds: isolate hysteresis */
uint64_t t = 0;
for (int i = 0; i < 200; i++) {
int s = stereo_shed_policy_should_shed(&p, t);
if (i && s != prev) transitions++;
prev = s;
/* alternate just under budget and inside the band (14.4..16) */
stereo_shed_policy_note_pair(&p, (i & 1) ? 15.8 : 15.0, t, 1);
t += 8;
}
CHECK(transitions <= 2); /* entered once, at most one exit: no flapping */
}
/* 5: stale estimate — a long gap with no measurement must still be interrupted
* by a probe within probe_ms, and must not suppress indefinitely. */
static void test_stale_estimate_recovers(void) {
StereoShedPolicy p;
configure(&p, 40.0, 250, 1000, 0.9); /* high avg, count bound far away */
CHECK(stereo_shed_policy_should_shed(&p, 0) == 1); /* enter */
CHECK(stereo_shed_policy_should_shed(&p, 8) == 1);
CHECK(stereo_shed_policy_should_shed(&p, 260) == 0); /* >= probe_ms: probe */
CHECK(stereo_shed_policy_should_shed(&p, 260) == 1); /* fresh streak begins */
CHECK(stereo_shed_policy_should_shed(&p, 520) == 0); /* bounded again */
}
/* 6: a measurement gap (e.g. OpenXR failing so render_stereo never runs) leaves
* the average untouched, but the time bound still forces a probe. */
static void test_transient_measurement_gap(void) {
StereoShedPolicy p;
uint64_t first_probe = 0;
configure(&p, 30.0, 250, 8, 0.9);
uint64_t t = 0;
for (int i = 0; i < 200; i++) {
if (stereo_shed_policy_should_shed(&p, t) == 0) { first_probe = t; break; }
t += 8; /* no note_pair: the average is never refreshed */
}
CHECK(first_probe > 0 && first_probe <= 256u);
}
/* 7: a failure that aborts the attempt early is still a measurement, so the
* average falls and shedding ends (no unbounded failure loop). */
static void test_failure_ends_streak(void) {
StereoShedPolicy p;
configure(&p, 30.0, 100000, 240, 0.9);
CHECK(stereo_shed_policy_should_shed(&p, 0) == 1);
stereo_shed_policy_note_pair(&p, 3.0, 8, 0); /* aborted early, not published */
for (int i = 0; i < 40; i++)
stereo_shed_policy_note_pair(&p, 3.0, (uint64_t)(16 + i * 8), 0);
CHECK(stereo_shed_policy_should_shed(&p, 1000) == 0);
}
/* 8: pair age tracks the retained pair independently of the compute estimate. */
static void test_pair_age_separate(void) {
StereoShedPolicy p;
configure(&p, 0.0, 250, 8, 0.9);
CHECK(stereo_shed_policy_pair_age_ms(&p, 100) == 0); /* nothing published yet */
stereo_shed_policy_note_pair(&p, 12.0, 1000, 1);
CHECK(stereo_shed_policy_pair_age_ms(&p, 1000) == 0);
CHECK(stereo_shed_policy_pair_age_ms(&p, 1050) == 50);
/* a later failed attempt must not refresh the age */
stereo_shed_policy_note_pair(&p, 12.0, 1100, 0);
CHECK(stereo_shed_policy_pair_age_ms(&p, 1100) == 100);
}
/* 9: reset clears the estimate so a new scene does not inherit it. */
static void test_reset_on_transition(void) {
StereoShedPolicy p;
configure(&p, 40.0, 250, 8, 0.9);
CHECK(stereo_shed_policy_should_shed(&p, 0) == 1);
stereo_shed_policy_reset(&p);
CHECK(stereo_shed_policy_avg_ms(&p) == 0.0);
CHECK(stereo_shed_policy_should_shed(&p, 10) == 0); /* fresh, under budget */
}
/* 11: a pair time that settles *inside* the hysteresis band must not latch the
* shedding state. Measured case (SteamVR null HMD at 90 Hz): deadline
* 11.111 ms, pair 10.4 ms, so the band is 10.0 to 11.111. Shedding entered
* during a heavy moment and then never ended, because the band holds whatever
* state it is in and the old probe exit test was the band's lower edge. That
* cost 26 sheds/s against 3.7 published pairs/s. The probe is authoritative and
* must exit on the deadline itself. */
static void test_band_latch_recovers(void) {
StereoShedPolicy p;
uint32_t sheds = 0;
configure(&p, 0.0, 250, 8, 0.9);
p.budget_ms = 11.111;
uint64_t t = 0;
stereo_shed_policy_note_pair(&p, 15.0, t, 1); /* heavy moment: enter shedding */
for (int i = 0; i < 60; i++) { /* 2 s at a 33 ms frame */
if (stereo_shed_policy_should_shed(&p, t)) { sheds++; t += 33; continue; }
stereo_shed_policy_note_pair(&p, 10.4, t, 1); /* steady state inside the band */
t += 33;
}
CHECK(sheds <= 12u); /* one bounded streak, then rendering resumes */
CHECK(stereo_shed_policy_avg_ms(&p) <= 11.111);
}
/* 10: env parsing clamps to safe ranges (and PSX_STEREO_SHED=0 disables). */
static void test_env(void) {
StereoShedPolicy p;
stereo_shed_policy_init(&p);
CHECK(p.enabled == 1 && p.max_streak >= 1u && p.probe_ms > 0.0);
stereo_shed_policy_configure_from_env(&p); /* no env set in CI: defaults hold */
CHECK(p.max_streak >= 1u && p.max_streak <= 240u);
CHECK(p.hysteresis >= 0.5 && p.hysteresis <= 1.0);
}
int main(void) {
test_disabled();
test_short_spike_bounded();
test_persistent_overload_probes();
test_alternating_load_hysteresis();
test_stale_estimate_recovers();
test_transient_measurement_gap();
test_failure_ends_streak();
test_pair_age_separate();
test_reset_on_transition();
test_band_latch_recovers();
test_env();
if (g_fail) { printf("stereo_shed_policy: FAILED\n"); return 1; }
printf("stereo_shed_policy: all checks passed\n");
return 0;
}