feat(app): Frame pacing "Match display" (mode B) (#1038) (#1128)

With the emulation thread on, Settings > Video > Frame pacing offers
Jaguar rate (default, mode A) or Match display (mode B). In mode B each
emulated frame starts on a display tick, the audio rate follows the
display, and the UI swaps as soon as the tick's frame is ready.

- video.pacing setting, --pacing jaguar|display (one run, not saved),
  Video tab and TV rows with live state, reset class.
- LateLatch measures the real vblank period (59.94 Hz panels that
  report 60 lock correctly) and gives a last-chance wait for heavy
  frames.
- The UI evaluates DisplayLock after each swap, publishes the tick grid
  after draining input, and re-evaluates on display/fullscreen events.
- The emulation loop runs frames on ticks, with a backstop 2 ms past a
  late tick and mode A when the grid is stale or fast-forwarding.
- latchWait ends on the tick's frame publish.
- One notice per lasting fallback reason; link play forces mode A.
- Perf trace columns mode, tick_seq, ticks_dropped, lock_reason.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Joe MattielloandClaude Opus 5.5 authored and GitHub committed 2026-10-31 03:50:19 -04:00
1 parent 507d11f4c9
commit fe80b36a2b
20 files changed
+708 -30

No files matched your search

+19 -2
View File
@@ -565,7 +565,7 @@ it. PNGs are decoded with SDL3's own loader (`SDL_LoadPNG_IO`): no image library
busy UI no longer stalls the picture. The game runs at the true Jaguar rate (60.05 Hz, PAL 50.08 Hz), paced by the
sound card's clock, and the display shows the newest frame (on a 60 Hz panel the true-rate drift alone skips one frame about every 18 s; in this phase frames that finish
close to the take point also repeat or skip more often than in the single-thread loop, roughly 1 to 2.5 a second as
measured on a 60 Hz panel, which the display-locked mode planned for a later phase addresses).
measured on a 60 Hz panel; **Frame pacing: Match display**, below, removes both).
The setting takes effect at the next start. It is **on** by default in nightly builds and **off** in releases (the build
channel; About shows it, and the log prints `[emu] channel=...`). A stored choice always wins in both directions; **Reset
group** in Settings > Interface returns to the channel's default. `dev` builds (pull-request artifacts, local builds)
@@ -585,6 +585,22 @@ really shown and splits their timing. With the latch's vblank grid locked, the e
grid's phase once whenever frame ends would straddle the take point, so each 60.05-on-60 Hz crossing is one skip rather
than a burst of repeats and skips; `--pace-steer off` turns that off for a measurement.
**Settings > Video > Frame pacing** (`video.pacing`, #1038) picks the clock the emulation thread follows. **Jaguar
rate** (the default) is the above: the console's own 60.05 / 50.08 Hz, kept by the sound card. **Match display** runs
one emulated frame on every refresh (every second refresh at 120 Hz) where the display's measured rate is within 0.3%
of a whole multiple of the game's: 60, 59.94, 120 and 119.88 Hz for NTSC games, 50 and 100 Hz for PAL. The sound plays
that much faster or slower (0.09% on a 60 Hz panel, inaudible), the Jaguar rate's periodic repeat or skip goes away,
and a frame starts right after the swap that hands it the newest input, so event to present matches the single-thread loop even
for heavy games. It needs vsync and the late latch's lock on the vblanks, and settles for about two seconds after the
start or a display change. Elsewhere (144 or 75 Hz, a PAL game on 60 Hz, vsync off, a refresh that is not steady), and
always during fast-forward and link play, it uses the Jaguar rate; the setting shows why ("Now: ..."), and a lasting
fallback shows a message once. The log says `[emu] frame pacing: mode B (locked)` / `mode A (<reason>)` at each
change. A window that stops swapping (a modal, a sleeping display) does not stop the game: the emulation keeps the
display's grid on its own, and returns to the Jaguar rate after a quarter of a second. `--pacing jaguar|display`
chooses for one run without saving. The single-thread loop ignores the setting (it is already display-paced).
`VJ_PERF_TRACE`'s emulation rows end with `mode`, `tick_seq`, `ticks_dropped` and `lock_reason`, which the summary
script counts.
While it is on, every Tools window works (their data is copied by the emulation thread after each frame, and their edits
run on it), and so do Break, Step, breakpoints and the GDB stub. The halt is served by the emulation thread in 4 ms slices
and the window mirrors it once per frame, so the UI keeps drawing while the game is parked (a gdb session holding a halt
@@ -1104,7 +1120,8 @@ The tests labelled `emu_thread` (`ctest -L emu_thread`) cover the emulation thre
| `cmake/VJShaders.cmake` | pinned header fetch, optional prebuilt runtime, shader tests |
| `src/audio.*` | SDL3 audio stream with dynamic rate control |
| `src/emu_thread.*`, `src/emu_commands.h` | the emulation thread (#967): its loop, `post` / `call` / `advance`, the ordered command queue |
| `src/pacing_clock.h` | mode A pacing: the core's period trimmed by the audio queue (the sound card as master clock) |
| `src/pacing_clock.h` | the emulation thread's pacing: mode A, the core's period trimmed by the audio queue (the sound card as master clock); mode B, a frame every k display ticks with owed ticks dropped |
| `src/display_lock.h` | Frame pacing's lock verdict (mode B, #1038): which displays lock at which k, the fallback reasons, the hysteresis |
| `src/frame_exchange.h`, `src/frame.h` | finished frames from the emulation thread to the UI (triple buffer, content epochs) |
| `src/emu_snapshot.h` | what the emulation thread publishes for the UI: the per-frame snapshot and the core-event stream (log lines, events, option writes) |
| `src/ach_event.*` | an rc_client event copied, so it can be applied on the UI thread |
+200 -9
View File
@@ -143,7 +143,27 @@ struct App::EmuClient : EmuThread::Client {
double framePeriod() override { return 1.0 / std::max(1.0, app.host.fps()); }
double audioQueuedMs() override { return app.emuAudioQueuedMs(); }
void runFrame() override { app.emuRunOneFrame(); }
void framePaced(double lateSec, const PacingClock& clock) override { app.tracePaced(lateSec, clock); }
void framePaced(double lateSec, const PacingClock& clock) override {
app.frameTick_ = clock.ticking() ? clock.startedTick() : 0;
app.audioModeB_ = clock.ticking();
app.tracePaced(lateSec, clock);
}
bool displayGrid(double now, DisplayGrid& g) override {
App::ModeBGrid m;
{
std::lock_guard<std::mutex> l(app.gridMu_);
m = app.grid_;
}
const uint64_t sdlNow = SDL_GetTicksNS(); // the UI's clock -> this thread's
// (signed: the tick's vblank is the grid point nearest the swap's return, which can be a little after now)
if (!m.on || !m.periodNs || (int64_t)(sdlNow - m.tickNs) > (int64_t)App::kGridStaleNs) return false;
g.tick = m.tick;
g.tickAt = now - (double)(int64_t)(sdlNow - m.tickNs) / 1e9;
g.period = (double)m.periodNs / 1e9;
g.k = m.k;
app.frameBase_ = m.baseRatio;
return true;
}
bool takeGrid(double now, double& at, double& period) override {
const uint64_t p = app.takePeriodNs_.load(std::memory_order_acquire);
const uint64_t t = app.takeNs_.load(std::memory_order_relaxed);
@@ -211,6 +231,7 @@ void App::applySettingsFromConfig() {
present.stretch = cfg.getBool("video.stretch", false);
present.aspectOverride = cfg.getFloat("video.aspect", 0.0f);
vsync = cfg.getBool("video.vsync", true);
pacingDisplay = cfg.get("video.pacing", "jaguar") == "display";
video.shader.loadSettings(cfg, paths.root);
volume = cfg.getFloat("audio.volume", 1.0f);
mute = cfg.getBool("audio.mute", false);
@@ -291,6 +312,8 @@ void App::saveConfig() {
cfg.setBool("video.stretch", present.stretch);
cfg.setFloat("video.aspect", present.aspectOverride);
cfg.setBool("video.vsync", vsync);
if (pacingFromCli_ < 0 || pacingDisplay != (pacingFromCli_ == 1)) // (--pacing is for this run only)
cfg.set("video.pacing", pacingDisplay ? "display" : "jaguar");
video.shader.saveSettings(cfg);
cfg.setFloat("audio.volume", volume);
cfg.setBool("audio.mute", mute);
@@ -790,7 +813,15 @@ void App::emuRunOneFrame() {
// The lanes were drained before this call: a Detach posted by the UI has run, so the silent host is missing now. Put it
// back BEFORE the frame, or a gdb breakpoint firing in this frame would halt with no pump and no mirror.
attachGdbHostOnCore();
if (!emuFastForward_.load(std::memory_order_relaxed)) audio.update(false); // underruns only: mode A follows the audio clock
const uint64_t tick = frameTick_; // (advance() frames are not paced: no tick, and the controller in charge stays)
frameTick_ = 0;
if (!emuFastForward_.load(std::memory_order_relaxed)) {
// One audio controller at a time, switched at this frame boundary. Mode A: underruns only (the pacing follows
// the audio clock). Mode B: the stream plays at the display's rate (base) and AudioOut trims around it.
const double base = audioModeB_ ? frameBase_ : 1.0;
if (base != audio.baseRatio()) audio.setBaseRatio(base);
audio.update(audioModeB_);
}
host.wantVideo = true;
PerfTrace::Emu te = tracePaced_;
if (trace_.enabled()) {
@@ -815,9 +846,18 @@ void App::emuRunOneFrame() {
s.frame.pixels.assign(f.pixels.begin(), f.pixels.end());
s.aspect = host.aspect();
s.inputSeq = emuInputSeq_;
s.tick = tick;
frames.publish();
lastSentSerial_ = f.serial;
}
if (tick) { // mode B: the UI's latch wait ends now (also when the core made no new picture: nothing to wait for)
publishedTick_.store(tick, std::memory_order_release);
SDL_Event ev;
SDL_zero(ev);
ev.type = SDL_EVENT_USER;
ev.user.code = kFrameReadyCode;
SDL_PushEvent(&ev);
}
if (trace_.enabled()) {
te.endNs = SDL_GetTicksNS();
te.serial = f.serial;
@@ -842,6 +882,10 @@ void App::tracePaced(double lateSec, const PacingClock& clock) {
tracePaced_.queuedMs = (float)emuAudioQueuedMs();
tracePaced_.audio = clock.audioLocked() ? 1 : 0;
tracePaced_.refill = clock.refilling() ? 1 : 0;
tracePaced_.mode = clock.ticking() ? 1 : 0;
tracePaced_.tickSeq = clock.ticking() ? clock.startedTick() : 0;
tracePaced_.ticksDropped = clock.droppedTicks();
tracePaced_.lockReason = (unsigned char)lockReasonCode_.load(std::memory_order_relaxed);
tracePacedSet_ = true;
}
@@ -854,6 +898,129 @@ void App::publishTakeGrid(uint64_t takeNs) {
takePeriodNs_.store(p, std::memory_order_release);
}
// UI thread, threaded, right after every swap (#1038): the display lock's verdict, and in mode B the tick this swap
// returned on. The tick counts vblanks, not swaps: an iteration that missed one moves it by two, and the emulation
// thread runs the frame it owes. As the single-thread loop does after its swap, the input that came in while the UI
// built and swapped is handed over first, so the tick's frame polls it.
void App::displayLockAfterSwap(uint64_t swap0, uint64_t swap1) {
DisplayLockInput in;
const uint64_t mp = latch_.measuredPeriodNs();
in.matchDisplay = pacingDisplay;
in.vsync = wantSync() && swap1 - swap0 >= LateLatch::kBlockedNs; // a swap that did not block is no vblank
in.latchLocked = latch_.locked() && mp; // (not measured yet: not a clock to follow yet)
in.displayPeriod = (double)mp / 1e9;
in.corePeriod = host.isLoaded() && host.fps() > 1.0 ? 1.0 / host.fps() : 0;
in.fastForward = fastForward;
in.linked = netplay.link() != LinkState::Off;
lockVerdict_ = displayLock_.evaluate(in);
lockReasonCode_.store((int)lockVerdict_.reason, std::memory_order_relaxed);
if (lockVerdict_.reason != loggedReason_) { // (the transitions, for the log: Settings shows the live state)
loggedReason_ = lockVerdict_.reason;
std::fprintf(stderr, "[emu] frame pacing: %s (%s), display %.3f Hz, k=%u, base ratio %.5f\n",
lockVerdict_.mode == PacingMode::B ? "mode B" : "mode A", DisplayLock::name(lockVerdict_.reason),
mp ? 1e9 / (double)mp : 0.0, lockVerdict_.k, lockVerdict_.baseRatio);
}
displayLockNotice();
ModeBGrid g;
if (lockVerdict_.mode == PacingMode::B) {
const uint64_t v = latch_.nearestVblankNs(swap1);
tickSeq_ += (tickNs_ && v > tickNs_) ? std::max<uint64_t>(1, (v - tickNs_ + mp / 2) / mp) : 1;
tickNs_ = v;
if (std::fabs(lockVerdict_.baseRatio - gridBase_) > kBaseStep || !grid_.on) gridBase_ = lockVerdict_.baseRatio;
SDL_Event e;
while (!quit && SDL_PollEvent(&e))
if (!LatchWaker::isWake(e) && !isFrameReady(e)) handleEvent(e, false);
publishInput();
g.on = true;
g.tick = tickSeq_;
g.tickNs = v;
g.periodNs = mp;
g.k = lockVerdict_.k;
g.baseRatio = gridBase_;
} else {
tickNs_ = 0;
gridBase_ = 1.0;
}
{
std::lock_guard<std::mutex> l(gridMu_);
grid_ = g;
}
if (g.on) emu.notifyTick();
}
// The display's grid changed, or may have (another display, a new mode, fullscreen): mode B settles and measures again.
void App::displayChanged() {
displayLock_.reset();
latch_.resetMeasure();
if (window_) latchUpdatePeriod(); // (inline it only sets a period nothing reads)
}
const char* App::lockReasonText(LockReason r) {
switch (r) {
case LockReason::Locked: return "following the display";
case LockReason::Setting: return "Jaguar rate";
case LockReason::NoVsync: return "vsync is off";
case LockReason::Unlocked: return "the display's refresh is not steady";
case LockReason::Ratio: return "not a multiple";
case LockReason::Settling: return "measuring the display";
case LockReason::FastForward: return "fast-forward";
case LockReason::Link: return "link play";
}
return "";
}
// One message per change of a fallback that lasted (two seconds), while Match display is chosen. Settling and
// fast-forward never show one (every start, every press); a latch that flaps (Unlocked and back) shows one at most per
// lasting change. Back to following the display after a message: says so once.
void App::displayLockNotice() {
const LockReason r = lockVerdict_.reason;
if (r != heldReason_) {
heldReason_ = r;
heldSwaps_ = 0;
}
if (r == LockReason::Setting) noticeReason_ = LockReason::Setting; // chosen again later: report from scratch
if (!pacingDisplay || r == LockReason::Settling || r == LockReason::FastForward || r == LockReason::Setting) return;
if (++heldSwaps_ != 120 || r == noticeReason_) return;
const LockReason was = noticeReason_;
noticeReason_ = r;
char msg[160];
const double displayHz = latch_.measuredPeriodNs() ? 1e9 / (double)latch_.measuredPeriodNs()
: latch_.periodNs() ? 1e9 / (double)latch_.periodNs()
: 0.0;
if (r == LockReason::Locked) {
if (was == LockReason::Setting) return; // the usual case: no news
std::snprintf(msg, sizeof msg, "Match display: following the display again (%.2f Hz)", displayHz);
} else if (r == LockReason::Ratio) {
std::snprintf(msg, sizeof msg, "Match display: using Jaguar rate (%.2f Hz is not a multiple of %.2f Hz)",
displayHz, host.fps());
} else {
std::snprintf(msg, sizeof msg, "Match display: using Jaguar rate (%s)", lockReasonText(r));
}
toast(msg, 4.0);
}
// Settings > Video > Frame pacing: what it is doing now.
std::string App::pacingStatus() const {
char buf[160];
if (!threaded()) return pacingDisplay ? "Match display needs the emulation thread: running at the Jaguar rate" : "";
const DisplayLockVerdict& v = lockVerdict_;
if (v.mode == PacingMode::B) {
const double hz = latch_.measuredPeriodNs() ? 1e9 / (double)latch_.measuredPeriodNs() : 0.0;
std::snprintf(buf, sizeof buf, "Now: following the display, %.2f Hz, %u refresh%s per frame", hz, v.k,
v.k == 1 ? "" : "es");
return buf;
}
if (!pacingDisplay) return "Now: the Jaguar's own rate, kept by the sound card";
if (v.reason == LockReason::Ratio) {
const uint64_t p = latch_.measuredPeriodNs() ? latch_.measuredPeriodNs() : latch_.periodNs();
std::snprintf(buf, sizeof buf, "Now: Jaguar rate (%.2f Hz is not a multiple of %.2f Hz)", p ? 1e9 / (double)p : 0.0,
host.fps());
return buf;
}
std::snprintf(buf, sizeof buf, "Now: Jaguar rate (%s)", lockReasonText(v.reason));
return buf;
}
// UI thread (VJ_PERF_TRACE): one row per iteration, right after its swap.
void App::traceUi(uint64_t takeNs, uint64_t serial, uint64_t swap0, uint64_t swap1) {
if (!trace_.enabled()) return;
@@ -955,6 +1122,13 @@ void App::notePresented(uint64_t swapEndNs) {
void App::latchWait() {
if (latchChecks_++ % 60 == 0) latchUpdatePeriod(); // the window may have moved to another display
const uint64_t wake = latch_.wakeAt(SDL_GetTicksNS());
// Mode B (#1038): the wait ends as soon as the frame for this iteration's tick is finished, and the UI builds and
// swaps at once, as the single-thread loop does. Past the wake time it goes on for that frame only, up to the last
// moment a build still makes the vblank (LateLatch::lastChanceNs): a heavy frame (Cybermorph, ~14 ms) finishes after
// the padded wake time but still fits the period, as it does inline.
const uint64_t waitTick = lockVerdict_.mode == PacingMode::B ? tickSeq_ : 0;
const uint64_t until = waitTick && latch_.lastChanceNs() > wake ? latch_.lastChanceNs() : wake;
const auto frameReady = [&] { return waitTick && publishedTick_.load(std::memory_order_acquire) >= waitTick; };
traceWakeNs_ = wake;
traceTargetNs_ = latch_.targetNs();
const auto over = [this](uint64_t wantNs, unsigned char by) { // VJ_PERF_TRACE: a blocking call that overran
@@ -966,14 +1140,19 @@ void App::latchWait() {
};
for (;;) {
const uint64_t t = SDL_GetTicksNS();
if (t >= wake || quit) return;
const uint64_t left = wake - t;
if (t >= until || quit || frameReady()) return;
const uint64_t left = until - t;
const Sint32 ms = (Sint32)LateLatch::eventWaitMs(left);
if (!ms) {
stall_.enter(wake, 2, trace_.uiRows());
SDL_DelayPrecise(left);
stall_.enter(until, 2, trace_.uiRows());
if (!waitTick) {
SDL_DelayPrecise(left);
} else { // mode B: the frame's wake event cannot end a precise sleep, so look for it every 250 us
for (uint64_t n = SDL_GetTicksNS(); n < until && !frameReady(); n = SDL_GetTicksNS())
SDL_DelayPrecise(std::min<uint64_t>(until - n, 250000));
}
stall_.leave();
over(wake, 2);
over(until, 2);
return;
}
SDL_Event e;
@@ -986,7 +1165,7 @@ void App::latchWait() {
continue;
}
do {
if (LatchWaker::isWake(e)) continue;
if (LatchWaker::isWake(e) || isFrameReady(e)) continue;
handleEvent(e, false);
traceWaitEvents_++;
} while (!quit && SDL_PollEvent(&e));
@@ -2120,13 +2299,16 @@ void App::handleEvent(const SDL_Event& e, bool headless) {
case SDL_EVENT_WINDOW_DISPLAY_CHANGED:
case SDL_EVENT_DISPLAY_CURRENT_MODE_CHANGED: // the same display switched its refresh rate
displayPeriodValid_ = false; // the next displayPeriodNs() reads the new display's refresh rate
displayChanged();
break;
case SDL_EVENT_WINDOW_ENTER_FULLSCREEN:
displayChanged();
fullscreen = true; // also the green button / Ctrl+Cmd+F on macOS, which never go through setFullscreen()
fullscreenMenu.update(fullscreen, fullscreenAutoHideMenu, menuPinned);
tvFullscreenEdge();
break;
case SDL_EVENT_WINDOW_LEAVE_FULLSCREEN:
displayChanged();
fullscreen = false;
fullscreenMenu.update(fullscreen, fullscreenAutoHideMenu, menuPinned);
tvFullscreenEdge();
@@ -2401,6 +2583,7 @@ int App::runInteractive(const CliArgs& a) {
if (emuThreadThisRun_) startEmuThread(); // after the window and the audio device (design section 7)
lateLatchOn_ = a.lateLatch != 0;
paceSteerOn_ = a.paceSteer != 0;
if (a.pacing >= 0) pacingDisplay = (pacingFromCli_ = a.pacing) == 1;
if (a.latchWake != 0 && !headless_) latchWaker_.start(); // macOS only (latch_waker.h)
fullscreen = false;
if (a.fullscreen) setFullscreen(true);
@@ -2488,9 +2671,16 @@ int App::runInteractive(const CliArgs& a) {
std::to_string(latch_.misses()) + ", released " + std::to_string(latch_.released()) +
", pad " + std::to_string(latch_.padNs() / 1000000.0).substr(0, 4) + " ms)"
: std::string("off"));
if (threaded())
if (threaded()) {
perf.note += std::string(", pace steering ") + (paceSteerOn_ ? "on" : "off") + " (" +
std::to_string(emu.phaseSteps()) + " steps)";
perf.note += std::string(", frame pacing ") + (pacingDisplay ? "match display" : "jaguar rate") + " (" +
(emu.modeB() ? "mode B, k=" + std::to_string(lockVerdict_.k) : std::string("mode A")) + ", " +
DisplayLock::name(lockVerdict_.reason) + ", ticks dropped " +
std::to_string(emu.droppedTicks()) + ", display " +
std::to_string(latch_.measuredPeriodNs() ? 1e9 / (double)latch_.measuredPeriodNs() : 0.0).substr(0, 6) +
" Hz)";
}
perf.report(host.contentName().c_str());
stall_.stop();
trace_.write();
@@ -2583,6 +2773,7 @@ int App::runInteractive(const CliArgs& a) {
if (latchedAtNs_) latch_.noteBuild(swap0 - latchedAtNs_);
latch_.onSwap(swap0, swap1);
latchLog();
displayLockAfterSwap(swap0, swap1);
}
perf.lap(Perf::Present);
+41
View File
@@ -10,6 +10,7 @@
#include <deque>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
@@ -22,6 +23,7 @@
#include "config.h"
#include "core_host.h"
#include "debug/debug_session.h"
#include "display_lock.h"
#include "desktop_nav_app.h"
#include "fonts.h"
#include "fullscreen_menu.h"
@@ -113,6 +115,7 @@ struct CliArgs {
bool perfInput = false; // dev: with --perf-frames, inject a key event every 30 iterations (input-latency column)
int lateLatch = -1; // dev: --late-latch on|off, threaded + vsync only (default on; #967 measurement)
int paceSteer = -1; // dev: --pace-steer on|off, threaded + vsync only (default on; #967 evenness)
int pacing = -1; // dev: --pacing jaguar|display, Video > Frame pacing for this run (not saved; #1038)
int emuWake = -1; // dev: --emu-wake precise|plain, the emulation thread's wake-ups (default precise)
int latchWake = -1; // dev: --latch-wake timer|plain, how the UI thread's timed event waits end (default timer; #1026)
// RetroAchievements (#836): the headless test hooks. There is no way to sign in
@@ -291,6 +294,11 @@ public:
// Video settings (persisted)
PresentOptions present;
bool vsync = true;
// Video > Frame pacing (video.pacing): Match display = mode B (#1038, display_lock.h), emulation thread only. Off =
// Jaguar rate (mode A), the default. Read live after every swap: no restart.
bool pacingDisplay = false;
std::string pacingStatus() const; // Settings: what Frame pacing is doing right now
int pacingFromCli_ = -1; // --pacing: the value this run started with, never saved over the setting
float volume = 1.0f;
bool mute = false;
float audioTargetMs = 64.0f;
@@ -503,6 +511,39 @@ private:
bool paceSteerOn_ = true;
std::atomic<uint64_t> takeNs_{0}, takePeriodNs_{0};
void publishTakeGrid(uint64_t takeNs);
// Mode B (#1038, display_lock.h): the lock verdict, after every threaded swap, and the grid it hands the emulation
// thread (EmuThread::Client::displayGrid). The grid is one snapshot under gridMu_: the emulation thread must never
// see mode B with another verdict's k or base ratio.
struct ModeBGrid {
bool on = false;
uint64_t tick = 0; // the vblank this swap returned on (counted, never reset)
uint64_t tickNs = 0; // when (SDL_GetTicksNS)
uint64_t periodNs = 0; // LateLatch::measuredPeriodNs()
unsigned k = 1;
double baseRatio = 1.0;
};
static constexpr uint64_t kGridStaleNs = 250000000; // a grid not refreshed this long (the UI stopped swapping): mode A
DisplayLock displayLock_;
DisplayLockVerdict lockVerdict_; // UI thread: the last verdict
std::mutex gridMu_;
ModeBGrid grid_; // (under gridMu_)
uint64_t tickSeq_ = 0, tickNs_ = 0; // UI thread: the last tick published (tickNs_ 0 = not ticking)
double gridBase_ = 1.0; // UI thread: the base ratio published (moved only past kBaseStep)
static constexpr double kBaseStep = 0.0002; // (AudioOut::setBaseRatio restarts the trim: not on every re-measure)
LockReason noticeReason_ = LockReason::Setting, heldReason_ = LockReason::Setting; // UI thread: displayLockNotice()
unsigned heldSwaps_ = 0;
LockReason loggedReason_ = LockReason::Setting; // UI thread: the last reason logged
std::atomic<uint64_t> publishedTick_{0}; // emulation thread: the tick of the newest frame it finished (wake on publish)
std::atomic<int> lockReasonCode_{0}; // UI -> emulation thread (VJ_PERF_TRACE lock_reason)
uint64_t frameTick_ = 0; // emulation thread: the tick the frame about to run started on (0 = mode A)
bool audioModeB_ = false; // emulation thread: the audio controller in charge (mode B: AudioOut's)
double frameBase_ = 1.0; // emulation thread: the base ratio of the grid the frame started on
static constexpr Sint32 kFrameReadyCode = 0x564A4652; // 'VJFR': the emulation thread finished a tick's frame
static bool isFrameReady(const SDL_Event& e) { return e.type == SDL_EVENT_USER && e.user.code == kFrameReadyCode; }
void displayLockAfterSwap(uint64_t swap0, uint64_t swap1);
void displayLockNotice();
void displayChanged(); // the window moved display, its mode changed, fullscreen: measure again
static const char* lockReasonText(LockReason r);
uint64_t latchedAtNs_ = 0; // when this iteration's wait ended: its UI work starts there
unsigned latchChecks_ = 0;
bool latchLogged_ = false;
+21 -3
View File
@@ -215,7 +215,20 @@ void EmuThread::loop() {
pacing = true;
}
double due = in.now;
if (!budget) {
Client::DisplayGrid g;
const bool ticks = !budget && !in.fastForward && client_->displayGrid(in.now, g) && g.period > 0;
if (ticks) {
// Mode B: the UI's latest tick, or the grid's own once the UI is kTickSlack late for the next one.
const double over = in.now - g.tickAt - kTickSlack;
in.tickSeq = g.tick + (over > 0 ? (uint64_t)(over / g.period) : 0);
in.ticksPerFrame = std::max(1u, g.k);
if (!clock.tickDue(in)) {
const double backstop = g.tickAt + (double)(clock.nextTick() - g.tick) * g.period + kTickSlack;
cmds_.waitUntil(atSec(std::min(std::max(backstop, in.now + 0.0002), in.now + 0.1))); // or notifyTick()
continue;
}
due = g.tickAt + ((double)in.tickSeq - (double)g.tick) * g.period; // the vblank this frame starts on
} else if (!budget) {
due = clock.nextDue(in);
if (due > in.now + 0.0002) {
// Precise wake (default): the condition wait (woken by commands) ends kPreciseSec before the due time
@@ -229,8 +242,12 @@ void EmuThread::loop() {
continue;
}
}
clock.frameStarted(in);
if (!budget) client_->framePaced(in.now - due, clock);
if (ticks) clock.tickStarted(in);
else clock.frameStarted(in); // (an advance() frame in mode B leaves the ticks alone: mode A's state is reset on return)
if (!budget) {
modeB_.store(ticks);
client_->framePaced(in.now - due, clock);
}
client_->runFrame();
clock.frameEnded(nowSec());
frames_.fetch_add(1);
@@ -239,6 +256,7 @@ void EmuThread::loop() {
advanceLeft_.fetch_sub(1);
}
dropped_.store(clock.droppedFrames());
droppedTicks_.store(clock.droppedTicks());
steers_.store(clock.steers());
notifyFrame();
}
+21 -2
View File
@@ -1,7 +1,8 @@
// The emulation thread (#967, docs/app-emulation-thread-design.md): the one thread that calls into the core.
//
// Threaded: a std::thread runs the frame loop: apply queued commands, wait until PacingClock says a frame is due (or
// for a command while paused / idle), run one frame through the Client, repeat. The UI thread talks to it only
// for a command while paused / idle), run one frame through the Client, repeat. Mode B (#1038): while the Client
// reports a display grid, frames start on its vblank ticks instead (PacingClock's tick path). The UI thread talks to it only
// through post() (fire and forget; applied at the next frame boundary, in order), call() (runs on the emulation
// thread and waits for the result: at most one frame) and the per-frame publishers (FrameExchange, CoreHost's
// EmuSnapshot, InputSnapshot).
@@ -58,6 +59,16 @@ public:
// Where the UI takes finished frames, for PacingClock's steering: one take time (seconds, on `now`'s clock:
// steady_clock) and the take period. False = no take grid (vsync off, swaps not tracked): no steering.
virtual bool takeGrid(double now, double& at, double& period) { (void)now, (void)at, (void)period; return false; }
// Mode B (#1038, display lock): the display grid the UI publishes after each swap. False = mode A (the setting,
// no lock, fast-forward, link play, or a grid the UI has not refreshed for a while: a UI that stops swapping --
// a modal, a live resize, a sleeping display -- must not stop the emulation).
struct DisplayGrid {
uint64_t tick = 0; // the vblank the UI last swapped on (a count that only goes up)
double tickAt = 0; // when it was, on `now`'s clock (steady_clock seconds)
double period = 0; // the measured vblank period, seconds
unsigned k = 1; // vblanks per emulated frame
};
virtual bool displayGrid(double now, DisplayGrid& g) { (void)now, (void)g; return false; }
virtual void afterCommands() {} // a batch of commands ran (App: CoreHost::publishSnapshot)
virtual void commandFailed(const std::string& what) { (void)what; } // a post()ed command threw
};
@@ -130,6 +141,11 @@ public:
void setFastForward(bool on) {
if (ff_.exchange(on) != on) cmds_.notify();
}
// Mode B: the UI published a new display tick (Client::displayGrid): start its frame now.
void notifyTick() { cmds_.notify(); }
// Mode B: when the UI's tick is late (it is still building, or stopped swapping), a frame starts this long after the
// vblank the grid says it is due on, so the game keeps its rate without the UI.
static constexpr double kTickSlack = 0.002;
static constexpr std::chrono::milliseconds kBusyAfter{250};
// Called on the thread waiting in call() once it has waited kBusyAfter, then about every 16 ms until the call
// returns (tick 0 first). See the contract in docs/app-emulation-thread-plan-phase2.md (Task 7): it may pump OS
@@ -143,6 +159,8 @@ public:
uint64_t framesRun() const { return frames_.load(); }
uint64_t droppedFrames() const { return dropped_.load(); }
uint64_t phaseSteps() const { return steers_.load(); } // PacingClock::steers()
uint64_t droppedTicks() const { return droppedTicks_.load(); } // mode B: PacingClock::droppedTicks()
bool modeB() const { return modeB_.load(); } // the last paced frame started on a display tick
private:
friend struct EmuThreadTestAccess; // test-only inspection of the advance/queue publication boundary
@@ -164,7 +182,8 @@ private:
std::atomic<double> targetMs_{64.0};
std::atomic<unsigned> advanceLeft_{0};
std::atomic<uint64_t> advanceDone_{0}; // budgeted frames run (bumped before advanceLeft_ drops)
std::atomic<uint64_t> frames_{0}, dropped_{0}, steers_{0};
std::atomic<uint64_t> frames_{0}, dropped_{0}, steers_{0}, droppedTicks_{0};
std::atomic<bool> modeB_{false};
std::mutex mu_;
std::condition_variable cv_; // start and finish handshakes
std::condition_variable advanceCv_; // a frame ran
+1
View File
@@ -24,6 +24,7 @@ public:
float aspect = 4.0f / 3.0f; // CoreHost::aspect() when the frame was made
uint64_t epoch = 0; // stamped by writeBuffer()
uint64_t inputSeq = 0; // the InputSnapshot publish this frame's poll took (event -> present, VJ_PERF_CSV)
uint64_t tick = 0; // mode B: the display tick the frame started on (0 = mode A)
};
// ---- writer (the emulation thread) ----
+57 -1
View File
@@ -65,6 +65,8 @@ public:
static constexpr unsigned kMinOutcomes = 8; // waits observed before the miss rate can release the lock
static constexpr unsigned kCooldownSwaps = 300; // swaps after a release before the lock may be taken again
static constexpr unsigned kMaxLateExempt = 8; // consecutive late-start waits excused; after that they count
static constexpr unsigned kMeasureMin = 120; // vblanks spanned before the measured period is trusted (2 s)
static constexpr unsigned kMeasureMax = 1200; // ... and before the estimate starts a fresh span (20 s)
// The display's refresh period (SDL_DisplayMode), 0 = unknown: the latch stays off.
void setPeriodNs(uint64_t ns) {
@@ -76,8 +78,28 @@ public:
targetNs_ = 0;
outcomeAt_ = recentMisses_ = 0;
cooldown_ = 0;
resetMeasure();
}
uint64_t periodNs() const { return periodNs_; } // the display mode's nominal period
// The display's real vblank period (mode B, #1038: the display lock follows it, not the mode's nominal rate), measured
// over the tracked vblanks since the lock: the tracked phase's travel divided by the vblanks it spanned. A 59.94 Hz
// display announced as 60 Hz reads 16.683 ms. 0 until kMeasureMin vblanks were spanned; restarts when the grid is
// lost (an unlock, a new period) or on resetMeasure() (a display event). Spans of kMeasureMax vblanks: a fresh span
// starts there, and the last span's value stands until the new one has kMeasureMin.
uint64_t measuredPeriodNs() const { return measuredNs_; }
void resetMeasure() {
measAnchorNs_ = measLastNs_ = measuredNs_ = 0;
measCount_ = 0;
}
// The vblank nearest `ns` on the tracked grid (0 before the first observation).
uint64_t nearestVblankNs(uint64_t ns) const {
if (!havePhase_ || !periodNs_) return 0;
const int64_t P = (int64_t)periodNs_;
int64_t r = (int64_t)(ns - phaseNs_) % P;
if (r < 0) r += P;
if (r > P / 2) r -= P;
return ns - (uint64_t)r;
}
uint64_t periodNs() const { return periodNs_; }
// The UI's own work in one iteration: from the end of the wait to the start of the swap.
void noteBuild(uint64_t ns) {
@@ -148,6 +170,7 @@ public:
phaseNs_ = endNs - (uint64_t)(r - r / 4); // move a quarter of the way toward the observation
outliers_ = 0;
if (++tracked_ >= kLockAfter && jitterNs_ < kJitterMaxNs && !cooldown_) locked_ = true;
if (locked_) measure();
} else if (++outliers_ >= kUnlockAfter) { // the grid moved (a new display mode, a compositor hiccup)
unlock();
phaseNs_ = endNs;
@@ -169,6 +192,16 @@ public:
return wakeNs_;
}
// Mode B (#1038): the latest the UI may still start building for the vblank wakeAt() aimed at, while it waits for
// the frame of the tick it just published: the build estimate without the pad (the pad is for the wait's own
// overshoot; here a frame that arrives later is shown a vblank later anyway, so waiting longer costs nothing but a
// possible repeat). 0 when wakeAt() sized no wait. A frame that finishes by then makes the vblank, as in the
// single-thread loop; one that does not is taken at the next one.
uint64_t lastChanceNs() const {
if (!targetNs_) return 0;
const uint64_t build = budgetNs() - padNs_;
return targetNs_ > build ? targetNs_ - build : 0;
}
bool locked() const { return locked_; }
uint64_t targetNs() const { return targetNs_; } // the vblank the wait wakeAt() just sized aims at, 0 = none
uint64_t padNs() const { return padNs_; }
@@ -195,6 +228,27 @@ private:
locked_ = false;
tracked_ = outliers_ = nonBlocking_ = 0;
outcomeAt_ = recentMisses_ = 0;
resetMeasure();
}
// A tracked vblank while locked: the phase moved to it. The vblanks between two observations are counted from that
// short gap (exact while the real period is within half a period's worth of the nominal one per gap), never from the
// whole span, where an off-nominal display (0.3% is 50 us a vblank) would round wrong after a few hundred. The
// phase follows the real grid with a constant lag (the quarter step), so its travel over the span is count x real.
void measure() {
if (!measAnchorNs_) {
measAnchorNs_ = measLastNs_ = phaseNs_;
measCount_ = 0;
return;
}
const uint64_t gap = phaseNs_ - measLastNs_;
measCount_ += (gap + periodNs_ / 2) / periodNs_;
measLastNs_ = phaseNs_;
if (measCount_ >= kMeasureMin) measuredNs_ = (phaseNs_ - measAnchorNs_) / measCount_;
if (measCount_ >= kMeasureMax) {
measAnchorNs_ = phaseNs_;
measCount_ = 0;
}
}
uint64_t periodNs_ = 0;
@@ -214,6 +268,8 @@ private:
uint64_t targetNs_ = 0; // the vblank the current wait aims at (0 = no wait this iteration)
uint64_t builds_[kBuilds] = {};
unsigned buildAt_ = 0;
uint64_t measAnchorNs_ = 0, measLastNs_ = 0, measuredNs_ = 0; // measuredPeriodNs(): span start, last phase, value
uint64_t measCount_ = 0; // vblanks spanned since measAnchorNs_
};
} // namespace vj
+9
View File
@@ -26,6 +26,7 @@ static void usage(const char* argv0) {
" --emu-thread on|off the emulation on its own thread for this run (default: Settings > Interface; headless: off)\n"
" --late-latch on|off dev: with the emulation thread and vsync, take each frame just before the vblank (default on)\n"
" --pace-steer on|off dev: with the emulation thread and vsync, keep frame ends off the take point (default on)\n"
" --pacing jaguar|display dev: Video > Frame pacing for this run (not saved; default: the setting)\n"
" --emu-wake precise|plain dev: the emulation thread's frame wake-ups (default precise)\n"
" --latch-wake timer|plain dev: macOS, how the UI thread's timed event waits end (default timer; plain keeps the QoS raise)\n"
" --data-dir DIR keep config, saves and states in DIR\n"
@@ -216,6 +217,14 @@ int main(int argc, char** argv) {
}
a.lateLatch = v == "on" ? 1 : 0;
}
else if (!std::strcmp(s, "--pacing")) {
const std::string v = need(s);
if (v != "jaguar" && v != "display") {
std::fprintf(stderr, "--pacing wants jaguar or display, got '%s'\n", v.c_str());
return 2;
}
a.pacing = v == "display" ? 1 : 0;
}
else if (!std::strcmp(s, "--pace-steer")) {
const std::string v = need(s);
if (v != "on" && v != "off") {
+4
View File
@@ -124,6 +124,7 @@ public:
enterTicks(in);
lastStart_ = in.now;
curDue_ = -1;
startedSeq_ = in.tickSeq;
if (in.tickSeq < nextSeq_) { // the refill's extra frame
extraRan_ = true;
refillFrames_++;
@@ -152,6 +153,8 @@ public:
bool audioLocked() const { return audio_ && !ticking_; }
double trim() const { return audio_ && !ticking_ ? trim_ : 1.0; }
bool ticking() const { return ticking_; } // mode B
uint64_t nextTick() const { return nextSeq_; } // mode B: the tick the next on-tick frame is due at
uint64_t startedTick() const { return startedSeq_; } // mode B: the tick the frame started last started on
uint64_t droppedTicks() const { return droppedTicks_; } // mode B ticks dropped behind a stall (VJ_PERF_TRACE)
uint64_t refillFrames() const { return refillFrames_; } // mode B extra frames run to refill the queue
uint64_t droppedFrames() const { return dropped_; }
@@ -288,6 +291,7 @@ private:
uint64_t lastTickStart_ = UINT64_MAX; // mode B: the tick the last on-tick frame started on
bool dropRefill_ = false; // mode B: ticks were dropped; refill to the target
uint64_t droppedTicks_ = 0, refillFrames_ = 0;
uint64_t startedSeq_ = 0; // mode B: startedTick()
};
} // namespace vj
+10 -3
View File
@@ -30,6 +30,11 @@ struct PerfTrace {
uint64_t dueNs, startNs, endNs, serial, fillNs;
float trim, filtQMs, queuedMs;
unsigned char audio, refill;
// Mode B (#1038): the frame started on a display tick (mode 1) and which; the ticks dropped so far; the lock
// verdict's reason (display_lock.h LockReason: 0 locked, 1 setting, 2 no_vsync, 3 unlocked, 4 ratio,
// 5 settling, 6 fast_forward, 7 link).
unsigned char mode, lockReason;
uint64_t tickSeq, ticksDropped;
};
struct Ui {
uint64_t takeNs, serial, swap0Ns, swap1Ns, wakeNs, waitEndNs, budgetNs, targetNs, waitOverNs, waitCpuNs;
@@ -66,13 +71,15 @@ struct PerfTrace {
if (!enabled()) return;
const size_t n = emuN_.load(std::memory_order_acquire);
if (FILE* f = std::fopen((prefix + "-emu.csv").c_str(), "w")) {
std::fprintf(f, "due_ns,start_ns,end_ns,serial,trim,filt_q_ms,queued_ms,audio,refill,fill_ns\n");
std::fprintf(f, "due_ns,start_ns,end_ns,serial,trim,filt_q_ms,queued_ms,audio,refill,fill_ns,mode,tick_seq,"
"ticks_dropped,lock_reason\n");
for (size_t i = 0; i < n; i++) {
const Emu& e = emu_[i];
std::fprintf(f, "%llu,%llu,%llu,%llu,%.6f,%.3f,%.3f,%d,%d,%llu\n", (unsigned long long)e.dueNs,
std::fprintf(f, "%llu,%llu,%llu,%llu,%.6f,%.3f,%.3f,%d,%d,%llu,%d,%llu,%llu,%d\n", (unsigned long long)e.dueNs,
(unsigned long long)e.startNs, (unsigned long long)e.endNs, (unsigned long long)e.serial,
(double)e.trim, (double)e.filtQMs, (double)e.queuedMs, (int)e.audio, (int)e.refill,
(unsigned long long)e.fillNs);
(unsigned long long)e.fillNs, (int)e.mode, (unsigned long long)e.tickSeq,
(unsigned long long)e.ticksDropped, (int)e.lockReason);
}
std::fclose(f);
}
+1 -1
View File
@@ -20,7 +20,7 @@ struct Exact {
const Exact kExact[] = {
{"video.integer", KeyClass::Video}, {"video.linear", KeyClass::Video},
{"video.stretch", KeyClass::Video}, {"video.aspect", KeyClass::Video},
{"video.vsync", KeyClass::Video},
{"video.vsync", KeyClass::Video}, {"video.pacing", KeyClass::Video},
// shader.folder is a path the user picked, like a library folder: kept.
{"shader.enabled", KeyClass::Shader}, {"shader.preset", KeyClass::Shader},
{"audio.volume", KeyClass::Audio}, {"audio.mute", KeyClass::Audio},
+8
View File
@@ -261,6 +261,12 @@ std::vector<SettingRow> AppHost::settingRows() {
add(pictureRow("stretch", "Stretch to window (ignore aspect ratio)", app_.present.stretch));
add(aspectRow(app_.present.aspectOverride, kAspectChoices, kAspectChoiceCount));
add(pictureRow("vsync", "VSync", app_.vsync));
{
SettingRow r = choiceRow("pacing", "Frame pacing", "picture", "Picture", {"Jaguar rate", "Match display"},
app_.pacingDisplay ? 1 : 0);
r.note = app_.pacingStatus();
add(std::move(r));
}
add(pictureRow("fullscreen", "Fullscreen", app_.fullscreen));
// ---- Sound
@@ -380,6 +386,8 @@ void AppHost::setSetting(const std::string& id, int index) {
} else if (id == "vsync") {
app_.vsync = on;
app_.applyVsync();
} else if (id == "pacing") {
app_.pacingDisplay = index == 1;
} else if (id == "fullscreen") {
app_.setFullscreen(on);
} else if (id == "volume") {
+12
View File
@@ -188,6 +188,18 @@ void drawVideoTab(App& app) {
auto aspectLabel = [](void*, int i) { return kAspectChoices[i].label; };
if (ImGui::Combo("Aspect ratio", &sel, aspectLabel, nullptr, kAspectChoiceCount)) p.aspectOverride = kAspectChoices[sel].value;
if (ImGui::Checkbox("VSync", &app.vsync)) app.applyVsync();
int pace = app.pacingDisplay ? 1 : 0;
const char* paceChoices[] = {"Jaguar rate", "Match display"};
ImGui::SetNextItemWidth(ui::fitWidth(0.4f, 9.0f, 16.0f));
if (ImGui::Combo("Frame pacing", &pace, paceChoices, 2)) app.pacingDisplay = pace == 1;
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Jaguar rate: the console's own 60.05 / 50.08 Hz, kept by the sound card. A 60 Hz display shows one\n"
"frame twice about every 20 seconds.\n"
"Match display: one frame per refresh (or per two) where the display runs at a multiple of the\n"
"game's rate (60, 59.94, 120 Hz for NTSC games; 50, 100 Hz for PAL), the sound following it.\n"
"Elsewhere it says why and uses the Jaguar rate. Needs the emulation thread and vsync.");
const std::string paceNow = app.pacingStatus();
if (!paceNow.empty()) ui::hint("%s", paceNow.c_str());
bool fs = app.fullscreen;
if (ImGui::Checkbox("Fullscreen (F11)", &fs)) app.setFullscreen(fs);
drawShaderSettings(app);
+105
View File
@@ -755,6 +755,110 @@ void testAdvanceBudgetDoesNotOvertakeFramePost() {
CHECK(e.stop(), "stop");
}
// Mode B (#1038): a fake UI publishing display ticks. Frames only on ticks (k per frame), owed ticks dropped behind a
// jump, the grid's own backstop when the UI stops ticking, mode A again without a grid; pause, advance() and commands as
// in mode A. Deadline polls and frame counts only: no latency ceilings (a starved runner only makes things slower).
struct TickClient : FakeClient {
std::mutex gm;
bool on = false;
EmuThread::Client::DisplayGrid grid; // (under gm)
bool displayGrid(double now, DisplayGrid& g) override {
(void)now;
std::lock_guard<std::mutex> l(gm);
if (on) g = grid;
return on;
}
void tick(EmuThread& e, uint64_t seq, unsigned k = 1, double period = 10.0) { // a 10 s period: no backstop
{
std::lock_guard<std::mutex> l(gm);
on = true;
grid.tick = seq;
grid.tickAt = std::chrono::duration<double>(Clock::now().time_since_epoch()).count();
grid.period = period;
grid.k = k;
}
e.notifyTick();
}
void off(EmuThread& e) {
{
std::lock_guard<std::mutex> l(gm);
on = false;
}
e.notifyTick();
}
};
bool waitFrames(const EmuThread& e, uint64_t n) {
for (int i = 0; i < 5000 && e.framesRun() < n; i++) sleepMs(1);
return e.framesRun() >= n;
}
void testDisplayTicks() {
EmuThread e;
TickClient c;
c.period = 0.001; // mode A would run 1000 frames a second
c.tick(e, 100);
CHECK(e.start(EmuThread::Mode::Threaded, c), "start");
sleepMs(100);
CHECK(e.framesRun() == 0, "entering mode B, the first frame waits for the next tick (%llu)", (unsigned long long)e.framesRun());
c.tick(e, 101);
CHECK(waitFrames(e, 1), "tick 101: one frame");
sleepMs(100);
CHECK(e.framesRun() == 1 && e.modeB(), "and only one, on a tick (%llu)", (unsigned long long)e.framesRun());
for (uint64_t t = 102; t <= 104; t++) {
c.tick(e, t);
CHECK(waitFrames(e, t - 100), "tick %llu: its frame", (unsigned long long)t);
}
// k = 2: a new multiple settles on the next tick, then one frame every two ticks.
c.tick(e, 105, 2);
sleepMs(50);
const uint64_t at = e.framesRun();
c.tick(e, 106, 2);
CHECK(waitFrames(e, at + 1), "k=2: the first frame on the next tick");
c.tick(e, 107, 2);
sleepMs(100);
CHECK(e.framesRun() == at + 1, "k=2: nothing on the tick between (%llu)", (unsigned long long)(e.framesRun() - at));
c.tick(e, 108, 2);
CHECK(waitFrames(e, at + 2), "k=2: the next frame two ticks later");
// Commands are served while it waits for a tick.
const auto t0 = Clock::now();
CHECK(e.call([] { return 5; }) == 5, "a call() is answered between ticks (%.3f s)", since(t0));
// A jump of 10 ticks (a stall): the owed ticks are dropped, one catch-up frame runs, never a burst.
c.tick(e, 108, 1);
sleepMs(50);
const uint64_t b0 = e.framesRun(), d0 = e.droppedTicks();
c.tick(e, 109, 1);
CHECK(waitFrames(e, b0 + 1), "k=1 again: a frame on the next tick");
c.tick(e, 119, 1);
CHECK(waitFrames(e, b0 + 3), "a 10-tick jump: the frame on it and one catch-up frame");
sleepMs(100);
CHECK(e.framesRun() == b0 + 3, "no burst behind the jump (%llu frames)", (unsigned long long)(e.framesRun() - b0));
CHECK(e.droppedTicks() - d0 == 8, "the 8 owed ticks between were dropped (%llu)", (unsigned long long)(e.droppedTicks() - d0));
// Paused: no frames on ticks; advance() still runs its frames.
e.setPaused(true);
sleepMs(20);
const uint64_t p0 = e.framesRun();
c.tick(e, 120);
c.tick(e, 121);
sleepMs(100);
CHECK(e.framesRun() == p0, "paused: ticks run nothing");
const auto a0t = Clock::now();
CHECK(e.advance(2) && e.framesRun() == p0 + 2, "paused: advance(2) runs two frames without ticks");
CHECK(since(a0t) < 2.0, "without waiting for one (the next is 10 s away): no hang (%.3f s)", since(a0t));
e.setPaused(false);
// The UI stops ticking: the grid's own backstop keeps frames coming at the display rate.
c.tick(e, 130, 1, 0.005);
const uint64_t s0 = e.framesRun();
CHECK(waitFrames(e, s0 + 20), "no UI ticks: the grid's backstop keeps running frames (%llu)", (unsigned long long)(e.framesRun() - s0));
// No grid (mode A): frames on the clock again.
c.off(e);
const uint64_t a0 = e.framesRun();
CHECK(waitFrames(e, a0 + 50), "no grid: mode A runs frames on its own");
sleepMs(20);
CHECK(!e.modeB(), "and says so");
CHECK(e.stop(), "stop");
}
int main() {
#ifdef _WIN32
// The app runs EmuThread after SDL_Init, which sets the Windows timer resolution to 1 ms
@@ -782,6 +886,7 @@ int main() {
testAdvanceGivesUp();
testCallGiveUpLosesToStart();
testAdvanceBudgetDoesNotOvertakeFramePost();
testDisplayTicks();
#ifdef _WIN32
timeEndPeriod(1);
#endif
+147
View File
@@ -4,6 +4,8 @@
// "Decided: input latency in mode A").
#include "late_latch.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <vector>
@@ -293,6 +295,64 @@ void testOffNominal() {
CHECK(l.misses() == 0, "no misses (%u)", l.misses());
}
// The measured period (mode B, #1038): the real grid, whatever the mode announces; 0 until two seconds were spanned.
void testMeasuredPeriod() {
struct Case {
uint64_t nominal, real;
double jitterNs;
const char* what;
};
const Case cases[] = {
{kP60, kP60, 0, "60 Hz announced as 60"},
{kP60, 16683350, 0, "59.94 Hz announced as 60"},
{kP60, 16716667, 300000, "0.3% slow, 0.3 ms jitter"},
{8333333, 8341675, 200000, "119.88 Hz announced as 120, 0.2 ms jitter"},
};
for (const Case& c : cases) {
LateLatch l;
l.setPeriodNs(c.nominal);
uint64_t v = kV0;
unsigned firstAt = 0;
double worst = 0;
for (unsigned k = 1; k <= 3000; k++) {
v += c.real;
const uint64_t end = v + 50000 + (uint64_t)(unit() * c.jitterNs);
l.noteBuild(2 * kMs);
l.onSwap(end - 3 * kMs, end);
if (l.measuredPeriodNs() && !firstAt) firstAt = k;
if (l.measuredPeriodNs() && k > 600) {
const double e = std::fabs((double)l.measuredPeriodNs() - (double)c.real) / (double)c.real;
worst = std::max(worst, e);
}
}
CHECK(firstAt >= LateLatch::kLockAfter + LateLatch::kMeasureMin && firstAt < LateLatch::kLockAfter + LateLatch::kMeasureMin + 20,
"%s: measured after the lock plus %u vblanks (swap %u)", c.what, LateLatch::kMeasureMin, firstAt);
CHECK(worst < 0.0002, "%s: within 0.02%% of the real period past 10 s, across fresh spans (%.4f%%)", c.what, worst * 100);
}
// Lost grid: an unlock or a new period starts over; resetMeasure() too.
LateLatch l;
l.setPeriodNs(kP60);
uint64_t end = 0;
runGrid(l, kP60, 300, end);
CHECK(l.measuredPeriodNs() > 0, "measured on an exact grid");
l.resetMeasure();
CHECK(l.measuredPeriodNs() == 0, "resetMeasure() clears it");
runGrid(l, kP60, 300, end);
CHECK(l.measuredPeriodNs() > 0, "and it comes back");
l.setPeriodNs(8333333);
CHECK(l.measuredPeriodNs() == 0, "a new nominal period clears it");
LateLatch u;
u.setPeriodNs(kP60);
runGrid(u, kP60, 300, end);
for (unsigned k = 0; k < LateLatch::kUnlockAfter * 8; k++) u.onSwap(end + k * kP60, end + k * kP60 + 1000); // non-blocking
CHECK(!u.locked() && u.measuredPeriodNs() == 0, "an unlock clears it");
const uint64_t at = kV0 + 7 * kP60 + 50000;
LateLatch nv;
nv.setPeriodNs(kP60);
runGrid(nv, kP60, 50, end);
CHECK(nv.nearestVblankNs(at + 3 * kMs) - at < 200000, "nearestVblankNs: the grid point near a time");
}
// ---- the pipeline model ---------------------------------------------------------------------------------------------
struct Iter {
@@ -406,6 +466,91 @@ void testModel() {
CHECK(b.misses * 100 <= b.iters, "at most 1%% missed vblanks (%u of %u)", b.misses, b.iters);
}
// Mode B (#1038): every frame starts on a display tick, just after the swap that returned there (the UI hands its
// input over first), or 2 ms past the vblank when the UI missed it (the emulation thread's own backstop). The UI's
// wait ends when the tick's frame is finished, or at LateLatch::lastChanceNs() without it. Same display, builds and
// events as model(). Returns event -> present for mode B and inline, the misses, and how many frames were shown one
// period after their tick.
struct ResultB {
double inlineMs, modeBMs;
unsigned misses, iters, onePeriod;
};
ResultB modelB(uint64_t frameNs) {
const uint64_t kEnd = 50000, kWork = 200000, kPump = 50000, kStart = 200000, kSlack = 2000000;
const unsigned K = 22000;
LateLatch l;
l.setPeriodNs(kP60);
g_rng = 777;
std::vector<uint64_t> start(K + 4, 0), fin(K + 4, 0); // per vblank: its tick's frame
std::vector<uint64_t> shownAt; // per iteration: the vblank its swap returned at ...
std::vector<uint64_t> shownFrame; // ... and the frame (tick) it showed
ResultB r = {};
uint64_t m = 0;
start[0] = kV0 + kEnd + kPump + kStart;
fin[0] = start[0] + frameNs;
uint64_t newest = 0;
for (unsigned k = 0; k < K && m + 2 < K; k++) {
const uint64_t now = kV0 + m * kP60 + kEnd + kWork;
uint64_t w = l.wakeAt(now);
if (w < now) w = now;
const uint64_t until = std::max(w, l.lastChanceNs());
const uint64_t end = fin[m] <= now ? now : (fin[m] <= until ? fin[m] : until);
while (newest < m && fin[newest + 1] <= end) newest++;
if (fin[m] <= end) newest = m;
const uint64_t take = end + kPump;
const uint64_t build = (uint64_t)(1.7e6 + unit() * 0.6e6);
const uint64_t begin = take + build;
const uint64_t n = (begin - kV0 + kP60 - 1) / kP60;
l.noteWaitEnd(end);
l.noteBuild(begin - end);
l.onSwap(begin, kV0 + n * kP60 + kEnd);
shownAt.push_back(kV0 + n * kP60);
shownFrame.push_back(newest);
if (newest == m && n == m + 1) r.onePeriod++;
for (uint64_t v = m + 1; v <= n && v < K + 4; v++) { // the ticks up to this swap's: missed ones on the backstop
start[v] = kV0 + v * kP60 + (v == n ? kEnd + kPump + kStart : kSlack + kStart);
start[v] = std::max(start[v], fin[v - 1]);
fin[v] = start[v] + frameNs;
}
m = n;
r.iters++;
}
r.misses = l.misses();
double sum = 0, inl = 0;
unsigned cnt = 0;
size_t it = 0;
uint64_t v = 0;
g_rng = 4242;
for (uint64_t e0 = kV0 + 120 * kP60; e0 + 240 * kP60 < shownAt.back(); e0 += kP60) {
const uint64_t e = e0 + (uint64_t)(unit() * (double)kP60);
while (start[v] < e) v++; // the first frame to poll it
while (it < shownFrame.size() && shownFrame[it] < v) it++; // the first swap to show that frame (or a newer one)
if (it >= shownFrame.size()) break;
sum += (double)(shownAt[it] - e);
const uint64_t vi = (e - kV0 + kP60 - 1) / kP60;
inl += (double)(kV0 + (vi + 1) * kP60 - e);
cnt++;
}
r.modeBMs = sum / cnt / 1e6;
r.inlineMs = inl / cnt / 1e6;
return r;
}
void testModelB() {
for (uint64_t f : {5 * kMs, 12 * kMs, 14 * kMs}) {
const ResultB b = modelB(f);
std::printf("model B, %2llu ms frames: inline %.1f ms, mode B %.1f ms, shown one period after the tick %u of %u, "
"misses %u\n",
(unsigned long long)(f / kMs), b.inlineMs, b.modeBMs, b.onePeriod, b.iters, b.misses);
CHECK(b.modeBMs <= b.inlineMs + 2.0, "%llu ms frames: mode B is within 2 ms of inline (%.2f vs %.2f)",
(unsigned long long)(f / kMs), b.modeBMs, b.inlineMs);
CHECK(b.onePeriod * 100 >= b.iters * 99, "%llu ms frames: 99%% shown one period after their tick (%u of %u)",
(unsigned long long)(f / kMs), b.onePeriod, b.iters);
CHECK(b.misses * 100 <= b.iters, "%llu ms frames: at most 1%% missed vblanks (%u of %u)",
(unsigned long long)(f / kMs), b.misses, b.iters);
}
}
// #967 final-review minor 3: with vsync off (or a swap that did not block), the threaded loop naps until the display's
// next refresh instead of a flat 4 ms (which presented at ~250 Hz for nothing).
void testIdleNap() {
@@ -474,7 +619,9 @@ int main() {
testLateStartIsNotAMiss();
testLateStartsAreCapped();
testOffNominal();
testMeasuredPeriod();
testModel();
testModelB();
testIdleNap();
testPresentGrid();
testCompositorDeadline();
+13 -1
View File
@@ -35,6 +35,18 @@ def exact_latency(emu, ui):
lat.append((sw[r] - e) / 1e6)
e += 5310000
return sum(lat) / len(lat) if lat else float('nan'), len(lat)
REASONS = ['locked', 'setting', 'no_vsync', 'unlocked', 'ratio', 'settling', 'fast_forward', 'link']
def mode_b(emu):
"""Mode B (#1038) columns, when the trace has them: the share of frames started on a display tick, the ticks
dropped over the window, and the lock reasons seen (most frequent first)."""
if not emu or 'mode' not in emu[0]: return ''
b = sum(1 for e in emu if e['mode'] == '1')
dropped = int(emu[-1]['ticks_dropped']) - int(emu[0]['ticks_dropped'])
seen = {}
for e in emu:
r = int(e['lock_reason']); seen[r] = seen.get(r, 0) + 1
why = ' '.join(f"{REASONS[r] if r < len(REASONS) else r}:{n}" for r, n in sorted(seen.items(), key=lambda x: -x[1]))
return f" | mode B {b}/{len(emu)} frames, ticks dropped {dropped}, reasons {why}"
for pre in sys.argv[1:]:
emu = list(csv.DictReader(open(pre + '-emu.csv')))
ui = list(csv.DictReader(open(pre + '-ui.csv')))
@@ -73,4 +85,4 @@ for pre in sys.argv[1:]:
f"end-due p1-p99 spread {pct(fin,.99)-pct(fin,.01):.2f} ms | take-after-vblank p1 {pct(tk,.01):.2f} p50 {pct(tk,.5):.2f} p99 {pct(tk,.99):.2f} | "
f"trim [{min(trim):.4f},{max(trim):.4f}] phase wander {hi-lo:.2f} ms | "
f"ui-misses {misses} late-waits>3ms {late_waits} | "
f"event->present exact avg {lat_avg:.2f} ms (n={lat_n})")
f"event->present exact avg {lat_avg:.2f} ms (n={lat_n})" + mode_b(emu))
+16 -6
View File
@@ -59,6 +59,10 @@ int main(int argc, char** argv) {
e.endNs = 300 + i;
e.serial = 7 + i;
e.fillNs = i == 1 ? 4500 : 0;
e.mode = i == 2 ? 1 : 0;
e.tickSeq = i == 2 ? 123456789012ull : 0;
e.ticksDropped = i;
e.lockReason = i == 2 ? 0 : 4;
t.emu(e);
}
t.write();
@@ -69,18 +73,24 @@ int main(int argc, char** argv) {
CHECK(lines.size() == 4, "a header and three rows (%zu lines)", lines.size());
if (lines.size() == 4) {
const std::vector<std::string> hdr = split(lines[0]);
CHECK(hdr.size() == 10 && hdr.back() == "fill_ns", "fill_ns is the last of ten header columns: %s",
CHECK(hdr.size() == 14 && hdr[9] == "fill_ns", "fill_ns is the tenth of fourteen header columns: %s",
lines[0].c_str());
CHECK(lines[0].rfind("due_ns,start_ns,end_ns,serial,trim,filt_q_ms,queued_ms,audio,refill,", 0) == 0,
"the columns before fill_ns are unchanged: %s", lines[0].c_str());
CHECK(lines[0].rfind("due_ns,start_ns,end_ns,serial,trim,filt_q_ms,queued_ms,audio,refill,fill_ns,", 0) == 0,
"the columns through fill_ns are unchanged: %s", lines[0].c_str());
CHECK(lines[0].size() > 0 && lines[0].substr(lines[0].rfind("fill_ns,") + 8) == "mode,tick_seq,ticks_dropped,lock_reason",
"mode B's columns come after them (#1038): %s", lines[0].c_str());
for (size_t i = 1; i < lines.size(); i++) {
const std::vector<std::string> row = split(lines[i]);
CHECK(row.size() == hdr.size(), "row %zu has %zu fields, header %zu", i, row.size(), hdr.size());
if (row.size() != hdr.size()) continue;
const long long fill = std::atoll(row.back().c_str());
CHECK(fill >= 0 && !row.back().empty() && row.back()[0] != '-', "row %zu fill_ns >= 0 (%s)", i,
row.back().c_str());
const long long fill = std::atoll(row[9].c_str());
CHECK(fill >= 0 && !row[9].empty() && row[9][0] != '-', "row %zu fill_ns >= 0 (%s)", i, row[9].c_str());
CHECK(fill == (i == 2 ? 4500 : 0), "row %zu fill_ns round-trips (%lld)", i, fill);
const size_t r = i - 1;
CHECK(row[10] == (r == 2 ? "1" : "0") && row[11] == (r == 2 ? "123456789012" : "0") &&
row[12] == std::to_string(r) && row[13] == (r == 2 ? "0" : "4"),
"row %zu mode B columns round-trip (%s,%s,%s,%s)", i, row[10].c_str(), row[11].c_str(),
row[12].c_str(), row[13].c_str());
}
}
std::remove((prefix + "-emu.csv").c_str());
+2 -1
View File
@@ -55,7 +55,7 @@ static std::string slurp(const fs::path& p) {
static void fill(Config& c) {
const char* keys[] = {
// Video (+ Shader)
"video.integer", "video.linear", "video.stretch", "video.aspect", "video.vsync", "ui.show_fps", "ui.theme",
"video.integer", "video.linear", "video.stretch", "video.aspect", "video.vsync", "video.pacing", "ui.show_fps", "ui.theme",
"shader.enabled", "shader.preset", "shader.folder",
// Audio
"audio.volume", "audio.mute", "audio.target_ms",
@@ -93,6 +93,7 @@ static std::set<std::string> klass(const Config& c, KeyClass k) {
static void testClassify() {
CHECK(classifyKey("video.vsync") == KeyClass::Video);
CHECK(classifyKey("video.pacing") == KeyClass::Video); // Frame pacing (#1038)
CHECK(classifyKey("ui.show_fps") == KeyClass::Interface); // #933: the Interface tab owns the app's own look and behaviour
CHECK(classifyKey("ui.theme") == KeyClass::Interface); // Reset on that tab returns the theme to System
CHECK(classifyKey("ui.controls.autoshow") == KeyClass::Interface);
+1 -1
View File
@@ -2,7 +2,7 @@
# number of occurrences (grep -o) each (no_threaded_limits.sh, 5). More fails (a new gate?); fewer fails (tighten this file).
# Every hit selects HOW something runs on the emulation thread or the UI thread; none may disable, grey out, hide, refuse
# or lock a feature because the thread is on (#967 Phase 3 bar). Raise a count only together with the new line's reason.
app/src/app.cpp 51 # emuThreadWanted() definition and its use in the one run() decision; inline-vs-threaded plumbing: wireHost ff/mute/input/syncCore; start/stop/quit/detach; shownFrame, pump, present/latch/pacing, perf notes, SRAM flush, headless loops and logs; openContent/openNoContent/closeContent/reset/addDisc refuse while halted INLINE only (threaded detaches first); run(): the one decision, headless --tv-test-script stays inline; the start-up "this run" report (emuThreadThisRun_, emuThreadWanted(): a log line, not a gate)
app/src/app.cpp 52 # emuThreadWanted() definition and its use in the one run() decision; inline-vs-threaded plumbing: wireHost ff/mute/input/syncCore; start/stop/quit/detach; shownFrame, pump, present/latch/pacing, perf notes, SRAM flush, headless loops and logs; openContent/openNoContent/closeContent/reset/addDisc refuse while halted INLINE only (threaded detaches first); run(): the one decision, headless --tv-test-script stays inline; the start-up "this run" report (emuThreadThisRun_, emuThreadWanted(): a log line, not a gate); pacingStatus(): Frame pacing's live state names the thread Match display runs on (a status line; the choice stays enabled)
app/src/app.h 8 # declarations: emu, threaded() x2, emuThreadWanted(), emuThreadThisRun(), emuThreadThisRun_, pictureAspect() source of the aspect
app/src/core_host.cpp 7 # option events: queued to the UI when threaded, applied directly inline; snapshot publish and drain are threaded-only plumbing
app/src/core_host.h 1 # the definition of threaded() (owner_ set)
+20
View File
@@ -114,6 +114,26 @@ The UI never waits on emulation.
119.88 Hz lock NTSC, 50 / 100 Hz lock PAL, everything else falls back. Mode A is also forced while link play is
active, and in mode B the UI's latch wait ends when the tick's frame is published. Plan:
[app-emulation-thread-plan-phase4.md](app-emulation-thread-plan-phase4.md).
- *As built (phase 4b, #1038):*
- **The measured period** is `LateLatch::measuredPeriodNs()`: the tracked vblank phase's travel over 2-20 s spans,
counted vblank by vblank, so a 59.94 Hz panel announced as 60 Hz reads 16.683 ms. Mode B waits for it.
- **Ticks count vblanks, not swaps.** The UI publishes one snapshot after each blocking swap (tick, its vblank time,
the measured period, k, the base ratio) under a mutex, after handing over the input that arrived while it built
and swapped, so the tick's frame polls it, as in the single-thread loop. A UI iteration that misses a vblank moves
the tick by two, and the emulation thread runs the frame it owes.
- **The emulation thread does not depend on the UI swapping.** When the UI's next tick is 2 ms late, the thread
starts the frame on the snapshot's grid by itself; when the snapshot is 250 ms old (a modal, a live resize, a
sleeping display), it runs mode A until the UI ticks again. One non-blocking swap leaves mode B at once.
- **Wake on publish, then the last chance.** The UI's latch wait ends when the tick's frame is published (an SDL
event from the emulation thread). Past the latch's padded wake time it goes on waiting for that frame only, until
the vblank minus the build estimate without the pad (`LateLatch::lastChanceNs`): a 14 ms frame (Cybermorph)
finishes after the padded wake time but still fits the period, as it does inline. The pipeline model
(`late_latch_unit`) puts mode B at 24.7 ms event to present for 5, 12 and 14 ms frames, against inline's 25.0;
without the last chance a 14 ms frame takes 41.3 ms.
- **Audio**: one controller at a time, switched at a frame boundary: mode B sets `AudioOut::setBaseRatio((display /
k) / core)` and trims around it with `update(true)`; mode A sets the base back to 1 and passes `update(false)`. The
base is republished only when the measurement moves it by more than 0.02% (each set restarts the trim).
- **Notices**: one per lasting (2 s) change of a fallback reason; settling and fast-forward never show one.
### Shared rules
- **Fast-forward:** unthrottled or a set multiple, audio dropped (as today).