mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-07 12:47:58 -10:00
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:
1 parent
507d11f4c9
commit
fe80b36a2b
20 files changed
+708
-30
No files matched your search
+19
-2
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user