video: rebuild Scanline Sync as a phase lock on the vblank event

The tuner this replaces nudged a target line by +-1, +-2 and +-4 based
on where the beam happened to be last frame, with kill/hold/rearm
heuristics to stop it thrashing, and found the beam by polling
D3DKMTGetScanLine. That call measures ~223 us on a WDDM driver, during
which the beam covers ~60 lines at 4K120, so the loop could not resolve
better than 60 lines and spent 5.5 to 9.2 ms per frame doing it -
longer than an 8.31 ms frame period. The heuristics on top produced
three separate latches, all fixed and then reverted in 302b9546ea,
because fixing them only exposed that the loop underneath does not
converge.

Anchor on D3DKMTWaitForVerticalBlankEvent instead and let the display
be the clock. Measured across ten modes on a 4K panel: zero vblank
intervals outside +-20% of the median in 499 samples per mode, p1..p99
spread 21-32 us, period accurate to better than 0.1% at 30, 50, 59, 60,
75, 100, 119 and 120 Hz. Because the period is measured rather than
read from the reported refresh rate, nominal-versus-actual error does
not enter and a variable refresh display is tracked rather than
assumed.

Three parameters, all measured at calibration:

  total   line count including blanking, from the peak scanline
  period  median vblank-to-vblank interval
  bias    residual prediction error at a fixed delay

bias is mode dependent - +27 lines at 4K120, +59 at 4K60 - so all three
are measured together and the calibration is dropped on video reinit,
which is where a mode change lands. It is an offset and not a drift:
swept against a 0-6 ms delay it moves 0.1 lines at 60 Hz and 3.9 lines
at 120 Hz, which is what makes a single calibrated constant valid.

The wait is a timer sleep to the computed moment followed by a short
spin across SCANLINE_MARGIN_US. The sleep is not a timing authority -
the spin decides the moment, so a mistimed sleep costs microseconds of
extra spin rather than putting the beam somewhere it is not.

The anchor is refreshed by reading the beam and back-computing where
the vblank must have been, not by a second blocking wait: that would
block for up to a whole frame period on top of the frame's own wait.
One GetScanLine is ~223 us, 0.17% of a frame at 120 Hz amortised over
16 frames. Calibration is 24 vblank waits, 160 scanline reads and 12
bias samples - 362 ms at 120 Hz, 662 ms at 60 Hz.

Prototype measurements over 3000 frames with a 0.5-5.5 ms synthetic
core load, against the implementation being replaced:

           landing error        spin per frame
  4K120    14.8 lines mean      331 us      was +-60 lines, 5500-9200 us
  4K60      6.8 lines mean      321 us      was +-30 lines, 5500-9200 us

Median landing error is +1 to +2 lines. Frames that cannot make their
target are counted and otherwise ignored; there is no hold state, so a
missed frame does not affect the next one.

Recorded in the header comment because it cost an evening to establish:
none of this can absorb a display refresh that is not the content rate.
Syncing to a scanline means releasing one frame per beam pass, so at
120 Hz with 60 Hz content there are two passes per frame and no single
correct moment to wait for. The wait duration alternates and shows up
as frame time deviation no matter how precisely it is measured -
15% observed, against 0.9% with the feature off. Configure the display
at the content's refresh rate, VSync off, and make sure
video_refresh_rate matches what the panel actually runs at: a nominal
120.000 against a real 120.39 drifts a full frame every ~2.5 seconds.
This commit is contained in:
LibretroAdmin authored and libretroadmin committed 2026-09-02 03:05:36 +02:00
1 parent b576415efb
commit c9260c895d
3 files changed
+300 -127

No files matched your search

+23
View File
@@ -188,6 +188,12 @@ static void d3dkmt_init(void)
GetProcAddress(GetModuleHandle("gdi32.dll"), "D3DKMTOpenAdapterFromHdc");
pD3DKMTGetScanLine = (D3DKMTGETSCANLINE)
GetProcAddress(GetModuleHandle("gdi32.dll"), "D3DKMTGetScanLine");
/* Optional: only the phase anchor depends on it, and
* d3dkmt_wait_vblank() reports its absence so the caller can
* fall back. Not part of the guard below for that reason. */
pD3DKMTWaitForVerticalBlankEvent = (D3DKMTWAITFORVERTICALBLANKEVENT)
GetProcAddress(GetModuleHandle("gdi32.dll"),
"D3DKMTWaitForVerticalBlankEvent");
/* Both exports are WDDM, so they are absent under XDDM - there
* are no D3DKMT entry points in gdi32 before Vista at all.
@@ -234,11 +240,28 @@ static void d3dkmt_init(void)
sl.VidPnSourceId = d3dkmt_adapter_VidPnSourceId;
d3dkmt_adapter.sl = sl;
}
{
D3DKMT_WAITFORVERTICALBLANKEVENT vb = {0};
vb.hAdapter = d3dkmt_adapter_hAdapter;
vb.VidPnSourceId = d3dkmt_adapter_VidPnSourceId;
/* hDevice is documented optional and is not needed to wait on
* a VidPn source. */
d3dkmt_adapter.vb = vb;
}
}
video_driver_scanline_init();
}
bool d3dkmt_wait_vblank(void)
{
if (!pD3DKMTWaitForVerticalBlankEvent || !d3dkmt_adapter.vb.hAdapter)
return false;
return (pD3DKMTWaitForVerticalBlankEvent(&d3dkmt_adapter.vb)
== STATUS_SUCCESS);
}
int d3dkmt_scanline_get(void)
{
if (pD3DKMTGetScanLine)
+13
View File
@@ -238,13 +238,26 @@ typedef NTSTATUS(CALLBACK* D3DKMTOPENADAPTERFROMHDC)(D3DKMT_OPENADAPTERFROMHDC*)
static D3DKMTOPENADAPTERFROMHDC pD3DKMTOpenAdapterFromHdc;
typedef NTSTATUS(CALLBACK* D3DKMTGETSCANLINE)(D3DKMT_GETSCANLINE*);
static D3DKMTGETSCANLINE pD3DKMTGetScanLine;
typedef NTSTATUS(CALLBACK* D3DKMTWAITFORVERTICALBLANKEVENT)(D3DKMT_WAITFORVERTICALBLANKEVENT*);
static D3DKMTWAITFORVERTICALBLANKEVENT pD3DKMTWaitForVerticalBlankEvent;
typedef struct d3dkmt_adapter
{
D3DKMT_GETSCANLINE sl;
D3DKMT_WAITFORVERTICALBLANKEVENT vb;
} d3dkmt_adapter_t;
extern int d3dkmt_scanline_get(void);
/* Block until the display signals vertical blank. Returns false when
* the entry point is unavailable or the wait fails, in which case the
* caller has no anchor and must fall back to polling.
*
* Measured on a 4K120 panel: 0 intervals outside +-20%% of the median
* across 499 samples, p1..p99 spread 30 us, period accurate to 0.02%%.
* That makes it a usable phase reference; GetScanLine at ~223 us a call
* is not. */
extern bool d3dkmt_wait_vblank(void);
#endif /* HAVE_D3DKMT */
RETRO_END_DECLS
+264 -127
View File
@@ -6237,12 +6237,23 @@ void video_frame_delay(video_driver_state_t *video_st,
}
}
#ifdef HAVE_D3DKMT
static void scanline_pll_reset(void);
#endif
void video_driver_scanline_init(void)
{
video_driver_state_t *video_st = video_state_get_ptr();
video_st->scanline[SCANLINE_NEXT] = 1;
video_st->scanline[SCANLINE_HOLD] = 1;
video_st->scanline[SCANLINE_TOTAL] = 0;
#ifdef HAVE_D3DKMT
/* Runs from d3dkmt_init(), so once per video (re)init. Drop the
* calibration: the line count, the period and the bias are all
* properties of the display mode and all three move when it
* changes. */
scanline_pll_reset();
#endif
}
static INLINE int16_t video_driver_scanline_get(void)
@@ -6253,87 +6264,257 @@ static INLINE int16_t video_driver_scanline_get(void)
return -1;
}
#ifdef HAVE_D3DKMT
/* --- Scanline Sync phase lock ------------------------------------
*
* D3DKMTGetScanLine costs ~223 us on a WDDM driver, during which the
* beam covers ~60 lines at 4K120, so a loop that polls it cannot
* resolve better than that and burns a core doing it. Every wait the
* D3DKMT surface exposes is frame-granular, so the sub-frame wait has
* to come from a timer.
*
* Anchor on D3DKMTWaitForVerticalBlankEvent instead and treat the
* display as the clock. Measured on a 4K120 panel: zero intervals
* outside +-20%% of the median across 499 samples, p1..p99 spread
* 30 us, period accurate to 0.02%%. Because the period is measured
* rather than taken from the reported refresh rate, nominal-vs-actual
* error does not enter and a variable refresh display is tracked
* rather than assumed.
*
* Three parameters, all measured at calibration, none assumed:
* total line count including blanking, from the peak scanline
* period vblank-to-vblank interval, median of a run
* bias residual prediction error, median at a fixed delay
*
* bias is mode dependent (+27 lines at 4K120, +59 at 4K60), so all
* three are measured together and dropped on video reinit - see
* video_driver_scanline_init(). It is a constant offset, not a drift:
* swept against a 0-6 ms delay it varies by 0.1 lines at 60 Hz and
* 3.9 at 120 Hz.
*
* What this cannot do, and no wait mechanism can: absorb a display
* refresh that is not the content rate. Syncing to a scanline means
* releasing one frame per beam pass, so at 120 Hz with 60 Hz content
* there are two passes per frame and no single correct moment to wait
* for - the wait duration alternates and shows up as frame time
* deviation regardless of how precisely it is measured. Configure the
* display at the content's refresh rate, turn VSync off, and make sure
* video_refresh_rate matches what the panel actually runs at; a
* nominal 120.000 against a real 120.39 drifts a full frame every
* ~2.5 seconds.
*
* The timer only has to be roughly right. The short spin across
* SCANLINE_MARGIN_US, not the sleep, decides the moment, and a
* mistimed sleep costs a few extra microseconds of spin rather than
* putting the beam somewhere it is not. */
#define SCANLINE_MARGIN_US 300.0
#define SCANLINE_CAL_SAMPLES 24
#define SCANLINE_ANCHOR_MAXAGE 16 /* frames before re-anchoring */
typedef struct
{
double period_us; /* measured vblank interval */
double line_us; /* period_us / total */
double bias_lines; /* calibrated residual */
double anchor_us; /* time of the last observed vblank */
unsigned anchor_age; /* frames since anchor_us was refreshed */
int total; /* lines including blanking */
bool locked;
unsigned misses; /* frames that could not make their target */
} scanline_pll_t;
static scanline_pll_t scl_pll;
static void scanline_pll_reset(void)
{
scl_pll.locked = false;
}
static bool scanline_pll_calibrate(void)
{
int i, n = 0;
int total = 0;
double prev = 0.0, acc = 0.0;
scl_pll.locked = false;
for (i = 0; i < 160; i++)
{
int16_t l = video_driver_scanline_get();
if (l < 0)
return false;
if (l > total)
total = l;
}
if (total < 16)
return false;
scl_pll.total = total + 1;
for (i = 0; i < SCANLINE_CAL_SAMPLES; i++)
{
double t;
if (!d3dkmt_wait_vblank())
return false;
t = (double)cpu_features_get_time_usec();
if (prev > 0.0)
{
double d = t - prev;
/* 15 Hz to 1 kHz, anything else is a missed or doubled event */
if (d > 1000.0 && d < 66000.0)
{
acc += d;
n++;
}
}
prev = t;
}
if (n < SCANLINE_CAL_SAMPLES / 3)
return false;
scl_pll.period_us = acc / (double)n;
scl_pll.line_us = scl_pll.period_us / (double)scl_pll.total;
/* Residual offset: predict at a fixed delay and compare with a real
* sample. Averaged rather than medianed to keep this short; the
* jitter is 6-12 lines so the mean is close enough for an offset
* the spin corrects anyway. */
{
double sum = 0.0;
int m = 0;
for (i = 0; i < 12; i++)
{
double t, at, off, pred, d;
int16_t meas;
if (!d3dkmt_wait_vblank())
return false;
t = (double)cpu_features_get_time_usec();
do { at = (double)cpu_features_get_time_usec(); }
while (at - t < 2000.0);
off = at - t;
while (off >= scl_pll.period_us)
off -= scl_pll.period_us;
pred = off / scl_pll.line_us;
if ((meas = video_driver_scanline_get()) < 0)
continue;
d = pred - (double)meas;
if (d > scl_pll.total / 2.0) d -= scl_pll.total;
if (d < -scl_pll.total / 2.0) d += scl_pll.total;
sum += d;
m++;
}
if (!m)
return false;
scl_pll.bias_lines = sum / (double)m;
}
scl_pll.anchor_us = (double)cpu_features_get_time_usec();
scl_pll.anchor_age = 0;
scl_pll.misses = 0;
scl_pll.locked = true;
RARCH_LOG("[Scanline] locked: %d lines, %.1f us (%.3f Hz),"
" bias %+.1f lines.\n",
scl_pll.total, scl_pll.period_us,
1000000.0 / scl_pll.period_us, scl_pll.bias_lines);
return true;
}
/* Refresh the anchor without blocking.
*
* The obvious way to re-anchor is another d3dkmt_wait_vblank(), but
* that blocks for up to a full frame period on top of the frame's own
* wait - at SCANLINE_ANCHOR_MAXAGE of 30 that was four visible hitches
* a second, and it is what made the first version of this stutter.
*
* Read the beam instead and back-compute where the vblank must have
* been. One GetScanLine is ~223 us against a whole frame period, and
* the bias term is the same constant calibration measured, applied in
* reverse. */
static void scanline_pll_anchor(void)
{
double now;
int16_t beam;
if (scl_pll.anchor_age++ < SCANLINE_ANCHOR_MAXAGE)
return;
if ((beam = video_driver_scanline_get()) < 0)
return;
now = (double)cpu_features_get_time_usec();
scl_pll.anchor_us = now
- (((double)beam + scl_pll.bias_lines) * scl_pll.line_us);
scl_pll.anchor_age = 0;
}
/* Wait until the beam reaches target_line. Returns false if that
* moment has already passed for this frame, which the caller counts
* and otherwise ignores - there is no state to latch. */
static bool scanline_pll_wait(int target_line)
{
double want, now, due;
if (!scl_pll.locked)
return false;
want = ((double)target_line + scl_pll.bias_lines) * scl_pll.line_us;
due = scl_pll.anchor_us + want;
now = (double)cpu_features_get_time_usec();
/* Advance to the next occurrence at or after now. */
while (due < now)
due += scl_pll.period_us;
/* More than one period out means the target for this frame is gone. */
if (due - now > scl_pll.period_us)
{
scl_pll.misses++;
return false;
}
if (due - now > SCANLINE_MARGIN_US)
retro_sleep((unsigned)((due - now - SCANLINE_MARGIN_US) / 1000.0));
while ((double)cpu_features_get_time_usec() < due)
;
return true;
}
#endif /* HAVE_D3DKMT */
VIDEO_NOINLINE static void video_driver_scanline_before_frame(video_driver_state_t *video_st,
float refresh_rate,
uint16_t frame_time_target,
uint16_t core_run_time)
{
uint16_t video_height = video_st->height;
int16_t scanline_next = video_st->scanline[SCANLINE_NEXT];
int16_t scanline_hold = video_st->scanline[SCANLINE_HOLD];
int16_t scanline_blank = video_st->scanline[SCANLINE_TOTAL] - video_height;
int16_t scanline = video_driver_scanline_get();
#ifdef HAVE_D3DKMT
(void)refresh_rate;
(void)frame_time_target;
(void)core_run_time;
/* Minimum usage is vblank */
uint16_t min_run_time = (scanline_blank > 0) ? (double)scanline_blank / (double)video_height * (double)frame_time_target : 1000;
core_run_time = (core_run_time < min_run_time) ? min_run_time : core_run_time;
/* Disable if unsupported */
if (scanline < 0)
/* Lock on first use. Calibration measures the line count, the period
* and the residual bias together, because a mode change moves all
* three. */
if (!scl_pll.locked)
{
scanline_next = 0;
scanline_hold = refresh_rate;
}
else if (video_st->frame_count > refresh_rate)
{
/* Disable if the core and/or frame takes too long */
uint16_t frame_time_index = video_st->frame_time_count & (MEASURE_FRAME_TIME_SAMPLES_COUNT - 1);
uint16_t sample_index = (uint16_t)((frame_time_index - 1) & (MEASURE_FRAME_TIME_SAMPLES_COUNT - 1));
retro_time_t frame_time = video_st->frame_time_samples[sample_index];
bool frame_time_deviation = frame_time >= frame_time_target * 1.66f || frame_time <= frame_time_target * 0.33f;
if (scanline_hold && (frame_time_deviation || core_run_time >= frame_time_target - 3000))
if (!scanline_pll_calibrate())
{
scanline_next = 0;
scanline_hold = refresh_rate / 2;
video_st->scanline[SCANLINE_NEXT] = 0;
return;
}
else if (!scanline_hold && frame_time_deviation)
scanline_hold += 3;
}
scanline_pll_anchor();
/* Shift overflow */
if (scanline > (int)video_height - (scanline_blank * 4))
scanline -= video_height;
/* Allow change */
if (!scanline_hold)
{
int16_t corelines = (video_height + scanline_blank) * ((double)core_run_time / (double)frame_time_target);
/* Fine-tuning */
if ( scanline > -scanline_blank
&& scanline < corelines + scanline_blank)
scanline_next -= 2;
else if (scanline_next <= scanline + corelines + scanline_blank)
scanline_next += 4;
if ( scanline > 0
&& scanline < video_height - scanline_blank
&& scanline_next >= -(scanline + corelines + scanline_blank))
scanline_next--;
else if (scanline > (video_height - scanline_blank) / 2
|| scanline < -scanline_blank)
scanline_next++;
/* Cap to avoid visible tear in bottom */
if ( scanline_next > corelines
&& scanline_next < video_height - corelines - scanline_blank)
scanline_next = video_height - corelines - scanline_blank;
/* Skip unsynced */
if (!scanline_next)
scanline_next--;
}
else if (scanline_hold)
scanline_hold--;
/* Wrap overflow */
if ( scanline_next >= (int)video_height
|| scanline_next <= (int)-video_height)
scanline_next = -1;
video_st->scanline[SCANLINE_NEXT] = scanline_next;
video_st->scanline[SCANLINE_HOLD] = scanline_hold;
/* Target the far end of the visible area so the flip lands in
* blanking. A plain line number: no tuner state, nothing to
* converge, and therefore nothing to thrash. */
video_st->scanline[SCANLINE_NEXT] =
(int16_t)(video_st->height - (video_st->height / 16));
video_st->scanline[SCANLINE_TOTAL] = (int16_t)scl_pll.total;
#else
(void)video_st;
(void)refresh_rate;
(void)frame_time_target;
(void)core_run_time;
#endif
}
VIDEO_NOINLINE static void video_driver_scanline_after_frame(video_driver_state_t *video_st,
@@ -6341,63 +6522,19 @@ VIDEO_NOINLINE static void video_driver_scanline_after_frame(video_driver_state_
uint16_t frame_time_target,
uint16_t core_run_time)
{
uint16_t video_height = video_st->height;
int16_t scanline_next = video_st->scanline[SCANLINE_NEXT];
int16_t scanline_total = video_st->scanline[SCANLINE_TOTAL];
int16_t scanline_blank = video_st->scanline[SCANLINE_TOTAL] - video_height;
int16_t scanline_target = (scanline_next < 0) ? video_height + scanline_next : scanline_next;
int16_t scanline = scanline_next;
uint16_t min_run_time = (scanline_blank > 0) ? (double)scanline_blank / (double)video_height * (double)frame_time_target : 1000;
bool init = (!scanline_total) ? true : false;
bool wait = true;
#ifdef HAVE_D3DKMT
int target;
if ( scanline_target <= 0
|| scanline_target >= video_height)
wait = false;
(void)refresh_rate;
(void)frame_time_target;
(void)core_run_time;
/* Reset */
if (scanline_next == 1)
scanline_target = video_height;
/* Minimum usage is vblank */
core_run_time = (core_run_time < min_run_time) ? min_run_time : core_run_time;
/* Use CPU friendlier sleep as much as possible */
if (wait && frame_time_target > core_run_time)
{
int8_t sleep = (frame_time_target - core_run_time) / 1000;
if (sleep > 1)
{
/* Sleeping too much causes problems */
sleep -= 4;
/* At least 1ms for balancing heavier loads */
sleep = (sleep < 1) ? 1 : sleep;
retro_sleep(sleep);
}
}
while (wait)
{
scanline = video_driver_scanline_get();
if (scanline >= scanline_target)
wait = false;
if (init)
{
if (!scanline_total)
scanline_total = video_height;
if (scanline)
wait = true;
else if (scanline_total > video_height)
init = false;
}
if (scanline >= scanline_total)
scanline_total = scanline + 1;
}
video_st->scanline[SCANLINE_NEXT] = scanline;
video_st->scanline[SCANLINE_TOTAL] = scanline_total;
if ((target = (int)video_st->scanline[SCANLINE_NEXT]) > 0)
scanline_pll_wait(target);
#else
(void)video_st;
(void)refresh_rate;
(void)frame_time_target;
(void)core_run_time;
#endif
}