mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-18 19:01:24 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31849f5f1d |
No files matched your search
@@ -4,6 +4,40 @@ What changed in each SpeedBreaker release, newest first. Each release's notes on
|
||||
its entry here, and the game shows the entry once, the first time a new version starts (offline:
|
||||
it's built into the game).
|
||||
|
||||
## v0.1.7
|
||||
|
||||
### What's new
|
||||
|
||||
- Smoother on 120 Hz screens on SteamOS and Linux, and on 180 and 240 Hz ones: each frame now
|
||||
stays on screen for exactly two refreshes (three at 180 Hz, four at 240 Hz), as a 60 Hz screen
|
||||
shows each for one. Until now some frames stayed for one refresh and the next for three,
|
||||
a small, uneven stutter that came and went. Macs are unchanged.
|
||||
- A note, once, when your screen runs at a rate 60 frames a second can't share evenly (90 Hz on a
|
||||
Steam Deck OLED, 144 or 165 Hz monitors), which makes motion stutter: it says how to set the
|
||||
screen to 60 Hz or 120 Hz, or to turn on variable refresh. It waits for a menu or a pause rather
|
||||
than coming up mid-race, pauses the game while it's open, and doesn't come back for that rate
|
||||
once closed. It never shows with variable refresh, nor on a Mac's ProMotion screen.
|
||||
|
||||
### More about it
|
||||
|
||||
- Tested on simulated 120, 180 and 240 Hz screens (a nested gamescope session on a Steam Machine,
|
||||
whose own TV tops out at 60 Hz): 99.9% or more of frames shown evenly, against 77-88% at 120 and
|
||||
240 Hz before. Not yet on a real 120 Hz panel: a report from yours would help.
|
||||
- Each frame reaches a 120 Hz screen about as soon as it did in v0.1.6, now evenly. While frames
|
||||
take longer (loading, a heavy scene), each is shown a refresh or two later, evenly, rather than
|
||||
some on time and some late.
|
||||
- As at 60 Hz, this needs a graphics driver that reports when each frame reaches the screen
|
||||
(Vulkan's present wait); the log's "[vblank] locked to every 2nd refresh" line says it took.
|
||||
With variable refresh on, the game leaves the screen to follow its frames, as before.
|
||||
NFSMW_REFRESH_MULTIPLE=0 brings back v0.1.6's behaviour.
|
||||
- The note's words are in the log too ("[video] refresh notice"), and NFSMW_REFRESH_NOTICE=0 turns
|
||||
it off. Where your screen's rate is recorded once the note is closed: settings.toml's [app]
|
||||
refresh_notices. On a Steam Deck it points to Quick Access > Performance; the names of Steam's
|
||||
settings in it haven't been checked on a Deck yet.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.6
|
||||
|
||||
### What's new
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ endif()
|
||||
# apps' versions, the SteamOS bundles' VERSION file and the release tag
|
||||
# (v<version>). CHANGELOG.md has an entry for each version (the in-game
|
||||
# "What's new" note is built from it).
|
||||
project(SpeedBreaker VERSION 0.1.6 LANGUAGES C CXX)
|
||||
project(SpeedBreaker VERSION 0.1.7 LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
@@ -129,7 +129,7 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
- **Everyone:** a disc image of your own **USA (NTSC-U) Xbox 360 disc** (see
|
||||
[Quick start](#quick-start)), about **7.2 GB** of free space, and a display running at about
|
||||
**60 Hz**.
|
||||
**60 Hz** (or 120 Hz, 180 Hz or 240 Hz on SteamOS and Linux).
|
||||
- **Steam Deck, Steam Machine and Linux PCs** (the x86-64 SteamOS download): a 64-bit x86 CPU
|
||||
with AVX2 (x86-64-v3): Intel Haswell (2013) or newer, or AMD Excavator, Zen or newer. A
|
||||
Vulkan 1.2 graphics card.
|
||||
@@ -202,7 +202,9 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
|
||||
formatted by SteamOS, or Game Mode won't find the game.
|
||||
5. **Check the screen settings.**
|
||||
- **Steam Deck OLED: set the refresh rate to 60 Hz** (Quick Access > Performance). At 90 Hz
|
||||
the game stutters. On any Deck, leave the Framerate Limit off.
|
||||
the game stutters, and since v0.1.7 a note says so, once, the first time it runs at 90 Hz.
|
||||
Turn on Steam's per-game profile there and it keeps 60 Hz for this game. On any Deck, leave
|
||||
the Framerate Limit off.
|
||||
- **Docked, or on a Steam Machine:** in the shortcut's Properties > General > Game
|
||||
Resolution, choose **Native**.
|
||||
- The picture fills the Deck's 16:10 screen, with a slightly taller view and the HUD kept at
|
||||
@@ -440,6 +442,11 @@ online, and only when you press it). The page also shows which build you're runn
|
||||
|
||||
- **Run your display at 60 Hz.** On a Steam Deck OLED, set the refresh rate to 60 Hz and leave
|
||||
Steam's Framerate Limit off.
|
||||
- **A 120 Hz monitor works too** on SteamOS and Linux (and 180 or 240 Hz): since v0.1.7 each frame
|
||||
stays on screen for two refreshes (three or four), evenly. That was tested on simulated 120,
|
||||
180 and 240 Hz screens, not yet on a real one: a report from yours would help. 90, 144 and
|
||||
165 Hz can't show 60 fps evenly; the game says so once, and 60 or 120 Hz (or variable refresh)
|
||||
is smooth.
|
||||
- **MacBook Pro with a 120 Hz (ProMotion) screen:** since v0.1.4 SpeedBreaker asks for each frame
|
||||
to reach the screen 1/60 s after the last. Measured in fullscreen on an M1 Pro: 99.9% of frames
|
||||
in menus and about 94% while driving (fewer in the heaviest scenes). Other screens show frames
|
||||
@@ -498,7 +505,8 @@ Security (see [Mac setup](#mac)), again after each update. SpeedBreaker needs ma
|
||||
or later on Apple silicon.
|
||||
|
||||
**The game stutters on my Steam Deck OLED.** Set the screen to 60 Hz (see
|
||||
[Steam Deck setup](#steam-deck-and-steam-machine)).
|
||||
[Steam Deck setup](#steam-deck-and-steam-machine)); with Steam's per-game profile on, it stays at
|
||||
60 Hz for this game.
|
||||
|
||||
**My laptop runs the game on the slow built-in graphics.** Since v0.1.2 SpeedBreaker is meant to
|
||||
pick the dedicated GPU by itself (not yet confirmed on a real two-GPU laptop). If it doesn't, see
|
||||
@@ -559,7 +567,7 @@ SpeedBreaker folder instead: `data/`, `data/game` and `cache/`.
|
||||
2. A notice says where the zip file went: your **Desktop** (on SteamOS, pick it up in Desktop
|
||||
Mode, or in the Desktop app on a Steam Frame), the **SpeedBreaker** folder in the Files app on
|
||||
iPhone and iPad, or the data folder above when there is no Desktop. Its name says which
|
||||
version you were running, for example `speedbreaker-bug-report-v0.1.6-<date>_<time>.zip`.
|
||||
version you were running, for example `speedbreaker-bug-report-v0.1.7-<date>_<time>.zip`.
|
||||
3. Open an [issue][issues], attach the zip, and say what you were doing, what you expected, what
|
||||
happened, and roughly how long into the session it happened.
|
||||
|
||||
@@ -596,7 +604,8 @@ native program for x86-64 or arm64. A runtime written for this project stands in
|
||||
- **Resolution:** the 3D scene renders at 1x, 1.5x, 2x or 3x; the bloom and haze stay at the original
|
||||
resolution, so the game keeps its colour grade.
|
||||
- **Frame pacing:** the game's clock locks to the display's measured refresh (58.5 to 61.5 Hz),
|
||||
one game step per refresh. 30 fps runs two game steps per frame. On a Mac the clock's thread
|
||||
one game step per refresh, or on SteamOS and Linux to every 2nd, 3rd or 4th refresh of a 120,
|
||||
180 or 240 Hz display. 30 fps runs two game steps per frame. On a Mac the clock's thread
|
||||
runs with real-time priority, and on a 120 Hz ProMotion screen each frame is shown at a set time
|
||||
after its tick (turned off for the session if the screen doesn't keep those times).
|
||||
- **Sun glare:** the game measures the sun with occlusion queries; SpeedBreaker counts them on
|
||||
|
||||
@@ -32,8 +32,10 @@
|
||||
// #9). Further behind, the queue ran dry and the display waited, and the
|
||||
// frame gets the rest; a frame slower than the vblank (GPU-bound) gets its
|
||||
// count, its length rounded either way. Game time adds up to the vblanks
|
||||
// that fired, to within those few. It runs at the display's rate over 60:
|
||||
// 0.044% slow on a 59.97 Hz monitor, at most 2.5% (the lock's range). Frames
|
||||
// that fired, to within those few. It runs at the display's rate over 60
|
||||
// (over 120, 180 or 240 on such a display, whose refreshes a vblank is 2-4
|
||||
// of: video/vblank_lock.h): 0.044% slow on a 59.97 Hz monitor, at most 2.5%
|
||||
// (the lock's range). Frames
|
||||
// of more than 6 vblanks (a hitch, loading) keep their measured time; below
|
||||
// 20 fps, where they are the frame rate's own, only those over half a second
|
||||
// do, the rest get 6 (the stepper's own cap) and later frames give back what
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
// guard (default 10, at most 12). Each movie leaves a [movie] line: its
|
||||
// frames by vblanks shown, and those held.
|
||||
#include <stdafx.h>
|
||||
#include "movie_pacing.h"
|
||||
|
||||
#include <cpu/guest_time.h>
|
||||
#include <kernel/function.h>
|
||||
@@ -78,6 +79,7 @@ namespace
|
||||
constexpr uint32_t kDeviceVblanks = 15600, kDeviceReleased = 15604, kDeviceCountdown = 15608;
|
||||
|
||||
std::atomic<uint64_t> s_movieRenders{ 0 }; // XMV frames rendered
|
||||
std::atomic<int64_t> s_lastMovieFrame{ 0 }; // when the last one was (LastMovieFrameNs)
|
||||
|
||||
// The swap callback's thread only: the command processor's (with
|
||||
// NFSMW_EAGER_WRITES=0 also the renderer's completion thread, one swap at
|
||||
@@ -109,6 +111,12 @@ PPC_FUNC(sub_826E8CF8)
|
||||
{
|
||||
__imp__sub_826E8CF8(ctx, base);
|
||||
s_movieRenders.fetch_add(1, std::memory_order_release);
|
||||
s_lastMovieFrame.store(NowNs(), std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
int64_t game::LastMovieFrameNs()
|
||||
{
|
||||
return s_lastMovieFrame.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
PPC_FUNC(sub_825985A0)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See movie_pacing.cpp.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace game
|
||||
{
|
||||
// Any thread: when the XMV player last rendered a movie frame (ns, steady
|
||||
// clock; 0: none yet). A movie (the boot logos, the intro, a cutscene)
|
||||
// plays while these keep coming.
|
||||
int64_t LastMovieFrameNs();
|
||||
}
|
||||
@@ -1126,7 +1126,23 @@ namespace gpu
|
||||
{
|
||||
const video::RefreshEstimate& d = s.display;
|
||||
std::string line;
|
||||
if (s.locked)
|
||||
if (s.locked && d.multiple >= 2)
|
||||
{
|
||||
// Every 2nd-4th refresh of a 120-240 Hz display (video/vblank_lock.h),
|
||||
// and how long new frames stayed on screen since the lock.
|
||||
const uint64_t frames = s.even + s.shorter + s.longer;
|
||||
line = std::format("locked to every {} refresh of the display: {:.4f} Hz, the vblank at {:.4f} Hz (fit {} presents over {:.1f} s, "
|
||||
"rms {:.2f} ms, {} set aside); vblank {:.2f} ms before its refresh, error {:+.3f} ms; vblank to screen {:.1f} ms (median), "
|
||||
"to ready {:.1f} ms (99%); new frames shown on the refresh {} after their vblank's ({}); on screen for {} refreshes "
|
||||
"{:.2f}%, fewer {}, more {} (of {})",
|
||||
d.multiple == 2 ? "2nd" : d.multiple == 3 ? "3rd" : "4th", 1e9 / d.periodNs, 1e9 / s.periodNs, d.samples, d.spanNs / 1e9,
|
||||
d.rmsNs / 1e6, d.rejected, s.leadNs / 1e6, s.errorNs / 1e6, s.latencyNs / 1e6, s.readyNs / 1e6, d.multiple + s.extra,
|
||||
s.extraChanges == 0 ? std::string("the next, as at first")
|
||||
: s.extraMissed ? std::format("later: {} of the last 120 missed theirs", s.extraMissed)
|
||||
: std::string("earlier: the last 600 were ready in time for it"),
|
||||
d.multiple, frames ? 100.0 * double(s.even) / double(frames) : 0.0, s.shorter, s.longer, frames);
|
||||
}
|
||||
else if (s.locked)
|
||||
line = std::format("locked to the display: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside); "
|
||||
"vblank {:.2f} ms before its refresh, error {:+.3f} ms; vblank to screen {:.1f} ms (median), to present {:.1f} ms (99%); {} slips",
|
||||
1e9 / d.periodNs, d.samples, d.spanNs / 1e9, d.rmsNs / 1e6, d.rejected, s.leadNs / 1e6, s.errorNs / 1e6,
|
||||
@@ -1135,9 +1151,9 @@ namespace gpu
|
||||
line = std::format("free-running every {:.3f} ms: the display follows the vblank (variable refresh?), "
|
||||
"so there is no refresh to lock to", s.periodNs / 1e6);
|
||||
else if (d.samples)
|
||||
line = std::format("free-running every {:.3f} ms; display {}: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside)",
|
||||
line = std::format("free-running every {:.3f} ms; display {}: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside{})",
|
||||
s.periodNs / 1e6, d.valid ? "measured (lock off)" : "not accepted", 1e9 / d.periodNs, d.samples, d.spanNs / 1e9,
|
||||
d.rmsNs / 1e6, d.rejected);
|
||||
d.rmsNs / 1e6, d.rejected, d.multiple >= 2 ? std::format("; fitted as {} refreshes a vblank", d.multiple) : std::string());
|
||||
else
|
||||
line = std::format("free-running every {:.3f} ms", s.periodNs / 1e6);
|
||||
if (video::VblankTiming::Summary t = timing.Summarize(); t.count)
|
||||
@@ -1173,7 +1189,7 @@ namespace gpu
|
||||
next = now();
|
||||
} while (guesttime::Suspended() || guesttime::PausedNs() != paused);
|
||||
bool locked = false;
|
||||
uint64_t slips = 0, count = 0;
|
||||
uint64_t slips = 0, count = 0, holdChanges = 0;
|
||||
while (true)
|
||||
{
|
||||
next = clock.Next(next);
|
||||
@@ -1201,11 +1217,12 @@ namespace gpu
|
||||
DispatchInterrupt(thread.ppcContext, 0, 2);
|
||||
timing.Add(fired - next, now() - fired);
|
||||
auto status = clock.GetStatus();
|
||||
if (status.locked != locked || status.slips != slips || ++count % 3600 == 0)
|
||||
if (status.locked != locked || status.slips != slips || status.extraChanges != holdChanges || ++count % 3600 == 0)
|
||||
{
|
||||
LogVblank(status, timing, timerName);
|
||||
locked = status.locked;
|
||||
slips = status.slips;
|
||||
holdChanges = status.extraChanges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ namespace
|
||||
uint16_t s_latchedButtons = 0;
|
||||
bool s_wasBlocked = false;
|
||||
bool s_scriptSuppressed = false; // under s_mutex: no scripted input while a menu is open
|
||||
std::chrono::steady_clock::time_point s_lastInput; // main thread: a control last in use (SecondsSinceInput)
|
||||
bool s_inputSeen = false;
|
||||
|
||||
// The controller's Back is held back from the game until it's clear it
|
||||
// isn't the start of the Back + Start settings chord: released (then
|
||||
@@ -575,6 +577,13 @@ namespace hid
|
||||
s_rumbleDirty = true;
|
||||
}
|
||||
|
||||
double SecondsSinceInput()
|
||||
{
|
||||
if (!s_inputSeen)
|
||||
return 0.0;
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - s_lastInput).count();
|
||||
}
|
||||
|
||||
void SetBlocked(bool blocked)
|
||||
{
|
||||
s_blocked.store(blocked, std::memory_order_relaxed);
|
||||
@@ -665,6 +674,19 @@ namespace hid
|
||||
unsigned(p.leftTrigger), unsigned(p.rightTrigger), deadzone, triggerDeadzone,
|
||||
s_blocked.load(std::memory_order_relaxed) ? " (a menu is open: the game sees an idle pad)" : "");
|
||||
|
||||
// The last time anything was pressed or moved, the script's taps
|
||||
// included (SecondsSinceInput).
|
||||
{
|
||||
Pad scripted = p;
|
||||
ApplyScript(scripted);
|
||||
if (scripted.buttons || scripted.leftTrigger || scripted.rightTrigger || scripted.lx || scripted.ly || scripted.rx ||
|
||||
scripted.ry || !s_inputSeen)
|
||||
{
|
||||
s_lastInput = std::chrono::steady_clock::now();
|
||||
s_inputSeen = true;
|
||||
}
|
||||
}
|
||||
|
||||
// While a menu has the input the game sees an idle pad; after it
|
||||
// closes, each button still held stays hidden until released, so the
|
||||
// press that closed the menu (B, Start, Esc) doesn't reach the game.
|
||||
|
||||
@@ -48,6 +48,10 @@ namespace hid
|
||||
// While set (a menu is open), the game sees an idle controller, and
|
||||
// after it is cleared until every control has been released.
|
||||
void SetBlocked(bool blocked);
|
||||
// Main thread: seconds since any control was last pressed or moved (a
|
||||
// controller, the keyboard's controls, NFSMW_INPUT_SCRIPT), as of the
|
||||
// last Update; from the first Update on.
|
||||
double SecondsSinceInput();
|
||||
// At exit: stop any rumble (the process may outlive nothing that would).
|
||||
void Shutdown();
|
||||
// Main thread, from the frame loop (not UIKit's callback), once the game
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <platform/macos_window.h>
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#include <cmath>
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
namespace platform::macos
|
||||
@@ -113,6 +114,23 @@ namespace platform::macos
|
||||
return strip > 0 ? float(strip) : 0.0f;
|
||||
}
|
||||
|
||||
bool DisplayRefreshFixed(SDL_Window* window, double* minHz, double* maxHz)
|
||||
{
|
||||
NSWindow* w = CocoaWindow(window);
|
||||
NSScreen* screen = w ? w.screen : nil;
|
||||
if (!screen)
|
||||
return false;
|
||||
const NSTimeInterval shortest = screen.minimumRefreshInterval, longest = screen.maximumRefreshInterval;
|
||||
if (minHz)
|
||||
*minHz = longest > 0 ? 1.0 / longest : 0.0;
|
||||
if (maxHz)
|
||||
*maxHz = shortest > 0 ? 1.0 / shortest : 0.0;
|
||||
NSNumber* number = screen.deviceDescription[@"NSScreenNumber"];
|
||||
if (number && CGDisplayIsBuiltin(CGDirectDisplayID(number.unsignedIntValue)))
|
||||
return false;
|
||||
return shortest > 0 && longest > 0 && std::abs(longest - shortest) <= 0.01 * shortest;
|
||||
}
|
||||
|
||||
void ForgetWindow(SDL_Window*)
|
||||
{
|
||||
for (id token in s_observers)
|
||||
|
||||
@@ -45,6 +45,12 @@ namespace platform::macos
|
||||
// laying the UI out below it.
|
||||
float TitleStripHeight(SDL_Window* window);
|
||||
|
||||
// The window's screen refreshes at one fixed rate (macOS's minimum and
|
||||
// maximum refresh intervals agree) and isn't the Mac's built-in one: not
|
||||
// a ProMotion panel, nor a monitor with adaptive sync on. `minHz` and
|
||||
// `maxHz`, if given, get the range (for the log). False without a screen.
|
||||
bool DisplayRefreshFixed(SDL_Window* window, double* minHz = nullptr, double* maxHz = nullptr);
|
||||
|
||||
// Before SDL_DestroyWindow: stops watching the window.
|
||||
void ForgetWindow(SDL_Window* window);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ui.h.
|
||||
//
|
||||
// A one-time notice panel over the game (the refresh-rate one,
|
||||
// video/refresh_notice.h): a title, a paragraph and OK, drawn like the
|
||||
// "What's new" note. The game pauses while it shows, as for the settings
|
||||
// menu (the 360's Guide), and goes on when it closes.
|
||||
#include <stdafx.h>
|
||||
#include "ui.h"
|
||||
|
||||
#include <kernel/xam.h>
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace ui
|
||||
{
|
||||
namespace
|
||||
{
|
||||
struct Notice
|
||||
{
|
||||
std::string title, text;
|
||||
std::function<void(const char*)> closed;
|
||||
bool firstFrame = true;
|
||||
};
|
||||
|
||||
void Close(Notice& notice, const char* how)
|
||||
{
|
||||
fprintf(stderr, "[ui] notice \"%s\" closed (%s)\n", notice.title.c_str(), how);
|
||||
auto closed = std::move(notice.closed);
|
||||
SetModal(nullptr); // destroys `notice`
|
||||
XamNotifySystemUi(false);
|
||||
if (closed)
|
||||
closed(how);
|
||||
}
|
||||
|
||||
void Draw(Notice& notice)
|
||||
{
|
||||
float s = Scale();
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
float width = std::min(viewport->WorkSize.x - 2 * kSpaceL * s, 640.0f * s);
|
||||
ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
ImGui::SetNextWindowSizeConstraints(ImVec2(width, 0), ImVec2(width, viewport->WorkSize.y));
|
||||
constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_AlwaysAutoResize;
|
||||
if (notice.firstFrame || !ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow))
|
||||
ImGui::SetNextWindowFocus();
|
||||
if (!ImGui::Begin("Notice", nullptr, flags))
|
||||
{
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
TitleText(notice.title.c_str());
|
||||
ImGui::Spacing();
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextWrapped("%s", notice.text.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Spacing();
|
||||
if (notice.firstFrame)
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
bool ok = ImGui::Button("OK");
|
||||
ImGui::SameLine();
|
||||
{
|
||||
const char* legend = "A or B: close";
|
||||
float w = ImGui::CalcTextSize(legend).x;
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - w));
|
||||
ImGui::TextDisabled("%s", legend);
|
||||
}
|
||||
notice.firstFrame = false;
|
||||
const char* how = ok ? "OK" : nullptr;
|
||||
if (!how && (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)))
|
||||
how = "B or Esc";
|
||||
else if (!how && ImGui::IsKeyPressed(ImGuiKey_GamepadStart, false))
|
||||
how = "Start";
|
||||
ImGui::End();
|
||||
if (how)
|
||||
Close(notice, how);
|
||||
}
|
||||
}
|
||||
|
||||
bool ShowNotice(std::string title, std::string text, std::function<void(const char*)> closed)
|
||||
{
|
||||
if (!Ready() || HasModal() || SettingsOpen())
|
||||
return false;
|
||||
auto notice = std::make_shared<Notice>();
|
||||
notice->title = std::move(title);
|
||||
notice->text = std::move(text);
|
||||
notice->closed = std::move(closed);
|
||||
fprintf(stderr, "[ui] notice \"%s\": %s\n", notice->title.c_str(), notice->text.c_str());
|
||||
SetModal([notice] { Draw(*notice); });
|
||||
// As the settings menu: the game pauses under it.
|
||||
XamNotifySystemUi(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,11 @@ namespace ui
|
||||
// and WhatsNewEntry()) as a modal panel over the game until the player
|
||||
// closes it (A, B, Start, Esc or OK). Nothing without an entry.
|
||||
void ShowWhatsNew(std::string_view version);
|
||||
// Main thread: a one-time notice (`title`, a paragraph of `text`, OK) as a
|
||||
// panel over the game, which pauses under it, until the player closes it
|
||||
// (A, B, Start, Esc or OK); then `closed` runs with how. False (nothing
|
||||
// shown) without a UI, or while another panel or the settings menu is up.
|
||||
bool ShowNotice(std::string title, std::string text, std::function<void(const char*)> closed);
|
||||
|
||||
// Main thread, before the game starts (main.cpp): a page saying why the
|
||||
// game can't start (memory_status.h's StartupFailure), drawn until it is
|
||||
|
||||
+110
-7
@@ -382,15 +382,49 @@ namespace settings
|
||||
bool s_fileFound = false; // the last Load found a settings.toml
|
||||
bool s_fileLoaded = false; // ...and read and parsed it
|
||||
|
||||
std::string AppTable(const std::string& lastVersion)
|
||||
// refresh_notices: the display rates (whole Hz) the refresh-rate
|
||||
// notice was closed for (video/refresh_notice.h), "90,144".
|
||||
constexpr const char* kRefreshNoticesKey = "refresh_notices";
|
||||
std::vector<int>& RefreshNoticesList()
|
||||
{
|
||||
static std::vector<int> list;
|
||||
return list;
|
||||
}
|
||||
|
||||
std::vector<int> ParseRates(std::string_view text)
|
||||
{
|
||||
std::vector<int> out;
|
||||
while (!text.empty())
|
||||
{
|
||||
size_t comma = text.find(',');
|
||||
std::string item(text.substr(0, comma));
|
||||
text = comma == std::string_view::npos ? std::string_view() : text.substr(comma + 1);
|
||||
char* end = nullptr;
|
||||
long hz = std::strtol(item.c_str(), &end, 10);
|
||||
if (end != item.c_str() && hz > 0 && hz < 1000 && std::find(out.begin(), out.end(), int(hz)) == out.end())
|
||||
out.push_back(int(hz));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string AppTable(const std::string& lastVersion, const std::vector<int>& notices)
|
||||
{
|
||||
// Only what a version looks like (a hand edit could hold a quote
|
||||
// or a newline, which would break the file).
|
||||
if (lastVersion.empty() || lastVersion.size() > 64 ||
|
||||
!std::all_of(lastVersion.begin(), lastVersion.end(), [](char c) { return std::isalnum(uint8_t(c)) || c == '.' || c == '-' || c == '+'; }))
|
||||
std::string version;
|
||||
if (!(lastVersion.empty() || lastVersion.size() > 64 ||
|
||||
!std::all_of(lastVersion.begin(), lastVersion.end(), [](char c) { return std::isalnum(uint8_t(c)) || c == '.' || c == '-' || c == '+'; })))
|
||||
version = std::format("# The SpeedBreaker version that ran last (the game keeps this up to date).\n{} = \"{}\"\n",
|
||||
kLastVersionKey, lastVersion);
|
||||
std::string rates;
|
||||
for (int hz : notices)
|
||||
rates += (rates.empty() ? "" : ",") + std::to_string(hz);
|
||||
if (!rates.empty())
|
||||
rates = std::format("# Screen refresh rates (Hz) the refresh-rate notice was closed for: it isn't shown for them again.\n"
|
||||
"{} = \"{}\"\n", kRefreshNoticesKey, rates);
|
||||
if (version.empty() && rates.empty())
|
||||
return "";
|
||||
return std::format("\n[{}]\n# The SpeedBreaker version that ran last (the game keeps this up to date).\n{} = \"{}\"\n",
|
||||
kAppTable, kLastVersionKey, lastVersion);
|
||||
return std::format("\n[{}]\n{}{}", kAppTable, version, rates);
|
||||
}
|
||||
|
||||
// `text` (a settings.toml) without its [app] table: from a line that
|
||||
@@ -868,11 +902,13 @@ namespace settings
|
||||
out += std::format("{} = {}\n", o.key, TomlValue(o, values[i]));
|
||||
}
|
||||
std::string lastVersion;
|
||||
std::vector<int> notices;
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
lastVersion = LastVersionText();
|
||||
notices = RefreshNoticesList();
|
||||
}
|
||||
return out + AppTable(lastVersion);
|
||||
return out + AppTable(lastVersion, notices);
|
||||
}
|
||||
|
||||
// Where a save of `path` goes: through a symlinked settings.toml
|
||||
@@ -1269,6 +1305,7 @@ namespace settings
|
||||
// wait only for the values to be published.
|
||||
std::array<std::optional<double>, kCount> fileValues;
|
||||
std::string text, readError, lastError, lastVersion;
|
||||
std::vector<int> notices;
|
||||
size_t fromFile = 0;
|
||||
ReadStatus status = ReadFile(path, text, readError);
|
||||
if (status == ReadStatus::Missing)
|
||||
@@ -1328,6 +1365,15 @@ namespace settings
|
||||
for (auto&& [key, node] : *table)
|
||||
{
|
||||
std::string name = std::format("{}.{}", tableKey.str(), key.str());
|
||||
if (tableKey.str() == kAppTable && key.str() == kRefreshNoticesKey)
|
||||
{
|
||||
if (auto v = node.as_string())
|
||||
notices = ParseRates(v->get());
|
||||
else
|
||||
fprintf(stderr, "[settings] %s: %s = %s isn't a list of rates; ignored\n", path.filename().c_str(), name.c_str(),
|
||||
NodeText(node).c_str());
|
||||
continue;
|
||||
}
|
||||
if (tableKey.str() == kAppTable && key.str() == kLastVersionKey)
|
||||
{
|
||||
if (auto v = node.as_string())
|
||||
@@ -1371,6 +1417,7 @@ namespace settings
|
||||
LoadedPath() = path;
|
||||
LastErrorText() = lastError;
|
||||
LastVersionText() = lastVersion;
|
||||
RefreshNoticesList() = notices;
|
||||
s_fileFound = status != ReadStatus::Missing;
|
||||
s_fileLoaded = status == ReadStatus::Ok && lastError.empty();
|
||||
detail::g_generation.fetch_add(1, std::memory_order_release);
|
||||
@@ -1471,7 +1518,12 @@ namespace settings
|
||||
text = "# SpeedBreaker settings. The settings menu writes every option here when one\n"
|
||||
"# changes; until then each one keeps its default.\n";
|
||||
std::string recorded(version);
|
||||
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(recorded)); !failed.empty())
|
||||
std::vector<int> notices;
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
notices = RefreshNoticesList();
|
||||
}
|
||||
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(recorded, notices)); !failed.empty())
|
||||
{
|
||||
fprintf(stderr, "[settings] the version isn't recorded: %s\n", failed.c_str());
|
||||
return false;
|
||||
@@ -1483,4 +1535,55 @@ namespace settings
|
||||
LoadedPath() = path;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RefreshNoticeShown(int hz)
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
const std::vector<int>& list = RefreshNoticesList();
|
||||
return std::find(list.begin(), list.end(), hz) != list.end();
|
||||
}
|
||||
|
||||
bool RecordRefreshNotice(int hz)
|
||||
{
|
||||
std::lock_guard saveLock(s_saveMutex);
|
||||
std::filesystem::path path;
|
||||
std::string lastVersion;
|
||||
std::vector<int> notices;
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
notices = RefreshNoticesList();
|
||||
if (std::find(notices.begin(), notices.end(), hz) != notices.end())
|
||||
return true;
|
||||
notices.push_back(hz);
|
||||
RefreshNoticesList() = notices; // this run doesn't show it again, saved or not
|
||||
if (s_fileFound && !s_fileLoaded)
|
||||
{
|
||||
fprintf(stderr, "[settings] the refresh notice's %d Hz isn't recorded: settings.toml didn't load\n", hz);
|
||||
return false;
|
||||
}
|
||||
path = LoadedPath().empty() ? DefaultPath() : LoadedPath();
|
||||
lastVersion = LastVersionText();
|
||||
}
|
||||
// Only the [app] table changes, as for RecordVersion.
|
||||
std::string text, error;
|
||||
ReadStatus status = ReadFile(path, text, error);
|
||||
if (status == ReadStatus::Failed)
|
||||
{
|
||||
fprintf(stderr, "[settings] the refresh notice's %d Hz isn't recorded: can't read %s: %s\n", hz, path.c_str(), error.c_str());
|
||||
return false;
|
||||
}
|
||||
if (status == ReadStatus::Missing)
|
||||
text = "# SpeedBreaker settings. The settings menu writes every option here when one\n"
|
||||
"# changes; until then each one keeps its default.\n";
|
||||
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(lastVersion, notices)); !failed.empty())
|
||||
{
|
||||
fprintf(stderr, "[settings] the refresh notice's %d Hz isn't recorded: %s\n", hz, failed.c_str());
|
||||
return false;
|
||||
}
|
||||
std::lock_guard lock(s_mutex);
|
||||
s_fileFound = s_fileLoaded = true;
|
||||
if (LoadedPath().empty())
|
||||
LoadedPath() = path;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -297,4 +297,11 @@ namespace settings
|
||||
// is left alone (false, logged), as is one already recording `version`.
|
||||
// Every Save() writes it too.
|
||||
bool RecordVersion(std::string_view version);
|
||||
|
||||
// The display rates (whole Hz) the refresh-rate notice was closed for
|
||||
// (video/refresh_notice.h): [app] refresh_notices in settings.toml.
|
||||
// Record adds one, in memory and in the file, as RecordVersion does (only
|
||||
// the [app] table is rewritten); any thread.
|
||||
bool RefreshNoticeShown(int hz);
|
||||
bool RecordRefreshNotice(int hz);
|
||||
}
|
||||
+250
-9
@@ -9,6 +9,7 @@
|
||||
#include <apu/audio.h>
|
||||
#include <gpu/renderer.h>
|
||||
#include <game/ultrawide.h>
|
||||
#include <game/movie_pacing.h>
|
||||
#include "gpu_choice.h"
|
||||
#include "present_schedule.h"
|
||||
#include "swapchain_use.h"
|
||||
@@ -19,6 +20,8 @@
|
||||
#include <cpu/guest_thread.h>
|
||||
#include <cpu/guest_time.h>
|
||||
#include "vblank_lock.h"
|
||||
#include "refresh_multiple.h"
|
||||
#include "refresh_notice.h"
|
||||
#include "display_tone.h"
|
||||
#include "fsr1.h"
|
||||
#include "upscale.h"
|
||||
@@ -291,6 +294,7 @@ namespace video
|
||||
uint64_t swapchain; // its generation (s_swapchainUse)
|
||||
int64_t presented; // vkQueuePresentKHR (ns, steady clock)
|
||||
int64_t vblank; // a new game frame: the guest vblank it followed; 0: a redraw
|
||||
int64_t ready; // ready to present (= presented, but for a frame held for its refresh: VblankLock::PresentAt)
|
||||
};
|
||||
// Never destroyed: the waiter is detached and blocks on them, and
|
||||
// glibc's pthread_cond_destroy waits for blocked waiters, so an exit
|
||||
@@ -303,6 +307,17 @@ namespace video
|
||||
SwapchainUse<VkSwapchainKHR>& s_swapchainUse = *new SwapchainUse<VkSwapchainKHR>;
|
||||
uint64_t s_swapchainGeneration = 0;
|
||||
FILE* s_timingLog = nullptr; // NFSMW_VBLANK_LOG=<file>: one CSV line per present
|
||||
// Display refreshes per 60 Hz guest vblank the fit should expect: the
|
||||
// display mode's rate over 60 where that is 2-4 (video/refresh_multiple.h),
|
||||
// else 1. Set by the main thread (UpdateRefreshMultiple), read by the
|
||||
// waiter, which owns the fit.
|
||||
std::atomic<int> s_refreshMultiple{ 1 };
|
||||
// The refresh-rate notice's grid check (video/refresh_notice.h): the
|
||||
// mode's rate when it is no whole multiple of 60 Hz (0: no check;
|
||||
// main thread sets it), and whether the waiter's grid fit takes the
|
||||
// present completions at it, since when (ns, steady clock; 0: not).
|
||||
std::atomic<double> s_gridHz{ 0.0 };
|
||||
std::atomic<int64_t> s_gridFixedSince{ 0 };
|
||||
|
||||
// macOS: a new frame may be shown a set time after the guest vblank
|
||||
// that released it (VK_GOOGLE_display_timing's desiredPresentTime,
|
||||
@@ -428,16 +443,43 @@ namespace video
|
||||
static RefreshFit& refresh = *new RefreshFit; // never destroyed either (the waiter may be in it at exit)
|
||||
static int64_t lastOnScreen = 0, lastVblank = 0;
|
||||
VblankLock& vblank = GuestVblank();
|
||||
// A new multiple (another display, another mode) starts the fit
|
||||
// over: the lock free-runs until it is measured again.
|
||||
if (const int multiple = s_refreshMultiple.load(std::memory_order_relaxed); multiple != refresh.Multiple())
|
||||
{
|
||||
refresh.SetMultiple(multiple);
|
||||
vblank.SetDisplay(refresh.Estimate());
|
||||
}
|
||||
if (timed)
|
||||
{
|
||||
refresh.Add(onScreen);
|
||||
vblank.SetDisplay(refresh.Estimate());
|
||||
}
|
||||
// The notice's grid check, beside the lock's fit (never for the lock).
|
||||
if (const double gridHz = s_gridHz.load(std::memory_order_relaxed); gridHz > 0)
|
||||
{
|
||||
static RefreshFit& grid = *new RefreshFit; // never destroyed, as `refresh`
|
||||
static double checking = 0;
|
||||
if (gridHz != checking)
|
||||
{
|
||||
checking = gridHz;
|
||||
grid.SetGridHz(gridHz);
|
||||
s_gridFixedSince.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
if (timed)
|
||||
{
|
||||
grid.Add(onScreen);
|
||||
const bool fixed = grid.Estimate().valid;
|
||||
const int64_t since = s_gridFixedSince.load(std::memory_order_relaxed);
|
||||
if (fixed != (since != 0))
|
||||
s_gridFixedSince.store(fixed ? onScreen : 0, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
const RefreshEstimate& display = refresh.Estimate();
|
||||
if (s_timingLog)
|
||||
fprintf(s_timingLog, "%llu,%lld,%lld,%lld,%d,%d,%.1f,%lld,%.0f\n", (unsigned long long)t.id, (long long)t.vblank,
|
||||
fprintf(s_timingLog, "%llu,%lld,%lld,%lld,%d,%d,%.1f,%lld,%.0f,%lld,%d\n", (unsigned long long)t.id, (long long)t.vblank,
|
||||
(long long)t.presented, (long long)onScreen, timed, display.valid, display.periodNs, (long long)display.gridNs,
|
||||
display.rmsNs);
|
||||
display.rmsNs, (long long)t.ready, display.multiple);
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
if (s_pastTiming)
|
||||
ReadDriverReports(t);
|
||||
@@ -456,15 +498,18 @@ namespace video
|
||||
{
|
||||
// The last frame's time on screen, against the vblanks the
|
||||
// game gave it (a gap of more than 0.5 s is a pause, not a repeat).
|
||||
// At a multiple of 60 Hz (refresh_fit.h) a vblank is that
|
||||
// many refreshes, counted in refreshes.
|
||||
double refreshNs = display.valid ? display.periodNs : 1e9 / 60.0;
|
||||
int64_t shownFor = std::llround(double(onScreen - lastOnScreen) / refreshNs);
|
||||
int64_t vblanks = std::max<int64_t>(1, std::llround(double(t.vblank - lastVblank) / vblank.PeriodNs()));
|
||||
if (shownFor > vblanks && shownFor <= 30)
|
||||
s_repeated += uint64_t(shownFor - vblanks);
|
||||
const int64_t multiple = display.valid ? display.multiple : 1;
|
||||
if (shownFor > vblanks * multiple && shownFor <= 30 * multiple)
|
||||
s_repeated += uint64_t(shownFor - vblanks * multiple);
|
||||
}
|
||||
lastOnScreen = onScreen;
|
||||
lastVblank = t.vblank;
|
||||
vblank.Observe(t.vblank, t.presented, onScreen);
|
||||
vblank.Observe(t.vblank, t.presented, onScreen, t.ready);
|
||||
}
|
||||
|
||||
void PresentWaiter()
|
||||
@@ -554,6 +599,45 @@ namespace video
|
||||
|
||||
void UpdateDisplayItem();
|
||||
|
||||
double s_modeHz = 0.0; // the window's display mode's rate (main thread; UpdateRefreshMultiple)
|
||||
std::chrono::steady_clock::time_point s_modeSince{};
|
||||
// Game frames for the refresh-rate notice: the first, and the first
|
||||
// after the last gap of RefreshNotice::kGapMs (a load). Main thread.
|
||||
std::chrono::steady_clock::time_point s_firstGameFrame{}, s_flowingSince{};
|
||||
|
||||
// The refreshes per guest vblank the fit should expect on the
|
||||
// window's display (video/refresh_multiple.h), and a [vblank] line
|
||||
// when that or the mode's rate changes and there is something to say
|
||||
// (nothing at 60 Hz). Main thread: at start, each swapchain of a new
|
||||
// size, and SDL's display and mode changes.
|
||||
void UpdateRefreshMultiple(const SDL_DisplayMode* mode)
|
||||
{
|
||||
static const RefreshMultiple policy = RefreshMultiple::FromEnv(std::getenv("NFSMW_REFRESH_MULTIPLE"));
|
||||
#if defined(__APPLE__)
|
||||
constexpr bool kApple = true;
|
||||
#else
|
||||
constexpr bool kApple = false;
|
||||
#endif
|
||||
const double hz = mode ? double(mode->refresh_rate) : 0.0;
|
||||
const int multiple = policy.For(hz, kApple);
|
||||
s_refreshMultiple.store(multiple, std::memory_order_relaxed);
|
||||
// The refresh-rate notice: the mode's rate and since when; the
|
||||
// grid check where the rate is no whole multiple of 60 (not on
|
||||
// Apple's systems: their present waits sit on no grid).
|
||||
if (hz != s_modeHz)
|
||||
{
|
||||
s_modeHz = hz;
|
||||
s_modeSince = std::chrono::steady_clock::now();
|
||||
}
|
||||
s_gridHz.store(!kApple && hz >= RefreshFit::kMinHz && RefreshFit::MultipleOf(hz) == 0 ? hz : 0.0, std::memory_order_relaxed);
|
||||
static double loggedHz = -1.0;
|
||||
if (hz == loggedHz)
|
||||
return;
|
||||
loggedHz = hz;
|
||||
if (const std::string line = policy.Describe(hz, multiple, kApple); !line.empty())
|
||||
fprintf(stderr, "[vblank] %s\n", line.c_str());
|
||||
}
|
||||
|
||||
void DestroySwapchain()
|
||||
{
|
||||
{
|
||||
@@ -2505,7 +2589,7 @@ void main()
|
||||
if (const char* path = std::getenv("NFSMW_VBLANK_LOG"))
|
||||
if ((s_timingLog = fopen(path, "w")))
|
||||
{
|
||||
fprintf(s_timingLog, "id,vblank,presented,on_screen,timed,fit_valid,period_ns,grid_ns,rms_ns\n");
|
||||
fprintf(s_timingLog, "id,vblank,presented,on_screen,timed,fit_valid,period_ns,grid_ns,rms_ns,ready,multiple\n");
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
if (s_pastTiming && (s_actualLog = fopen((std::string(path) + ".actual.csv").c_str(), "w")))
|
||||
fprintf(s_actualLog, "# steady_ns %lld uptime_raw_ns %llu\nid,actual_ns\n", (long long)NowNs(),
|
||||
@@ -2633,6 +2717,7 @@ void main()
|
||||
s_schedule.SetDisplayHz(s_displayHz);
|
||||
}
|
||||
#endif
|
||||
UpdateRefreshMultiple(mode);
|
||||
static const std::pair<int, int> native = NativeMonitorMode();
|
||||
const auto [nativeW, nativeH] = native;
|
||||
int windowW = 0, windowH = 0;
|
||||
@@ -3096,6 +3181,128 @@ void main()
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
// Linux: a monitor or TV is connected (and on), not only a built-in
|
||||
// panel (eDP): the Deck docked, a Steam Machine. Elsewhere false.
|
||||
bool ExternalScreen()
|
||||
{
|
||||
#ifdef __linux__
|
||||
std::error_code ec;
|
||||
for (const auto& entry : std::filesystem::directory_iterator("/sys/class/drm", ec))
|
||||
{
|
||||
const std::string name = entry.path().filename().string();
|
||||
if (name.find('-') == std::string::npos || name.find("eDP") != std::string::npos || name.find("Writeback") != std::string::npos)
|
||||
continue;
|
||||
std::ifstream status(entry.path() / "status"), enabled(entry.path() / "enabled");
|
||||
std::string state, on;
|
||||
if ((status >> state) && state == "connected" && (enabled >> on) && on == "enabled")
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
// The one-time refresh-rate notice (video/refresh_notice.h), judged
|
||||
// twice a second; a line for each rate once it is shown or won't be,
|
||||
// and for the first few reasons it waits. Main thread.
|
||||
void MaybeRefreshNotice()
|
||||
{
|
||||
using Notice = RefreshNotice;
|
||||
static const int forced = [] {
|
||||
const char* v = std::getenv("NFSMW_REFRESH_NOTICE");
|
||||
return v && (v[0] == '0' || v[0] == '1') ? v[0] - '0' : -1;
|
||||
}();
|
||||
static auto lastCheck = std::chrono::steady_clock::time_point{};
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now - lastCheck < std::chrono::milliseconds(500) || !s_window)
|
||||
return;
|
||||
lastCheck = now;
|
||||
const int key = Notice::Key(s_modeHz);
|
||||
static int decided = -1, waitedFor = -1, waitLines = 0; // the rate decided (shown or never), the rate waited on
|
||||
static const char* lastWait = nullptr;
|
||||
if (key == decided)
|
||||
return;
|
||||
Notice::Inputs in;
|
||||
in.modeHz = s_modeHz;
|
||||
in.modeStableSec = std::chrono::duration<double>(now - s_modeSince).count();
|
||||
#if (defined(__APPLE__) && TARGET_OS_IOS) || (defined(__linux__) && defined(__aarch64__))
|
||||
in.platform = Notice::Platform::Excluded;
|
||||
#elif defined(__APPLE__)
|
||||
in.platform = Notice::Platform::Mac;
|
||||
#else
|
||||
in.platform = s_gameMode ? Notice::Platform::GameMode : Notice::Platform::Desktop;
|
||||
#endif
|
||||
const Notice::Decision early = Notice::Decide(in); // NotApplicable, without the costlier inputs
|
||||
if (early.verdict == Notice::Verdict::NotApplicable)
|
||||
return;
|
||||
static double macMin = 0, macMax = 0;
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
in.macFixed = platform::macos::DisplayRefreshFixed(s_window, &macMin, &macMax);
|
||||
#endif
|
||||
// NFSMW_REFRESH_NOTICE_SCREEN=builtin|external: which screen Game
|
||||
// Mode's words are for, whatever is connected (tests: a Steam
|
||||
// Machine has only a TV).
|
||||
static const char* screen = std::getenv("NFSMW_REFRESH_NOTICE_SCREEN");
|
||||
if (in.platform == Notice::Platform::GameMode)
|
||||
in.externalScreen = screen && !strcmp(screen, "builtin") ? false : screen && !strcmp(screen, "external") ? true : ExternalScreen();
|
||||
const int64_t gridSince = s_gridFixedSince.load(std::memory_order_relaxed);
|
||||
in.gridFixed = gridSince != 0;
|
||||
in.gridStableSec = gridSince ? double(NowNs() - gridSince) / 1e9 : 0.0;
|
||||
in.shownBefore = settings::RefreshNoticeShown(key);
|
||||
in.forced = forced;
|
||||
const bool frames = s_firstGameFrame != std::chrono::steady_clock::time_point{};
|
||||
in.sessionSec = frames ? std::chrono::duration<double>(now - s_firstGameFrame).count() : 0.0;
|
||||
in.flowingSec = frames && now - s_lastGameFrame <= std::chrono::duration<double, std::milli>(Notice::kGapMs)
|
||||
? std::chrono::duration<double>(now - s_flowingSince).count() : 0.0;
|
||||
if (const int64_t movie = game::LastMovieFrameNs())
|
||||
in.movieQuietSec = double(NowNs() - movie) / 1e9;
|
||||
in.idleSec = hid::SecondsSinceInput();
|
||||
in.uiBusy = ui::HasModal() || ui::SettingsOpen();
|
||||
const Notice::Decision d = Notice::Decide(in);
|
||||
const char* where = in.platform == Notice::Platform::GameMode ? (in.externalScreen ? "Game Mode, a monitor or TV" : "Game Mode, the built-in screen")
|
||||
: in.platform == Notice::Platform::Mac ? "macOS" : "a desktop";
|
||||
switch (d.verdict)
|
||||
{
|
||||
case Notice::Verdict::NotApplicable:
|
||||
return;
|
||||
case Notice::Verdict::Never:
|
||||
decided = key;
|
||||
if (in.platform == Notice::Platform::Mac)
|
||||
fprintf(stderr, "[video] refresh notice for %d Hz: not shown: %s (the screen: %.1f-%.1f Hz)\n", key, d.reason, macMin, macMax);
|
||||
else
|
||||
fprintf(stderr, "[video] refresh notice for %d Hz: not shown: %s\n", key, d.reason);
|
||||
return;
|
||||
case Notice::Verdict::Wait:
|
||||
if (waitedFor != key)
|
||||
{
|
||||
waitedFor = key;
|
||||
waitLines = 0;
|
||||
lastWait = nullptr;
|
||||
}
|
||||
if (d.reason != lastWait && waitLines < 4)
|
||||
{
|
||||
waitLines++;
|
||||
lastWait = d.reason;
|
||||
fprintf(stderr, "[video] refresh notice for %d Hz (%s): not yet: %s (%.0f s in)\n", key, where, d.reason, in.sessionSec);
|
||||
}
|
||||
return;
|
||||
case Notice::Verdict::Show:
|
||||
break;
|
||||
}
|
||||
const bool shown = ui::ShowNotice(Notice::kTitle, Notice::Text(in), [key](const char*) {
|
||||
if (settings::RecordRefreshNotice(key))
|
||||
fprintf(stderr, "[video] refresh notice for %d Hz: recorded in settings.toml (not shown again for it)\n", key);
|
||||
});
|
||||
if (!shown)
|
||||
return;
|
||||
decided = key;
|
||||
fprintf(stderr, "[video] refresh notice for %d Hz: shown (%s; %.0f s in, the controls idle %.1f s, %s)\n", key, where,
|
||||
in.sessionSec, in.idleSec, in.platform == Notice::Platform::Mac ? "macOS says the screen's refresh is fixed"
|
||||
: std::format("frames on a fixed {:.2f} Hz grid for {:.0f} s", s_modeHz, in.gridStableSec).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool RunFrame()
|
||||
{
|
||||
DeliverTestAppEvents();
|
||||
@@ -3130,6 +3337,8 @@ void main()
|
||||
s_background.load(std::memory_order_acquire) ? " (app inactive: checked after the resume)" : "", GeometryNote().c_str());
|
||||
#endif
|
||||
}
|
||||
if (e.type == SDL_EVENT_WINDOW_DISPLAY_CHANGED || e.type == SDL_EVENT_DISPLAY_CURRENT_MODE_CHANGED)
|
||||
UpdateRefreshMultiple(SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(s_window)));
|
||||
if (e.type == SDL_EVENT_WINDOW_RESIZED)
|
||||
RememberWindowSize();
|
||||
else if (e.type == SDL_EVENT_WINDOW_FOCUS_GAINED || e.type == SDL_EVENT_WINDOW_FOCUS_LOST)
|
||||
@@ -3185,6 +3394,7 @@ void main()
|
||||
hid::SetBlocked(ui::CapturingInput());
|
||||
hid::Update();
|
||||
ApplySettings();
|
||||
MaybeRefreshNotice();
|
||||
if (!s_swapchain && !CreateSwapchain())
|
||||
{
|
||||
SDL_Delay(16);
|
||||
@@ -3224,7 +3434,14 @@ void main()
|
||||
s_lastHeight = h;
|
||||
s_lastAspectW = aspectW;
|
||||
s_lastAspectH = aspectH;
|
||||
s_lastGameFrame = std::chrono::steady_clock::now();
|
||||
{
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (s_firstGameFrame == std::chrono::steady_clock::time_point{})
|
||||
s_firstGameFrame = s_flowingSince = now;
|
||||
else if (now - s_lastGameFrame > std::chrono::duration<double, std::milli>(RefreshNotice::kGapMs))
|
||||
s_flowingSince = now;
|
||||
s_lastGameFrame = now;
|
||||
}
|
||||
ui::NoteGameFrame();
|
||||
}
|
||||
else
|
||||
@@ -3750,13 +3967,26 @@ void main()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
int64_t presented = NowNs();
|
||||
const int64_t ready = NowNs();
|
||||
int64_t presented = ready;
|
||||
// On a display at a multiple of 60 Hz, a new frame waits for the
|
||||
// refresh before its own, so FIFO doesn't show it early (VblankLock::
|
||||
// PresentAt; 0 everywhere else). Its GPU work is submitted; the queue
|
||||
// is free meanwhile.
|
||||
// (No hold of more than 100 ms: three vblanks are the most it gives.)
|
||||
if (const int64_t at = frameVblank ? GuestVblank().PresentAt(frameVblank) : 0; at > ready && at - ready < 100'000'000)
|
||||
{
|
||||
queueLock.unlock();
|
||||
std::this_thread::sleep_until(std::chrono::steady_clock::time_point(std::chrono::nanoseconds(at)));
|
||||
queueLock.lock();
|
||||
presented = NowNs();
|
||||
}
|
||||
VkResult present = vkQueuePresentKHR(s_queue, &pi);
|
||||
queueLock.unlock();
|
||||
if (s_presentTiming && (present == VK_SUCCESS || present == VK_SUBOPTIMAL_KHR))
|
||||
{
|
||||
std::lock_guard lock(s_timingMutex);
|
||||
s_timings.push_back({ id, s_swapchainGeneration, presented, frameVblank });
|
||||
s_timings.push_back({ id, s_swapchainGeneration, presented, frameVblank, ready });
|
||||
s_timingCv.notify_one();
|
||||
}
|
||||
s_lastPresent = std::chrono::steady_clock::now();
|
||||
@@ -3854,6 +4084,17 @@ void main()
|
||||
fprintf(stderr, "[vblank] lock off (NFSMW_VBLANK_LOCK=0): free-running every 16.667 ms\n");
|
||||
return VblankLock(fixed ? int64_t(std::llround(1e9 / rate)) : 16'667'000, !fixed && !(on && on[0] == '0'));
|
||||
}();
|
||||
// At a multiple of 60 Hz: NFSMW_PRESENT_HOLD=0 presents new frames
|
||||
// when ready (the lock alone, for comparison).
|
||||
static const bool holdChecked = [] {
|
||||
if (const char* hold = std::getenv("NFSMW_PRESENT_HOLD"); hold && hold[0] == '0')
|
||||
{
|
||||
lock.SetHold(false);
|
||||
fprintf(stderr, "[vblank] new frames presented when ready at a multiple of 60 Hz too (NFSMW_PRESENT_HOLD=0)\n");
|
||||
}
|
||||
return true;
|
||||
}();
|
||||
(void)holdChecked;
|
||||
return lock;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,10 @@ namespace video
|
||||
void NoteGuestVblank(int64_t t);
|
||||
// Any thread: guest vblanks so far, and when the last one fired.
|
||||
uint64_t GuestVblanks();
|
||||
// Display refreshes per guest vblank: 1, or 2 at Frame Rate 30 (the
|
||||
// game then shows a frame every other refresh, and each guest vblank
|
||||
// is two world steps: game/frame_time.cpp).
|
||||
// Lock periods (1/60 s, VblankLock::PeriodNs: one refresh at 60 Hz, two
|
||||
// at 120 Hz) per guest vblank: 1, or 2 at Frame Rate 30 (the game then
|
||||
// shows a frame every other period, and each guest vblank is two world
|
||||
// steps: game/frame_time.cpp).
|
||||
int GuestVblankDivider();
|
||||
int64_t LastGuestVblank();
|
||||
|
||||
|
||||
@@ -6,17 +6,60 @@
|
||||
|
||||
namespace video
|
||||
{
|
||||
int RefreshFit::MultipleOf(double hz)
|
||||
{
|
||||
for (int m = 1; m <= kMaxMultiple; m++)
|
||||
if (hz >= kMinHz * m && hz <= kMaxHz * m)
|
||||
return m;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RefreshFit::SetMultiple(int multiple)
|
||||
{
|
||||
if (multiple < 1 || multiple > kMaxMultiple)
|
||||
multiple = 1;
|
||||
if (multiple == m_multiple && m_rate == double(multiple))
|
||||
return;
|
||||
m_multiple = multiple;
|
||||
m_rate = double(multiple);
|
||||
m_lineFits = false;
|
||||
m_samples.clear();
|
||||
m_estimate = RefreshEstimate{};
|
||||
m_estimate.multiple = multiple;
|
||||
}
|
||||
|
||||
void RefreshFit::SetGridHz(double hz)
|
||||
{
|
||||
const double rate = hz / 60.0;
|
||||
if (!(rate >= kMinHz / 60.0 && rate <= 8.0))
|
||||
return SetMultiple(1);
|
||||
if (m_multiple == 0 && m_rate == rate)
|
||||
return;
|
||||
m_multiple = 0;
|
||||
m_rate = rate;
|
||||
m_lineFits = false;
|
||||
m_samples.clear();
|
||||
m_estimate = RefreshEstimate{};
|
||||
m_estimate.multiple = 0;
|
||||
}
|
||||
|
||||
void RefreshFit::Add(int64_t t)
|
||||
{
|
||||
Sample s{ t, 0, m_segment };
|
||||
if (!m_samples.empty())
|
||||
{
|
||||
const Sample& last = m_samples.back();
|
||||
double period = m_estimate.valid ? m_estimate.periodNs : kNominalNs;
|
||||
int64_t n = std::llround(double(t - last.t) / period);
|
||||
// At a multiple, once the window fits a line (accepted or still
|
||||
// short of 10 s), measured from the last completion's place on
|
||||
// it: one seen more than half a refresh late (2.1 ms at 240 Hz)
|
||||
// is put on the wrong refresh, and set aside, alone; from its own
|
||||
// time every completion after it would be too.
|
||||
const bool fromLine = m_rate > 1.0 && m_lineFits;
|
||||
double period = m_estimate.valid || fromLine ? m_estimate.periodNs : kNominalNs / m_rate;
|
||||
int64_t n = fromLine ? std::llround((double(t) - double(m_estimate.gridNs)) / period) : std::llround(double(t - last.t) / period);
|
||||
if (n < 1)
|
||||
return; // within half a refresh of the last: a late wake-up, not a refresh
|
||||
if (n > kMaxGap)
|
||||
if (double(n) > double(kMaxGap) * m_rate)
|
||||
s.segment = ++m_segment;
|
||||
else
|
||||
s.k = last.k + n;
|
||||
@@ -44,6 +87,8 @@ namespace video
|
||||
}
|
||||
}
|
||||
m_estimate.valid = Acceptable(line, true);
|
||||
m_lineFits = Acceptable(line, false);
|
||||
m_estimate.multiple = m_multiple;
|
||||
m_estimate.samples = uint32_t(m_samples.size());
|
||||
m_estimate.rejected = line.rejected;
|
||||
m_estimate.rmsNs = line.rmsNs;
|
||||
@@ -60,7 +105,7 @@ namespace video
|
||||
if (!line.ok || line.rmsNs > kMaxRmsNs || line.rejected > kMaxRejected * double(line.used + line.rejected))
|
||||
return false;
|
||||
double hz = 1e9 / line.periodNs;
|
||||
return hz >= kMinHz && hz <= kMaxHz && (!needSpan || line.spanNs >= kMinSpanNs);
|
||||
return hz >= kMinHz * m_rate && hz <= kMaxHz * m_rate && (!needSpan || line.spanNs >= kMinSpanNs);
|
||||
}
|
||||
|
||||
RefreshFit::Line RefreshFit::Fit(size_t first) const
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
// the line (a late wake-up) are set aside and the line refitted without them.
|
||||
// A new display rate shows as a window that fits badly while its last 3 s fit
|
||||
// well: the older completions are dropped and the window refills.
|
||||
//
|
||||
// A display at a whole multiple of 60 Hz (120, 180, 240: SetMultiple, from
|
||||
// the display mode's rate) shows a 60 fps game a frame every `multiple`
|
||||
// refreshes, so its completions alone can't tell it from a 60 Hz one (all
|
||||
// gaps even). Told the multiple, the fit indexes completions in the display's
|
||||
// own refreshes (gaps of 2, and 1 or 3 where a frame came early or late, at
|
||||
// 120 Hz) and accepts `multiple` times 58.5-61.5 Hz: the guest vblank then
|
||||
// locks to every `multiple`th refresh (video/vblank_lock.h). At 1, the default,
|
||||
// everything is as it was. A rate that isn't such a multiple (90 Hz, 144 Hz)
|
||||
// stays at 1 and is never accepted (MultipleOf: 0).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
@@ -25,13 +35,14 @@ namespace video
|
||||
{
|
||||
struct RefreshEstimate
|
||||
{
|
||||
bool valid = false; // accepted: 58.5-61.5 Hz, at least 10 s of refreshes, small residuals
|
||||
double periodNs = 0; // one refresh
|
||||
bool valid = false; // accepted: 58.5-61.5 Hz (times `multiple`), at least 10 s of refreshes, small residuals
|
||||
double periodNs = 0; // one refresh (of the display: 8.33 ms at 120 Hz)
|
||||
int64_t gridNs = 0; // a refresh (the fitted time of the newest completion)
|
||||
double rmsNs = 0; // of the completions kept
|
||||
uint32_t samples = 0; // completions in the window
|
||||
uint32_t rejected = 0; // of which set aside as off the line
|
||||
double spanNs = 0; // refresh time the window covers (its segments together)
|
||||
int multiple = 1; // display refreshes per 60 Hz guest vblank: 1, or 2-4 (SetMultiple); 0: a grid check (SetGridHz)
|
||||
};
|
||||
|
||||
class RefreshFit
|
||||
@@ -40,10 +51,31 @@ namespace video
|
||||
// `t`, ns on the steady clock: a present was seen complete. In order.
|
||||
void Add(int64_t t);
|
||||
const RefreshEstimate& Estimate() const { return m_estimate; }
|
||||
// The display refreshes `multiple` (1-4) times per 60 Hz guest
|
||||
// vblank: its mode's rate is that multiple of 60 Hz (MultipleOf). A
|
||||
// change starts the fit over (the old completions are on another
|
||||
// grid). Out of range: 1.
|
||||
void SetMultiple(int multiple);
|
||||
int Multiple() const { return m_multiple; }
|
||||
// The whole multiple of 60 Hz (1-4) that `hz` lies within the
|
||||
// acceptance limits of (58.5-61.5 Hz times it): 1 for 59.94, 2 for
|
||||
// 119.88, 3 for 180, 4 for 239.76; 0 for 90, 143.9, 165 or an unknown
|
||||
// rate (0 or below).
|
||||
static int MultipleOf(double hz);
|
||||
// A grid check, not a lock's measure: completions indexed at `hz`
|
||||
// (the display mode's rate, any: 90, 144), accepted within 2.5% of
|
||||
// it, so a valid estimate says the display shows frames on a fixed
|
||||
// refresh grid at that rate (a variable-refresh one shows them as
|
||||
// they come, 16.7 ms apart, which fits no grid of 1/144 s). The
|
||||
// estimate's multiple is 0: never for the guest vblank. A change
|
||||
// starts the fit over.
|
||||
void SetGridHz(double hz);
|
||||
|
||||
// Acceptance limits: a 60 Hz display, give or take 2.5% (the guest
|
||||
// runs at the display's rate, and its game time with it).
|
||||
// runs at the display's rate, and its game time with it), or
|
||||
// `multiple` times that.
|
||||
static constexpr double kMinHz = 58.5, kMaxHz = 61.5;
|
||||
static constexpr int kMaxMultiple = 4; // 240 Hz
|
||||
static constexpr double kMaxRmsNs = 500'000; // 0.5 ms
|
||||
static constexpr double kMinSpanNs = 10e9; // 10 s
|
||||
static constexpr double kMaxRejected = 0.1; // of the window
|
||||
@@ -69,11 +101,14 @@ namespace video
|
||||
static constexpr int64_t kWindowNs = 20'000'000'000;
|
||||
static constexpr size_t kKeepSamples = 720;
|
||||
static constexpr int64_t kRecentNs = 3'000'000'000;
|
||||
static constexpr int64_t kMaxGap = 8; // refreshes; longer gaps start a segment
|
||||
static constexpr int64_t kMaxGap = 8; // refreshes (times the multiple: 8 vblanks); longer gaps start a segment
|
||||
static constexpr double kNominalNs = 1e9 / 60.0;
|
||||
|
||||
std::deque<Sample> m_samples;
|
||||
uint32_t m_segment = 0;
|
||||
int m_multiple = 1;
|
||||
double m_rate = 1.0; // refreshes per 1/60 s: the multiple, or a grid check's hz / 60
|
||||
bool m_lineFits = false; // the last fit was a good line (Acceptable without the span)
|
||||
RefreshEstimate m_estimate;
|
||||
mutable std::vector<double> m_scratch;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Which displays the guest vblank locks to every 2nd, 3rd or 4th refresh of
|
||||
// (video/refresh_fit.h's multiple, video/vblank_lock.h), from the display
|
||||
// mode's refresh rate (SDL's), and the log line that says so. Header only:
|
||||
// tests/refresh_fit_test.cpp checks it.
|
||||
//
|
||||
// Only the multiple comes from the mode: the period and phase are measured
|
||||
// from present completions, as at 60 Hz (the mode's rate is gamescope's CVT
|
||||
// one under gamescope, 59.94 for a 59.97 Hz monitor). At 60 Hz nothing
|
||||
// changes. A rate that is no whole multiple of 60 (90 Hz, the Steam Deck
|
||||
// OLED's; 144 Hz; 165 Hz) gets a line saying why the vblank free-runs there.
|
||||
//
|
||||
// Auto (NFSMW_REFRESH_MULTIPLE unset) is on everywhere but on Apple's
|
||||
// systems: there a MacBook Pro's ProMotion panel reports 120 Hz but has no
|
||||
// fixed refresh grid (frames are shown when their GPU work is done, on a
|
||||
// 240 Hz timing grid), and new frames are shown at a set time after their
|
||||
// vblank instead (video/present_schedule.h), measured to work on it and on a
|
||||
// player's external 120 Hz monitor (GitHub issue #20). NFSMW_REFRESH_MULTIPLE
|
||||
// =0: never (v0.1.6); =1: from the mode, on Apple's systems too; =2/3/4: that
|
||||
// multiple whatever the mode says (a display that misreports its rate).
|
||||
#pragma once
|
||||
#include "refresh_fit.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct RefreshMultiple
|
||||
{
|
||||
enum class Mode
|
||||
{
|
||||
Auto, // unset
|
||||
Off, // "0"
|
||||
On, // "1"
|
||||
Forced, // "2" to "4": `forced`
|
||||
};
|
||||
Mode mode = Mode::Auto;
|
||||
int forced = 0;
|
||||
|
||||
static RefreshMultiple FromEnv(const char* value)
|
||||
{
|
||||
RefreshMultiple r;
|
||||
if (!value || !*value)
|
||||
return r;
|
||||
if (!std::strcmp(value, "0"))
|
||||
r.mode = Mode::Off;
|
||||
else if (!std::strcmp(value, "1"))
|
||||
r.mode = Mode::On;
|
||||
else if (value[1] == 0 && value[0] >= '2' && value[0] <= '0' + RefreshFit::kMaxMultiple)
|
||||
{
|
||||
r.mode = Mode::Forced;
|
||||
r.forced = value[0] - '0';
|
||||
}
|
||||
return r; // anything else: Auto
|
||||
}
|
||||
|
||||
// The multiple RefreshFit should expect on a display whose mode runs
|
||||
// at `hz` (0: unknown): 1 (as v0.1.6), or 2-4.
|
||||
int For(double hz, bool apple) const
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case Mode::Off:
|
||||
return 1;
|
||||
case Mode::Forced:
|
||||
return forced;
|
||||
case Mode::Auto:
|
||||
if (apple)
|
||||
return 1;
|
||||
[[fallthrough]];
|
||||
case Mode::On:
|
||||
break;
|
||||
}
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
return m >= 2 ? m : 1;
|
||||
}
|
||||
|
||||
// The [vblank] line for a display mode of `hz` given multiple `m`
|
||||
// (For's), or "" where there is nothing to say: 60 Hz, an unknown
|
||||
// rate, Off, and Auto on Apple's systems (as v0.1.6 there).
|
||||
std::string Describe(double hz, int m, bool apple) const
|
||||
{
|
||||
char line[400];
|
||||
if (m >= 2)
|
||||
{
|
||||
std::snprintf(line, sizeof line, "a %.2f Hz display%s: the guest vblank locks to every %s refresh once presents "
|
||||
"measure it, and new frames are held for their refresh (NFSMW_REFRESH_MULTIPLE=0: as v0.1.6)", hz,
|
||||
mode == Mode::Forced ? " (NFSMW_REFRESH_MULTIPLE)" : "", m == 2 ? "2nd" : m == 3 ? "3rd" : "4th");
|
||||
return line;
|
||||
}
|
||||
if (mode == Mode::Off || (mode == Mode::Auto && apple) || !(hz > 0) || RefreshFit::MultipleOf(hz) >= 1)
|
||||
return {}; // 60 Hz (or a multiple held at 1: Forced never is, For never says 1 for one)
|
||||
const double perFrame = hz / 60.0;
|
||||
if (hz < RefreshFit::kMinHz)
|
||||
std::snprintf(line, sizeof line, "a %.2f Hz display is slower than 60 Hz: the guest vblank free-runs at 60 Hz, and "
|
||||
"some frames are never shown (%.2f refreshes a frame)", hz, perFrame);
|
||||
else if (hz > RefreshFit::kMaxHz * RefreshFit::kMaxMultiple)
|
||||
std::snprintf(line, sizeof line, "a %.2f Hz display is faster than the lock's 240 Hz: the guest vblank free-runs at "
|
||||
"60 Hz, and frames stay on screen for about %.2f refreshes each", hz, perFrame);
|
||||
else
|
||||
std::snprintf(line, sizeof line, "a %.2f Hz display is no whole multiple of 60 Hz: the guest vblank free-runs at 60 Hz, "
|
||||
"and each frame stays on screen for %d or %d refreshes (%.2f a frame); at 60 or 120 Hz, or with variable refresh, "
|
||||
"frames show evenly", hz, int(std::floor(perFrame)), int(std::floor(perFrame)) + 1, perFrame);
|
||||
return line;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The one-time notice on a display whose refresh rate 60 fps can't divide
|
||||
// evenly (the Steam Deck OLED at 90 Hz; 144 and 165 Hz monitors), and no
|
||||
// variable refresh: frames there stay on screen for 1 or 2 (2 or 3)
|
||||
// refreshes in turn, which looks like stutter, and only the player can
|
||||
// change the rate. When it shows and what it says; the presenter feeds it
|
||||
// (video/presenter.cpp), ui::ShowNotice draws it. Header only:
|
||||
// tests/refresh_notice_test.cpp checks it.
|
||||
//
|
||||
// Shown when, all at once:
|
||||
// - the display mode's rate (SDL's) is no whole multiple of 60 Hz
|
||||
// (RefreshFit::MultipleOf 0), from 58.5 Hz up, and it hasn't changed for
|
||||
// kStableSec;
|
||||
// - the display is a fixed-refresh one at that rate, as far as can be told:
|
||||
// on SteamOS, Linux and Windows the present waits' completions fit a grid
|
||||
// at the mode's rate (RefreshFit::SetGridHz) for kStableSec (a display
|
||||
// with variable refresh shows each frame as it comes, which fits no such
|
||||
// grid; the vblank lock's own "the display follows the vblank" is no use
|
||||
// here: at such a rate it isn't at the display's multiple, and the
|
||||
// intro's 30 fps movies, 3 refreshes a frame at 90 Hz, fit its 60 Hz line
|
||||
// until it gives up on a fixed display too); on a Mac,
|
||||
// macOS says the screen's refresh is fixed (its present waits sit on no
|
||||
// grid: a ProMotion panel, or one with adaptive sync, never shows it);
|
||||
// - not on iOS, nor on arm64 Linux (the Steam Frame: a headset's
|
||||
// compositor sets the rate);
|
||||
// - the game has shown frames for kSessionSec (past the logos and the
|
||||
// first loads), they have come without a gap of kGapMs for kFlowingSec
|
||||
// (not a load), no movie has played for kMovieQuietSec (the intro, a
|
||||
// cutscene), and the controls have been idle for kIdleSec (a menu, a
|
||||
// pause, the title screen; not a race: its throttle is held), with no
|
||||
// other panel or the settings menu open;
|
||||
// - not shown for this rate before (settings.toml's [app] refresh_notices;
|
||||
// NFSMW_REFRESH_NOTICE=0 never, =1 again every launch).
|
||||
// Game Mode's words are for the Deck's own screen (Quick Access's Refresh
|
||||
// Rate) unless a monitor or TV is connected (NFSMW_REFRESH_NOTICE_SCREEN=
|
||||
// builtin|external decides instead, for tests).
|
||||
// While it shows the game pauses, as for the settings menu (the 360's
|
||||
// Guide); A, B, Start, Esc or OK closes it, and the rate is recorded then.
|
||||
#pragma once
|
||||
#include "refresh_fit.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct RefreshNotice
|
||||
{
|
||||
enum class Platform
|
||||
{
|
||||
GameMode, // SteamOS Game Mode (gamescope)
|
||||
Desktop, // desktop Linux, Windows
|
||||
Mac,
|
||||
Excluded, // iOS, arm64 Linux (the Steam Frame)
|
||||
};
|
||||
struct Inputs
|
||||
{
|
||||
double modeHz = 0; // SDL's display mode
|
||||
double modeStableSec = 0; // since it last changed
|
||||
Platform platform = Platform::Desktop;
|
||||
bool externalScreen = false; // Game Mode: a monitor or TV, not the Deck's own screen
|
||||
bool gridFixed = false; // the grid check fits (not on a Mac)
|
||||
double gridStableSec = 0; // ...for this long
|
||||
bool macFixed = false; // macOS: the screen's refresh is fixed (min = max interval, not built in)
|
||||
bool shownBefore = false; // this rate is in settings.toml's [app] refresh_notices
|
||||
int forced = -1; // NFSMW_REFRESH_NOTICE: -1 unset, 0 never, 1 always
|
||||
double sessionSec = 0; // since the first game frame
|
||||
double flowingSec = 0; // since the last gap of kGapMs between game frames
|
||||
double movieQuietSec = 1e9; // since the last movie frame (the XMV player's)
|
||||
double idleSec = 0; // since the controls were last used
|
||||
bool uiBusy = false; // a panel or the settings menu is open
|
||||
};
|
||||
enum class Verdict
|
||||
{
|
||||
NotApplicable, // 60 Hz, a whole multiple, an unknown rate: nothing to say
|
||||
Never, // this rate, here: not shown (reason())
|
||||
Wait, // maybe later (reason())
|
||||
Show,
|
||||
};
|
||||
struct Decision
|
||||
{
|
||||
Verdict verdict = Verdict::NotApplicable;
|
||||
const char* reason = "";
|
||||
};
|
||||
|
||||
static constexpr double kStableSec = 5.0;
|
||||
static constexpr double kSessionSec = 30.0;
|
||||
static constexpr double kFlowingSec = 5.0;
|
||||
static constexpr double kGapMs = 250.0;
|
||||
static constexpr double kIdleSec = 3.0;
|
||||
static constexpr double kMovieQuietSec = 2.0;
|
||||
|
||||
// The rate as settings.toml records it: whole hertz.
|
||||
static int Key(double hz) { return int(std::lround(hz)); }
|
||||
|
||||
static Decision Decide(const Inputs& in)
|
||||
{
|
||||
if (!(in.modeHz >= RefreshFit::kMinHz) || RefreshFit::MultipleOf(in.modeHz) != 0)
|
||||
return { Verdict::NotApplicable, "" };
|
||||
if (in.forced == 0)
|
||||
return { Verdict::Never, "NFSMW_REFRESH_NOTICE=0" };
|
||||
if (in.platform == Platform::Excluded)
|
||||
return { Verdict::Never, "not on this device (iOS, or a headset's compositor)" };
|
||||
if (in.shownBefore && in.forced != 1)
|
||||
return { Verdict::Never, "dismissed before for this rate (settings.toml [app] refresh_notices)" };
|
||||
if (in.platform == Platform::Mac)
|
||||
{
|
||||
if (!in.macFixed)
|
||||
return { Verdict::Never, "macOS: a variable refresh rate (ProMotion, adaptive sync) or the built-in screen" };
|
||||
}
|
||||
else if (!in.gridFixed)
|
||||
return { Verdict::Wait, "no fixed refresh grid measured yet (variable refresh shows none)" };
|
||||
if (in.modeStableSec < kStableSec || (in.platform != Platform::Mac && in.gridStableSec < kStableSec))
|
||||
return { Verdict::Wait, "the rate isn't settled yet" };
|
||||
if (in.sessionSec < kSessionSec)
|
||||
return { Verdict::Wait, "starting up" };
|
||||
if (in.flowingSec < kFlowingSec)
|
||||
return { Verdict::Wait, "loading" };
|
||||
if (in.movieQuietSec < kMovieQuietSec)
|
||||
return { Verdict::Wait, "a movie is playing" };
|
||||
if (in.uiBusy)
|
||||
return { Verdict::Wait, "another panel is open" };
|
||||
if (in.idleSec < kIdleSec)
|
||||
return { Verdict::Wait, "the controls are in use (playing)" };
|
||||
return { Verdict::Show, "" };
|
||||
}
|
||||
|
||||
static constexpr const char* kTitle = "Screen refresh rate";
|
||||
|
||||
// What it says.
|
||||
static std::string Text(const Inputs& in)
|
||||
{
|
||||
char head[160];
|
||||
std::snprintf(head, sizeof head, "This screen is running at %d Hz, which doesn't divide 60 fps evenly, so motion can stutter. ",
|
||||
Key(in.modeHz));
|
||||
if (in.platform == Platform::GameMode && !in.externalScreen)
|
||||
return std::string(head) + "For smooth play, set Refresh Rate to 60 Hz in Quick Access > Performance. Turn on "
|
||||
"Use per-game profile there and Steam keeps it for this game.";
|
||||
if (in.platform == Platform::GameMode)
|
||||
return std::string(head) + "For smooth play, set the screen to 60 or 120 Hz in Steam's display settings, or turn "
|
||||
"on variable refresh if it has it.";
|
||||
return std::string(head) + "For smooth play, set it to 60 or 120 Hz in your display settings, or turn on variable "
|
||||
"refresh (FreeSync, G-SYNC, Adaptive-Sync) if the screen has it.";
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace video
|
||||
{
|
||||
@@ -15,24 +16,47 @@ namespace video
|
||||
m_locked = locked;
|
||||
m_steadyVblanks = 0;
|
||||
m_slipLeftNs = 0;
|
||||
// AdaptHold starts over with each lock (another display, maybe),
|
||||
// at the refresh right after the vblank's.
|
||||
m_extra = locked ? 1 - m_display.multiple : 0;
|
||||
m_earlierBackoff = 0;
|
||||
m_lastEarlier = false;
|
||||
m_sinceExtra = 0;
|
||||
m_earlyRun = m_earlyLate = 0;
|
||||
std::fill(m_missed, m_missed + kHistory, false);
|
||||
}
|
||||
if (!locked)
|
||||
return prev + m_fallbackNs;
|
||||
// One refresh, and a guest vblank's worth of them (1 at 60 Hz).
|
||||
const double period = m_display.periodNs;
|
||||
m_leadNs = period - double(kAfterRefreshNs);
|
||||
double candidate = double(prev) + period;
|
||||
const double vblank = period * m_display.multiple;
|
||||
m_leadNs = vblank - double(kAfterRefreshNs);
|
||||
double candidate = double(prev) + vblank;
|
||||
if (m_slipLeftNs > 0)
|
||||
{
|
||||
double step = std::min(double(kMaxStepNs), m_slipLeftNs);
|
||||
m_slipLeftNs -= step;
|
||||
return int64_t(std::llround(candidate + step));
|
||||
}
|
||||
// The wanted vblanks are grid + k * period - lead; the nearest one.
|
||||
// The wanted vblanks are grid + k * period - lead (k any refresh,
|
||||
// also at a multiple); the nearest one.
|
||||
double x = (candidate + m_leadNs - double(m_display.gridNs)) / period;
|
||||
m_errorNs = (std::round(x) - x) * period;
|
||||
double step = std::clamp(m_errorNs, -double(kMaxStepNs), double(kMaxStepNs));
|
||||
m_steadyVblanks = std::abs(m_errorNs) <= double(kMaxStepNs) ? m_steadyVblanks + 1 : 0;
|
||||
m_chasingVblanks = std::abs(m_errorNs) > double(kMaxStepNs) ? m_chasingVblanks + 1 : 0;
|
||||
// At a multiple, also the steps' sum: a display that follows the
|
||||
// vblank (its frames shown as they come, unheld at the first
|
||||
// target) keeps the lock stepping one way, though the fit's lag lets
|
||||
// the error reach a step now and then (which resets the count
|
||||
// above). A fixed display's grid is reached within half a refresh
|
||||
// of steps and then held; 10 s held forgets the walk.
|
||||
if (m_display.multiple >= 2)
|
||||
{
|
||||
m_chaseSumNs = m_steadyVblanks >= kSteadyToForget ? 0.0 : m_chaseSumNs + step;
|
||||
if (std::abs(m_chaseSumNs) > 1.5 * period)
|
||||
m_chasingVblanks = std::numeric_limits<uint32_t>::max() / 2;
|
||||
}
|
||||
if (double(m_chasingVblanks) * double(kMaxStepNs) > period)
|
||||
{
|
||||
m_follows = true;
|
||||
@@ -48,6 +72,30 @@ namespace video
|
||||
m_steadyVblanks = 0;
|
||||
m_chasingVblanks = 0;
|
||||
m_count = 0;
|
||||
m_lastOnScreen = 0;
|
||||
m_chaseSumNs = 0;
|
||||
}
|
||||
|
||||
void VblankLock::SetHold(bool on)
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_hold = on;
|
||||
}
|
||||
|
||||
int64_t VblankLock::PresentAt(int64_t vblank) const
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
if (!m_locked || m_display.multiple < 2 || !m_hold || !vblank)
|
||||
return 0;
|
||||
// The last refresh at or before the vblank (it fires kAfterRefreshNs
|
||||
// after one, a little later when its thread wakes late), and the
|
||||
// target `multiple` + m_extra refreshes on (AdaptHold; 1 at first):
|
||||
// rounding instead would take the next refresh for a vblank woken
|
||||
// more than half a refresh late.
|
||||
const double period = m_display.periodNs;
|
||||
const double k = std::floor(double(vblank - m_display.gridNs) / period);
|
||||
const double target = double(m_display.gridNs) + (k + m_display.multiple + m_extra) * period;
|
||||
return int64_t(std::llround(target - period + double(kHoldAfterRefreshNs)));
|
||||
}
|
||||
|
||||
void VblankLock::SetDisplay(const RefreshEstimate& estimate)
|
||||
@@ -56,20 +104,39 @@ namespace video
|
||||
m_display = estimate;
|
||||
}
|
||||
|
||||
void VblankLock::Observe(int64_t vblank, int64_t presented, int64_t onScreen)
|
||||
void VblankLock::Observe(int64_t vblank, int64_t presented, int64_t onScreen, int64_t ready)
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
if (!ready)
|
||||
ready = presented;
|
||||
uint32_t slot = m_count++ % kHistory;
|
||||
m_ready[slot] = float(presented - vblank);
|
||||
m_ready[slot] = float(ready - vblank);
|
||||
m_latency[slot] = float(onScreen - vblank);
|
||||
m_observed++;
|
||||
if (!m_locked)
|
||||
{
|
||||
m_late[slot] = 0;
|
||||
m_lastOnScreen = 0;
|
||||
return;
|
||||
}
|
||||
const double period = m_display.periodNs;
|
||||
m_late[slot] = int32_t(std::llround((double(onScreen - vblank) - m_leadNs) / period));
|
||||
if (m_display.multiple >= 2)
|
||||
{
|
||||
// Its predecessor's time on screen in refreshes, against the
|
||||
// vblanks between them (a gap of more than 0.5 s is a pause).
|
||||
if (m_lastOnScreen && onScreen - m_lastOnScreen <= 500'000'000)
|
||||
{
|
||||
const int64_t shown = std::llround(double(onScreen - m_lastOnScreen) / period);
|
||||
const int64_t wanted = std::max<int64_t>(1, std::llround(double(vblank - m_lastVblank) / (period * m_display.multiple))) *
|
||||
m_display.multiple;
|
||||
(shown == wanted ? m_even : shown < wanted ? m_shorter : m_longer)++;
|
||||
}
|
||||
m_lastOnScreen = onScreen;
|
||||
m_lastVblank = vblank;
|
||||
AdaptHold(vblank, ready, presented, onScreen);
|
||||
return; // nothing queues here (the display has spare refreshes): no slips
|
||||
}
|
||||
// Every frame of the last 2 s showed at least a refresh after its
|
||||
// target, with the phase settled and no slip for as long: a frame is
|
||||
// queued. Slip a refresh to drain it.
|
||||
@@ -94,6 +161,75 @@ namespace video
|
||||
}
|
||||
}
|
||||
|
||||
// Observe at a multiple, under the lock: frames held for later refreshes
|
||||
// when too many miss theirs, and for earlier ones again when every frame
|
||||
// for a while was ready in time for them; and a display that shows held
|
||||
// frames at once (variable refresh) let go of. As Observe's arguments.
|
||||
void VblankLock::AdaptHold(int64_t vblank, int64_t ready, int64_t presented, int64_t onScreen)
|
||||
{
|
||||
const double period = m_display.periodNs;
|
||||
if (presented > ready) // held (PresentAt)
|
||||
{
|
||||
// Counted across locks, as the chase is: such a display soon
|
||||
// stops fitting a line, and each lock is short.
|
||||
m_quick[m_quickNext] = double(onScreen - presented) < period / 2;
|
||||
m_quickNext = (m_quickNext + 1) % kHistory;
|
||||
m_heldSeen = std::min(m_heldSeen + 1, kHistory);
|
||||
if (m_heldSeen >= kQuickToFollow && uint32_t(std::count(m_quick, m_quick + kHistory, true)) * 3 >= m_heldSeen * 2)
|
||||
{
|
||||
m_follows = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The frame's target with no extra refreshes (the 60 Hz one,
|
||||
// PresentAt's at m_extra 0), and whether it was ready half a
|
||||
// refresh and kHoldMarginNs before the target one refresh earlier
|
||||
// than now.
|
||||
// Missed: seen a refresh or more after its held-for one (three
|
||||
// quarters of one: a present wait that returns late, as 1% do by up
|
||||
// to 2 ms, isn't a frame shown late at 240 Hz).
|
||||
const double k = std::floor(double(vblank - m_display.gridNs) / period);
|
||||
const double target = double(m_display.gridNs) + (k + m_display.multiple) * period;
|
||||
m_missed[m_missedNext] = double(onScreen) - target > (double(m_extra) + 0.75) * period;
|
||||
m_missedNext = (m_missedNext + 1) % kHistory;
|
||||
const double fewerAt = target + double(m_extra - 1) * period - period / 2 - double(kHoldMarginNs);
|
||||
if (m_extra > 1 - m_display.multiple)
|
||||
{
|
||||
m_earlyLate += double(ready) > fewerAt;
|
||||
m_earlyRun++;
|
||||
if (m_earlyLate > kEarlyExceptions)
|
||||
m_earlyRun = m_earlyLate = 0;
|
||||
}
|
||||
m_sinceExtra++;
|
||||
if (m_sinceExtra < kHistory || m_steadyVblanks < kHistory || m_count < kHistory)
|
||||
return;
|
||||
const uint32_t missed = uint32_t(std::count(m_missed, m_missed + kHistory, true));
|
||||
if (missed >= kMissedToHoldLonger && m_extra < kMaxExtraVblanks * m_display.multiple)
|
||||
{
|
||||
// An earlier target that didn't last (missed within 6 s): wait
|
||||
// twice as long before the next try.
|
||||
if (m_lastEarlier && m_sinceExtra < 3 * kHistory)
|
||||
m_earlierBackoff = std::min(m_earlierBackoff + 1, kMaxEarlierBackoff);
|
||||
m_lastEarlier = false;
|
||||
m_extra++;
|
||||
m_extraChanges++;
|
||||
m_extraMissed = missed;
|
||||
m_sinceExtra = 0;
|
||||
m_earlyRun = m_earlyLate = 0;
|
||||
std::fill(m_missed, m_missed + kHistory, false);
|
||||
}
|
||||
else if (m_earlyRun >= kEarlyToHoldShorter << m_earlierBackoff)
|
||||
{
|
||||
m_lastEarlier = true;
|
||||
m_extra--;
|
||||
m_extraChanges++;
|
||||
m_extraMissed = 0;
|
||||
m_sinceExtra = 0;
|
||||
m_earlyRun = m_earlyLate = 0;
|
||||
std::fill(m_missed, m_missed + kHistory, false);
|
||||
}
|
||||
}
|
||||
|
||||
bool VblankLock::Locked() const
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
@@ -103,7 +239,7 @@ namespace video
|
||||
double VblankLock::PeriodNs() const
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
return m_locked ? m_display.periodNs : double(m_fallbackNs);
|
||||
return m_locked ? m_display.periodNs * m_display.multiple : double(m_fallbackNs);
|
||||
}
|
||||
|
||||
VblankLock::Status VblankLock::GetStatus() const
|
||||
@@ -111,13 +247,19 @@ namespace video
|
||||
std::lock_guard lock(m_mutex);
|
||||
Status s;
|
||||
s.locked = m_locked;
|
||||
s.periodNs = m_locked ? m_display.periodNs : double(m_fallbackNs);
|
||||
s.periodNs = m_locked ? m_display.periodNs * m_display.multiple : double(m_fallbackNs);
|
||||
s.leadNs = m_leadNs;
|
||||
s.errorNs = m_errorNs;
|
||||
s.slips = m_slips;
|
||||
s.follows = m_follows;
|
||||
s.observed = m_observed;
|
||||
s.display = m_display;
|
||||
s.even = m_even;
|
||||
s.shorter = m_shorter;
|
||||
s.longer = m_longer;
|
||||
s.extra = m_extra;
|
||||
s.extraChanges = m_extraChanges;
|
||||
s.extraMissed = m_extraMissed;
|
||||
uint32_t n = std::min(m_count, kHistory);
|
||||
if (n)
|
||||
{
|
||||
|
||||
+101
-5
@@ -19,6 +19,40 @@
|
||||
// frames earlier they weren't queued but slow to present, and that lateness
|
||||
// is accepted from then on (one frame was shown twice to learn it).
|
||||
//
|
||||
// On a display at a whole multiple of 60 Hz (120, 180, 240: RefreshEstimate's
|
||||
// `multiple`, from video/refresh_fit.h) a guest vblank is `multiple`
|
||||
// refreshes: the vblank lands 1 ms after a refresh (any of them: the phase is
|
||||
// kept to the refresh grid), and its frame targets the refresh `multiple`
|
||||
// later, the one before the next vblank, as at 60 Hz (the same 15.7 ms from
|
||||
// vblank to screen). FIFO there shows a present on the first refresh after it,
|
||||
// though, with nothing queued (the display has spare refreshes): a frame ready
|
||||
// in under one refresh would show early and the one after it late, a
|
||||
// 1-and-3 pair at 120 Hz. So the presenter holds each new frame until just
|
||||
// after the refresh before its target (PresentAt), and every frame stays on
|
||||
// screen for exactly `multiple` refreshes, or one more when it misses its
|
||||
// refresh (with the next one shown on time, a refresh short). The target
|
||||
// starts at the refresh right after the vblank's (vblank to screen 7.3 ms at
|
||||
// 120 Hz, where it is 15.7 at 60): a frame ready within that refresh needs no
|
||||
// hold. Frames that miss it aren't queued as at 60 Hz (where FIFO's queue
|
||||
// then holds every later frame a refresh back, evenly, and a slip drains it):
|
||||
// when several of the last 2 s missed their refresh, the target moves a
|
||||
// refresh later (AdaptHold), up to two vblanks past the 60 Hz one, and a
|
||||
// refresh earlier again after 10 s of frames all ready half a refresh before
|
||||
// that earlier one (twice as long after each earlier target that missed
|
||||
// within 6 s, up to 160 s). There are no slips; the
|
||||
// "follows" chase counts in refreshes, as at 60 Hz. A display that follows
|
||||
// the vblank there (variable refresh in a 120 Hz mode, say) would show held
|
||||
// frames as soon as they are presented and so fit any grid the hold makes:
|
||||
// held frames seen on screen less than half a refresh after their present
|
||||
// (two thirds of the last 30-120 of them, across locks) are that, and the
|
||||
// lock lets go for the run, as at 60 Hz (on a fixed display a held frame
|
||||
// waits a refresh, so this can only take a fixed display for one where the
|
||||
// present waits return more than half a refresh late: it then free-runs,
|
||||
// as in v0.1.6). At the first target frames aren't held: there the lock's
|
||||
// steps adding up to more than a refresh and a half one way, without 10 s
|
||||
// held at the grid between, are that display (a fixed one's grid is
|
||||
// reached in half a refresh of steps).
|
||||
//
|
||||
// Without a valid measurement (no present wait, a display outside 58.5-61.5 Hz,
|
||||
// noisy completions) the vblank free-runs at `fallbackNs`, as before. So it
|
||||
// does, for the rest of the run, when the refreshes move with the vblank: a
|
||||
@@ -40,15 +74,26 @@ namespace video
|
||||
struct Status
|
||||
{
|
||||
bool locked = false;
|
||||
double periodNs = 0; // the vblank period in use
|
||||
double periodNs = 0; // the vblank period in use (locked: `display.multiple` refreshes)
|
||||
double leadNs = 0; // locked: before the refresh each vblank targets
|
||||
double errorNs = 0; // locked: the last vblank's distance from its wanted time
|
||||
uint64_t slips = 0; // refreshes slipped to drain queued frames
|
||||
bool follows = false; // the display follows the vblank: never locked again
|
||||
uint64_t observed = 0; // game frames timed on screen
|
||||
double latencyNs = 0; // median vblank -> on screen, recent frames
|
||||
double readyNs = 0; // 99th percentile vblank -> presented, recent frames
|
||||
double readyNs = 0; // 99th percentile vblank -> presented, recent frames (ready: before any hold)
|
||||
RefreshEstimate display; // the measurement
|
||||
// Locked at a multiple: new frames by refreshes on screen against
|
||||
// the vblanks' (since the lock): as many, fewer (shown early or
|
||||
// after a late one), more (late, or repeated). 0 at 60 Hz.
|
||||
uint64_t even = 0, shorter = 0, longer = 0;
|
||||
// Locked at a multiple: new frames target the refresh `multiple`
|
||||
// + extra after the last one before their vblank (AdaptHold;
|
||||
// extra starts at 1 - multiple: the next refresh), its changes,
|
||||
// and the misses in the last 2 s that made it later (0: earlier).
|
||||
int32_t extra = 0;
|
||||
uint64_t extraChanges = 0;
|
||||
uint32_t extraMissed = 0;
|
||||
};
|
||||
|
||||
VblankLock(int64_t fallbackNs, bool enabled) : m_fallbackNs(fallbackNs), m_enabled(enabled) {}
|
||||
@@ -65,16 +110,50 @@ namespace video
|
||||
// The display's refresh grid (from the presenter's wait thread).
|
||||
void SetDisplay(const RefreshEstimate& estimate);
|
||||
// A game frame, released by the vblank at `vblank`, was presented at
|
||||
// `presented` and seen on screen at `onScreen` (ns, steady clock).
|
||||
void Observe(int64_t vblank, int64_t presented, int64_t onScreen);
|
||||
// `presented` and seen on screen at `onScreen` (ns, steady clock);
|
||||
// `ready`: when it was ready to present, if it was held for its
|
||||
// refresh before that (PresentAt; 0: = presented).
|
||||
void Observe(int64_t vblank, int64_t presented, int64_t onScreen, int64_t ready = 0);
|
||||
Status GetStatus() const;
|
||||
bool Locked() const;
|
||||
double PeriodNs() const; // the vblank period now
|
||||
double PeriodNs() const; // the vblank period now (locked at a multiple: that many refreshes)
|
||||
// Main thread, before presenting a new game frame released by the
|
||||
// vblank that fired at `vblank`: not before the time returned (ns,
|
||||
// steady clock; 0: at once). Locked at a multiple only: just after the
|
||||
// refresh before the one the frame targets (the refresh `multiple`
|
||||
// after the last refresh before `vblank`, plus any lateness the lock
|
||||
// has accepted), so FIFO shows it on its target and no sooner.
|
||||
int64_t PresentAt(int64_t vblank) const;
|
||||
// NFSMW_PRESENT_HOLD=0: frames are presented when ready at a multiple
|
||||
// too (the lock alone, for comparison).
|
||||
void SetHold(bool on);
|
||||
|
||||
static constexpr int64_t kMaxStepNs = 50'000; // phase moved per vblank
|
||||
static constexpr int64_t kAfterRefreshNs = 1'000'000; // vblank this long after the refresh
|
||||
// A held frame is presented this long after the refresh before its
|
||||
// target (the fitted grid: a present seen on screen, a little after
|
||||
// the refresh itself), so the compositor latches it for the target.
|
||||
static constexpr int64_t kHoldAfterRefreshNs = 250'000;
|
||||
// AdaptHold: the target a refresh later when this many of the last
|
||||
// kHistory missed their refresh (one late frame, a stall's, doesn't),
|
||||
// at most kMaxExtraVblanks vblanks past the 60 Hz one; a refresh
|
||||
// earlier after kEarlyToHoldShorter frames in a row (times 2 to the
|
||||
// backoff: an earlier target that missed within 6 s doubles it, up
|
||||
// to kMaxEarlierBackoff) ready half a refresh and kHoldMarginNs
|
||||
// before that earlier target (the compositor's latch and the GPU's
|
||||
// last work fit in that).
|
||||
static constexpr uint32_t kMissedToHoldLonger = 2;
|
||||
static constexpr int32_t kMaxExtraVblanks = 2;
|
||||
static constexpr uint32_t kEarlyToHoldShorter = 600; // 10 s
|
||||
static constexpr uint32_t kEarlyExceptions = 0;
|
||||
static constexpr uint32_t kMaxEarlierBackoff = 4; // 160 s
|
||||
static constexpr int64_t kHoldMarginNs = 500'000;
|
||||
static constexpr uint32_t kQuickToFollow = 30; // held frames seen before judging
|
||||
static constexpr uint32_t kSteadyToForget = 600; // vblanks at the grid that forget the steps' sum
|
||||
|
||||
private:
|
||||
void AdaptHold(int64_t vblank, int64_t ready, int64_t presented, int64_t onScreen);
|
||||
|
||||
mutable std::mutex m_mutex;
|
||||
const int64_t m_fallbackNs;
|
||||
const bool m_enabled;
|
||||
@@ -104,5 +183,22 @@ namespace video
|
||||
// (a long present path), so lateness up to it is accepted.
|
||||
int32_t m_acceptedLate = 0, m_lateAtSlip = 0;
|
||||
bool m_slipJudged = true;
|
||||
bool m_hold = true;
|
||||
// Locked at a multiple: the last observed frame (cadence counts), and
|
||||
// AdaptHold's state: refreshes held past the target, which of the
|
||||
// last kHistory frames missed theirs, frames since a change, and
|
||||
// frames in a row ready in time for one refresh less.
|
||||
int64_t m_lastOnScreen = 0, m_lastVblank = 0;
|
||||
uint64_t m_even = 0, m_shorter = 0, m_longer = 0;
|
||||
int32_t m_extra = 0;
|
||||
bool m_missed[kHistory] = {};
|
||||
uint32_t m_missedNext = 0, m_sinceExtra = 0, m_earlyRun = 0, m_earlyLate = 0, m_extraMissed = 0, m_earlierBackoff = 0;
|
||||
bool m_lastEarlier = false;
|
||||
double m_chaseSumNs = 0; // at a multiple: the lock's steps since 10 s held (variable refresh)
|
||||
// Held frames seen on screen within half a refresh of their present,
|
||||
// of the last kHistory held ones (variable refresh).
|
||||
bool m_quick[kHistory] = {};
|
||||
uint32_t m_quickNext = 0, m_heldSeen = 0;
|
||||
uint64_t m_extraChanges = 0;
|
||||
};
|
||||
}
|
||||
@@ -86,7 +86,16 @@ sb_test(scale_budget_test SOURCES ${RT}/video/scale_budget.cpp ${RT}/video/pictu
|
||||
# tone shader's wrapping of the scale shaders, settings.cpp for the two
|
||||
# options' defaults (Xbox 360 Display Gamma on since v0.1.4).
|
||||
sb_test(display_tone_test ARGS ${RT}/video/presenter.cpp ${RT}/user/settings.cpp)
|
||||
# The display's refresh measured from present completions, at 60 Hz and at
|
||||
# whole multiples of it (video/refresh_fit.cpp, video/refresh_multiple.h).
|
||||
sb_test(refresh_fit_test SOURCES ${RT}/video/refresh_fit.cpp)
|
||||
# The one-time notice on a screen whose rate 60 fps can't divide evenly (90,
|
||||
# 144, 165 Hz): when it shows (variable refresh, Game Mode, a Mac's ProMotion,
|
||||
# dismissed, the moment), its words, the grid check under it, and the
|
||||
# dismissal in settings.toml, in a scratch folder (video/refresh_notice.h,
|
||||
# user/settings.cpp).
|
||||
sb_test(refresh_notice_test SOURCES ${RT}/video/refresh_fit.cpp ${RT}/user/settings.cpp
|
||||
ARGS ${CMAKE_CURRENT_BINARY_DIR}/refresh_notice_scratch)
|
||||
# How late the guest vblank's thread wakes on this machine, alone and with
|
||||
# every core busy (video/vblank_timer.cpp; checked on macOS, where its
|
||||
# scheduling changed in v0.1.4).
|
||||
@@ -227,3 +236,9 @@ target_include_directories(write_watch_test PRIVATE ${RT}/kernel ${RT}/thirdpart
|
||||
# The guest vblank lock's closed-loop simulation (a tool more than a test).
|
||||
add_executable(vblank_lock_sim ${CMAKE_CURRENT_SOURCE_DIR}/vblank_lock_sim.cpp ${RT}/video/refresh_fit.cpp ${RT}/video/vblank_lock.cpp)
|
||||
target_include_directories(vblank_lock_sim PRIVATE ${RT})
|
||||
target_compile_options(vblank_lock_sim PRIVATE -O2 -UNDEBUG)
|
||||
# Its displays at a whole multiple of 60 Hz (120, 180, 240: every 2nd-4th
|
||||
# refresh, frames held for theirs), the rates that aren't, variable refresh,
|
||||
# Frame Rate 30, Windows' late vblank wake-ups, and planted faults (about 40 s;
|
||||
# the 60 Hz tables, a minute and a half, by hand).
|
||||
add_test(NAME vblank_lock_sim_multiples COMMAND vblank_lock_sim --multiples)
|
||||
+229
-2
@@ -1,13 +1,19 @@
|
||||
// Synthetic-timestamp tests for video::RefreshFit (runtime/video/refresh_fit.cpp).
|
||||
// From the repo root:
|
||||
// Synthetic-timestamp tests for video::RefreshFit (runtime/video/refresh_fit.cpp)
|
||||
// and which displays it is told are a whole multiple of 60 Hz
|
||||
// (runtime/video/refresh_multiple.h). Sections 1-8 are the 60 Hz fit's (as in
|
||||
// v0.1.6); 9-15 the multiples (120, 180, 240 Hz), the rates that aren't
|
||||
// (143.9, 90 Hz), jitter, the policy, and planted wrong configurations that
|
||||
// must each fail a check. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/refresh_fit_test.cpp runtime/video/refresh_fit.cpp -o build/refresh_fit_test
|
||||
// build/refresh_fit_test
|
||||
#include <video/refresh_fit.h>
|
||||
#include <video/refresh_multiple.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
using video::RefreshFit;
|
||||
using video::RefreshEstimate;
|
||||
@@ -175,6 +181,227 @@ int main()
|
||||
CHECK(std::abs(got - hz) < 5e-4 && std::abs(got - 59.972616) > 0.02, "%.5f", got);
|
||||
}
|
||||
|
||||
// ---- Whole multiples of 60 Hz (refresh_multiple.h, vblank_lock.h) ----
|
||||
// A 60 fps game on a display of `multiple` x 60 Hz: a new frame every
|
||||
// `multiple` refreshes, `odd` of them a refresh early or late (gaps of
|
||||
// multiple -+ 1: the 1-and-3 pairs at 120 Hz), plus wake-up jitter.
|
||||
struct MultipleRun
|
||||
{
|
||||
bool valid = false;
|
||||
double hz = 0, rmsUs = 0, phaseUs = 0, validAfterS = -1;
|
||||
int multiple = 0;
|
||||
};
|
||||
auto runMultiple = [&](double hz, int multiple, double seconds, double odd, double jitterNs, bool absJitter = false) {
|
||||
Display d{ hz };
|
||||
Feed f;
|
||||
f.fit.SetMultiple(multiple);
|
||||
const int every = std::max(1, int(std::lround(hz / 60.0)));
|
||||
int64_t next = 0, firstValid = -1;
|
||||
f.Run(d, 0, int64_t(hz * seconds),
|
||||
[&](int64_t k) {
|
||||
if (k < next)
|
||||
return false;
|
||||
// The next frame `every` refreshes on, or one refresh either side.
|
||||
next = k + every + (uniform() < odd ? (uniform() < 0.5 ? -1 : 1) : 0);
|
||||
return true;
|
||||
},
|
||||
[&](int64_t) { return kWake + (absJitter ? std::abs(gauss(jitterNs)) : gauss(jitterNs)); },
|
||||
[&](int64_t k, const RefreshEstimate& e) { if (e.valid && firstValid < 0) firstValid = k; });
|
||||
const RefreshEstimate& e = f.fit.Estimate();
|
||||
MultipleRun r;
|
||||
r.valid = e.valid;
|
||||
r.hz = e.periodNs > 0 ? 1e9 / e.periodNs : 0;
|
||||
r.rmsUs = e.rmsNs / 1e3;
|
||||
r.phaseUs = (PhaseError(d, e) - kWake) / 1e3;
|
||||
r.validAfterS = firstValid >= 0 ? firstValid / hz : -1;
|
||||
r.multiple = e.multiple;
|
||||
return r;
|
||||
};
|
||||
|
||||
printf("9. 60, 120, 180 and 240 Hz (and 119.88, 239.76) told their multiple: a frame every 1-4 refreshes, 5%% a refresh early or late, "
|
||||
"0.2 ms jitter, 30 s\n");
|
||||
for (double hz : { 60.0, 59.94, 120.0, 119.88, 180.0, 240.0, 239.76 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
MultipleRun r = runMultiple(hz, m, 30, 0.05, 200'000);
|
||||
printf(" %7.2f Hz, multiple %d: valid %s after %.1f s, %.5f Hz (error %.1e), rms %.0f us, phase error %.0f us\n", hz, m,
|
||||
r.valid ? "yes" : "no", r.validAfterS, r.hz, r.hz - hz, r.rmsUs, r.phaseUs);
|
||||
CHECK(r.valid && r.multiple == m, "%.2f Hz not accepted as %d", hz, m);
|
||||
CHECK(std::abs(r.hz - hz) < 2e-4 * m, "%.2f Hz measured %.5f", hz, r.hz);
|
||||
CHECK(std::abs(r.phaseUs) < 100, "%.2f Hz phase %.0f us", hz, r.phaseUs);
|
||||
CHECK(r.validAfterS > 0 && r.validAfterS < 10.5, "%.2f Hz valid after %.1f s", hz, r.validAfterS);
|
||||
}
|
||||
|
||||
printf("9b. a free-running 59.9988 Hz guest on a 119.88 Hz display (before the lock): frames shown on the first refresh after "
|
||||
"vblank + 2-14 ms, so gaps of 1, 2 and 3 refreshes, all on the grid\n");
|
||||
{
|
||||
Display d{ 119.88 };
|
||||
RefreshFit fit;
|
||||
fit.SetMultiple(2);
|
||||
const double p = 1e9 / d.hz;
|
||||
int64_t lastShown = INT64_MIN;
|
||||
for (int64_t i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
const double ready = double(i) * 16'667'000.0 + 2e6 + 12e6 * uniform();
|
||||
int64_t k = int64_t(std::ceil((ready - d.phaseNs) / p));
|
||||
k = std::max(k, lastShown + 1); // one frame a refresh at most (FIFO)
|
||||
lastShown = k;
|
||||
fit.Add(int64_t(d.Refresh(k) + kWake + gauss(200'000)));
|
||||
}
|
||||
const RefreshEstimate& e = fit.Estimate();
|
||||
printf(" valid %s, %.5f Hz, rms %.0f us\n", e.valid ? "yes" : "no", 1e9 / e.periodNs, e.rmsNs / 1e3);
|
||||
CHECK(e.valid && std::abs(1e9 / e.periodNs - d.hz) < 4e-4, "%.5f Hz", 1e9 / e.periodNs);
|
||||
}
|
||||
|
||||
printf("10. no whole multiple: 143.9, 144 and 165 Hz (a 60 fps frame every 2 or 3 refreshes) and 90 Hz (1 or 2): MultipleOf 0, "
|
||||
"the fit stays at 1 and doesn't accept them\n");
|
||||
for (double hz : { 143.9, 144.0, 165.0, 90.0 })
|
||||
{
|
||||
CHECK(RefreshFit::MultipleOf(hz) == 0, "%.1f Hz has multiple %d", hz, RefreshFit::MultipleOf(hz));
|
||||
const video::RefreshMultiple policy;
|
||||
CHECK(policy.For(hz, false) == 1, "%.1f Hz: For %d", hz, policy.For(hz, false));
|
||||
Display d{ hz };
|
||||
Feed f;
|
||||
f.fit.SetMultiple(policy.For(hz, false));
|
||||
const double p = 1e9 / hz;
|
||||
int64_t lastShown = INT64_MIN;
|
||||
bool everValid = false;
|
||||
for (int64_t i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
const double ready = d.phaseNs + double(i) * 1e9 / 60.0 + 3e6 + gauss(500'000);
|
||||
int64_t k = std::max(int64_t(std::ceil((ready - d.phaseNs) / p)), lastShown + 1);
|
||||
lastShown = k;
|
||||
f.fit.Add(int64_t(d.Refresh(k) + kWake + gauss(200'000)));
|
||||
everValid |= f.fit.Estimate().valid;
|
||||
}
|
||||
printf(" %6.1f Hz: valid %s (ever: %s), fitted %.3f Hz, rms %.0f us\n", hz, f.fit.Estimate().valid ? "yes" : "no",
|
||||
everValid ? "yes" : "no", 1e9 / f.fit.Estimate().periodNs, f.fit.Estimate().rmsNs / 1e3);
|
||||
CHECK(!everValid, "%.1f Hz accepted", hz);
|
||||
CHECK(policy.Describe(hz, 1, false).find("no whole multiple") != std::string::npos, "%.1f Hz: no line saying why", hz);
|
||||
}
|
||||
|
||||
printf("11. jitter at 120 and 240 Hz: 0.3 ms accepted, 1.5 ms wake-ups not\n");
|
||||
for (double hz : { 120.0, 240.0 })
|
||||
{
|
||||
MultipleRun ok = runMultiple(hz, RefreshFit::MultipleOf(hz), 30, 0.02, 300'000);
|
||||
MultipleRun bad = runMultiple(hz, RefreshFit::MultipleOf(hz), 30, 0.02, 1'500'000, true);
|
||||
printf(" %.0f Hz: 0.3 ms valid %s (rms %.0f us), 1.5 ms valid %s (rms %.0f us)\n", hz, ok.valid ? "yes" : "no", ok.rmsUs,
|
||||
bad.valid ? "yes" : "no", bad.rmsUs);
|
||||
CHECK(ok.valid && std::abs(ok.hz - hz) < 5e-4 * RefreshFit::MultipleOf(hz), "%.0f Hz at 0.3 ms: %.5f", hz, ok.hz);
|
||||
CHECK(!bad.valid, "%.0f Hz accepted a 1.5 ms jitter stream", hz);
|
||||
}
|
||||
|
||||
printf("12. MultipleOf at and around the limits\n");
|
||||
{
|
||||
const struct { double hz; int m; } rows[] = { { 0, 0 }, { -1, 0 }, { 30, 0 }, { 50, 0 }, { 58.49, 0 }, { 58.5, 1 }, { 59.94, 1 },
|
||||
{ 61.5, 1 }, { 61.51, 0 }, { 75, 0 }, { 90, 0 }, { 100, 0 }, { 116.9, 0 }, { 117, 2 }, { 119.88, 2 }, { 120, 2 }, { 123, 2 },
|
||||
{ 123.1, 0 }, { 143.9, 0 }, { 144, 0 }, { 165, 0 }, { 175.4, 0 }, { 175.5, 3 }, { 180, 3 }, { 184.5, 3 }, { 200, 0 },
|
||||
{ 233.9, 0 }, { 234, 4 }, { 239.76, 4 }, { 240, 4 }, { 246, 4 }, { 246.1, 0 }, { 300, 0 }, { 360, 0 } };
|
||||
int bad = 0;
|
||||
for (const auto& r : rows)
|
||||
if (RefreshFit::MultipleOf(r.hz) != r.m)
|
||||
{
|
||||
bad++;
|
||||
CHECK(false, "MultipleOf(%.2f) = %d, want %d", r.hz, RefreshFit::MultipleOf(r.hz), r.m);
|
||||
}
|
||||
printf(" %zu rates, %d wrong\n", std::size(rows), bad);
|
||||
}
|
||||
|
||||
printf("13. a new multiple starts the fit over: valid at 60 Hz, told 2 (the window moved to a 120 Hz display), valid again at 120\n");
|
||||
{
|
||||
Display a{ 59.972616 };
|
||||
Feed f;
|
||||
f.Run(a, 0, 900, [](int64_t) { return true; }, [&](int64_t) { return kWake + gauss(200'000); });
|
||||
CHECK(f.fit.Estimate().valid && f.fit.Estimate().multiple == 1, "not valid at 60 Hz");
|
||||
f.fit.SetMultiple(2);
|
||||
CHECK(!f.fit.Estimate().valid && f.fit.Estimate().multiple == 2 && f.fit.Estimate().samples == 0, "the old estimate kept");
|
||||
f.fit.SetMultiple(2); // the same again: nothing to start over
|
||||
Display b{ 120.0, 2'000'000 };
|
||||
int64_t start = int64_t(std::ceil((a.Refresh(900) - b.phaseNs) * b.hz / 1e9)), validAt = -1;
|
||||
f.Run(b, start, start + 120 * 15, [](int64_t k) { return k % 2 == 0; }, [&](int64_t) { return kWake + gauss(200'000); },
|
||||
[&](int64_t k, const RefreshEstimate& e) { if (e.valid && validAt < 0) validAt = k - start; });
|
||||
const RefreshEstimate& e = f.fit.Estimate();
|
||||
printf(" valid again after %.1f s at %.5f Hz (multiple %d)\n", validAt / b.hz, 1e9 / e.periodNs, e.multiple);
|
||||
// 15 s of completions at 0.2 ms jitter: within 1e-3 Hz (8 ppm; libc++ and libstdc++ draw
|
||||
// other noise: 120.00033 and 120.00042 Hz).
|
||||
CHECK(e.valid && std::abs(1e9 / e.periodNs - 120.0) < 1e-3 && validAt / b.hz < 10.5, "%.5f Hz", 1e9 / e.periodNs);
|
||||
f.fit.SetMultiple(9); // out of range: 1
|
||||
CHECK(f.fit.Multiple() == 1 && !f.fit.Estimate().valid, "multiple %d", f.fit.Multiple());
|
||||
}
|
||||
|
||||
printf("14. the policy (NFSMW_REFRESH_MULTIPLE, the platform, the mode's rate) and its log line\n");
|
||||
{
|
||||
using video::RefreshMultiple;
|
||||
const RefreshMultiple autoMode = RefreshMultiple::FromEnv(nullptr), off = RefreshMultiple::FromEnv("0"),
|
||||
on = RefreshMultiple::FromEnv("1"), three = RefreshMultiple::FromEnv("3"),
|
||||
junk = RefreshMultiple::FromEnv("7"), empty = RefreshMultiple::FromEnv("");
|
||||
CHECK(autoMode.mode == RefreshMultiple::Mode::Auto && empty.mode == RefreshMultiple::Mode::Auto &&
|
||||
junk.mode == RefreshMultiple::Mode::Auto, "parsing");
|
||||
CHECK(off.mode == RefreshMultiple::Mode::Off && on.mode == RefreshMultiple::Mode::On &&
|
||||
three.mode == RefreshMultiple::Mode::Forced && three.forced == 3, "parsing");
|
||||
struct Row { const RefreshMultiple* p; const char* name; double hz; bool apple; int want; };
|
||||
const Row rows[] = {
|
||||
{ &autoMode, "auto", 59.94, false, 1 }, { &autoMode, "auto", 119.88, false, 2 }, { &autoMode, "auto", 180, false, 3 },
|
||||
{ &autoMode, "auto", 240, false, 4 }, { &autoMode, "auto", 144, false, 1 }, { &autoMode, "auto", 90, false, 1 },
|
||||
{ &autoMode, "auto", 0, false, 1 }, { &autoMode, "auto", 120, true, 1 }, { &autoMode, "auto", 60, true, 1 },
|
||||
{ &on, "on", 120, true, 2 }, { &on, "on", 144, true, 1 }, { &off, "off", 120, false, 1 }, { &off, "off", 240, false, 1 },
|
||||
{ &three, "3", 60, false, 3 }, { &three, "3", 144, true, 3 },
|
||||
};
|
||||
for (const Row& r : rows)
|
||||
CHECK(r.p->For(r.hz, r.apple) == r.want, "%s at %.2f Hz%s: %d, want %d", r.name, r.hz, r.apple ? " (Apple)" : "",
|
||||
r.p->For(r.hz, r.apple), r.want);
|
||||
// Lines: none at 60 Hz, at an unknown rate, off, or Auto on Apple's systems (v0.1.6 there).
|
||||
CHECK(autoMode.Describe(59.94, 1, false).empty() && autoMode.Describe(0, 1, false).empty(), "a line at 60 Hz or unknown");
|
||||
CHECK(autoMode.Describe(120, 1, true).empty() && autoMode.Describe(144, 1, true).empty(), "a line on Apple's systems");
|
||||
CHECK(off.Describe(144, 1, false).empty() && off.Describe(120, 1, false).empty(), "a line with NFSMW_REFRESH_MULTIPLE=0");
|
||||
const std::string l120 = autoMode.Describe(119.88, 2, false), l240 = autoMode.Describe(240, 4, false),
|
||||
l90 = autoMode.Describe(90, 1, false), l50 = autoMode.Describe(50, 1, false), l300 = autoMode.Describe(300, 1, false);
|
||||
printf(" 119.88: %s\n 90: %s\n 50: %s\n 300: %s\n", l120.c_str(), l90.c_str(), l50.c_str(), l300.c_str());
|
||||
CHECK(l120.find("every 2nd refresh") != std::string::npos && l240.find("every 4th refresh") != std::string::npos, "multiple lines");
|
||||
CHECK(l90.find("1 or 2 refreshes") != std::string::npos && l90.find("1.50") != std::string::npos, "90 Hz: %s", l90.c_str());
|
||||
CHECK(autoMode.Describe(144, 1, false).find("2 or 3 refreshes (2.40") != std::string::npos, "144 Hz");
|
||||
CHECK(l50.find("slower than 60 Hz") != std::string::npos && l300.find("faster than") != std::string::npos, "50 / 300 Hz");
|
||||
CHECK(three.Describe(60, 3, false).find("(NFSMW_REFRESH_MULTIPLE)") != std::string::npos, "forced");
|
||||
}
|
||||
|
||||
printf("15. planted wrong configurations, each must fail a check above\n");
|
||||
{
|
||||
int caught = 0, plants = 0;
|
||||
auto plant = [&](const char* name, bool failed) {
|
||||
plants++;
|
||||
caught += failed;
|
||||
printf(" plant: %-62s %s\n", name, failed ? "caught" : "NOT caught");
|
||||
CHECK(failed, "planted \"%s\" passed", name);
|
||||
};
|
||||
// 9's check (accepted, at the display's own rate) against:
|
||||
auto passes9 = [&](double hz, int multiple) {
|
||||
MultipleRun r = runMultiple(hz, multiple, 30, 0.05, 200'000);
|
||||
return r.valid && std::abs(r.hz - hz) < 2e-4 * std::max(multiple, 1);
|
||||
};
|
||||
plant("v0.1.6's fit (multiple 1) on a 119.88 Hz display", !passes9(119.88, 1));
|
||||
plant("multiple 2 on a 240 Hz display (fits every 2nd refresh)", !passes9(240.0, 2));
|
||||
plant("multiple 3 on a 240 Hz display", !passes9(240.0, 3));
|
||||
plant("multiple 4 on a 180 Hz display", !passes9(180.0, 4));
|
||||
// 10's check (never accepted) against a 143.9 Hz display told 2 or 3.
|
||||
for (int m : { 2, 3 })
|
||||
{
|
||||
Display d{ 143.9 };
|
||||
RefreshFit fit;
|
||||
fit.SetMultiple(m);
|
||||
bool ever = false;
|
||||
int64_t lastShown = INT64_MIN;
|
||||
for (int64_t i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
int64_t k = std::max(int64_t(std::ceil((double(i) * 1e9 / 60.0 + 3e6 + gauss(500'000)) * d.hz / 1e9)), lastShown + 1);
|
||||
lastShown = k;
|
||||
fit.Add(int64_t(d.Refresh(k) + kWake + gauss(200'000)));
|
||||
ever |= fit.Estimate().valid;
|
||||
}
|
||||
plant(m == 2 ? "143.9 Hz told multiple 2" : "143.9 Hz told multiple 3", !ever);
|
||||
}
|
||||
printf(" %d of %d caught\n", caught, plants);
|
||||
}
|
||||
|
||||
printf(g_failures ? "\n%d FAILED\n" : "\nall passed\n", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The one-time refresh-rate notice (video/refresh_notice.h): when it shows (the
|
||||
// display rates, variable refresh, Game Mode, a Mac's ProMotion or fixed
|
||||
// external screen, a headset, already dismissed, NFSMW_REFRESH_NOTICE, and the
|
||||
// moment: settled rate, past start-up, not loading, the controls idle, no other
|
||||
// panel, no movie); what it says on each platform; the grid check it rests on
|
||||
// (RefreshFit::SetGridHz: a fixed 90/143.9/144/165 Hz display taken, variable
|
||||
// refresh never); and the dismissal in settings.toml ([app] refresh_notices,
|
||||
// user/settings.cpp as the game links it), in a scratch folder (the argument).
|
||||
//
|
||||
// Build by hand (from the repo root):
|
||||
// c++ -std=c++20 -O2 -UNDEBUG -I runtime tests/refresh_notice_test.cpp runtime/video/refresh_fit.cpp \
|
||||
// runtime/user/settings.cpp -o build/refresh_notice_test
|
||||
// build/refresh_notice_test build/refresh_notice_scratch
|
||||
#include <user/settings.h>
|
||||
#include <video/refresh_fit.h>
|
||||
#include <video/refresh_notice.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failures = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
s_failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fprintf(stderr, "\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using video::RefreshNotice;
|
||||
using Platform = RefreshNotice::Platform;
|
||||
using Verdict = RefreshNotice::Verdict;
|
||||
|
||||
const char* Name(Verdict v)
|
||||
{
|
||||
switch (v)
|
||||
{
|
||||
case Verdict::NotApplicable: return "n/a";
|
||||
case Verdict::Never: return "never";
|
||||
case Verdict::Wait: return "wait";
|
||||
case Verdict::Show: return "show";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
// Everything settled and fixed: a display it should show on.
|
||||
RefreshNotice::Inputs Ready(double hz, Platform platform)
|
||||
{
|
||||
RefreshNotice::Inputs in;
|
||||
in.modeHz = hz;
|
||||
in.modeStableSec = 60;
|
||||
in.platform = platform;
|
||||
in.gridFixed = platform != Platform::Mac;
|
||||
in.gridStableSec = in.gridFixed ? 40 : 0;
|
||||
in.macFixed = platform == Platform::Mac;
|
||||
in.sessionSec = 90;
|
||||
in.flowingSec = 20;
|
||||
in.idleSec = 10;
|
||||
return in;
|
||||
}
|
||||
|
||||
std::string Read(const std::filesystem::path& path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::stringstream text;
|
||||
text << in.rdbuf();
|
||||
return text.str();
|
||||
}
|
||||
|
||||
void WhenItShows()
|
||||
{
|
||||
printf("when it shows:\n");
|
||||
struct Row
|
||||
{
|
||||
const char* name;
|
||||
RefreshNotice::Inputs in;
|
||||
Verdict want;
|
||||
};
|
||||
auto with = [](RefreshNotice::Inputs in, auto change) { change(in); return in; };
|
||||
const Row rows[] = {
|
||||
// The rates.
|
||||
{ "Steam Deck OLED, 90 Hz, Game Mode", Ready(90, Platform::GameMode), Verdict::Show },
|
||||
{ "144 Hz desktop", Ready(144, Platform::Desktop), Verdict::Show },
|
||||
{ "143.9 Hz desktop", Ready(143.9, Platform::Desktop), Verdict::Show },
|
||||
{ "165 Hz desktop", Ready(165, Platform::Desktop), Verdict::Show },
|
||||
{ "100 Hz desktop", Ready(100, Platform::Desktop), Verdict::Show },
|
||||
{ "75 Hz desktop", Ready(75, Platform::Desktop), Verdict::Show },
|
||||
{ "59.94 Hz: even", Ready(59.94, Platform::GameMode), Verdict::NotApplicable },
|
||||
{ "60 Hz: even", Ready(60, Platform::Desktop), Verdict::NotApplicable },
|
||||
{ "119.88 Hz: a multiple (locked)", Ready(119.88, Platform::Desktop), Verdict::NotApplicable },
|
||||
{ "180 Hz: a multiple", Ready(180, Platform::Desktop), Verdict::NotApplicable },
|
||||
{ "240 Hz: a multiple", Ready(240, Platform::GameMode), Verdict::NotApplicable },
|
||||
{ "50 Hz: below 58.5", Ready(50, Platform::Desktop), Verdict::NotApplicable },
|
||||
{ "unknown rate (0)", Ready(0, Platform::Desktop), Verdict::NotApplicable },
|
||||
// Variable refresh: no grid measured, or the lock let go.
|
||||
{ "144 Hz, no fixed grid (variable refresh)", with(Ready(144, Platform::Desktop), [](auto& i) { i.gridFixed = false; i.gridStableSec = 0; }), Verdict::Wait },
|
||||
{ "90 Hz Game Mode, no grid", with(Ready(90, Platform::GameMode), [](auto& i) { i.gridFixed = false; i.gridStableSec = 0; }), Verdict::Wait },
|
||||
// Macs: ProMotion (or adaptive sync) never; a fixed external screen yes.
|
||||
{ "Mac ProMotion, 120 Hz: a multiple anyway", Ready(120, Platform::Mac), Verdict::NotApplicable },
|
||||
{ "Mac ProMotion at 90 Hz (variable)", with(Ready(90, Platform::Mac), [](auto& i) { i.macFixed = false; }), Verdict::Never },
|
||||
{ "Mac external 144 Hz, adaptive sync on", with(Ready(144, Platform::Mac), [](auto& i) { i.macFixed = false; }), Verdict::Never },
|
||||
{ "Mac external 144 Hz, fixed", Ready(144, Platform::Mac), Verdict::Show },
|
||||
{ "Mac external 144 Hz, fixed, no grid (Metal's waits don't sit on one)", with(Ready(144, Platform::Mac), [](auto& i) { i.gridFixed = false; }), Verdict::Show },
|
||||
// Not here at all.
|
||||
{ "iOS or a headset (excluded)", Ready(90, Platform::Excluded), Verdict::Never },
|
||||
// Dismissed, and the override.
|
||||
{ "90 Hz, dismissed before", with(Ready(90, Platform::GameMode), [](auto& i) { i.shownBefore = true; }), Verdict::Never },
|
||||
{ "90 Hz, NFSMW_REFRESH_NOTICE=0", with(Ready(90, Platform::GameMode), [](auto& i) { i.forced = 0; }), Verdict::Never },
|
||||
{ "90 Hz, dismissed, NFSMW_REFRESH_NOTICE=1", with(Ready(90, Platform::GameMode), [](auto& i) { i.shownBefore = true; i.forced = 1; }), Verdict::Show },
|
||||
// The moment.
|
||||
{ "the mode just changed", with(Ready(144, Platform::Desktop), [](auto& i) { i.modeStableSec = 1; }), Verdict::Wait },
|
||||
{ "the grid just fitted", with(Ready(144, Platform::Desktop), [](auto& i) { i.gridStableSec = 2; }), Verdict::Wait },
|
||||
{ "starting up (20 s in)", with(Ready(90, Platform::GameMode), [](auto& i) { i.sessionSec = 20; }), Verdict::Wait },
|
||||
{ "loading (frames back 1 s ago)", with(Ready(90, Platform::GameMode), [](auto& i) { i.flowingSec = 1; }), Verdict::Wait },
|
||||
{ "racing (the throttle held)", with(Ready(90, Platform::GameMode), [](auto& i) { i.idleSec = 0; }), Verdict::Wait },
|
||||
{ "the controls used 2 s ago", with(Ready(90, Platform::GameMode), [](auto& i) { i.idleSec = 2; }), Verdict::Wait },
|
||||
{ "a movie playing (the intro, a cutscene)", with(Ready(90, Platform::GameMode), [](auto& i) { i.movieQuietSec = 0.1; }), Verdict::Wait },
|
||||
{ "a movie ended 3 s ago", with(Ready(90, Platform::GameMode), [](auto& i) { i.movieQuietSec = 3; }), Verdict::Show },
|
||||
{ "the settings menu or another panel is open", with(Ready(90, Platform::GameMode), [](auto& i) { i.uiBusy = true; }), Verdict::Wait },
|
||||
{ "Mac: the mode just changed", with(Ready(144, Platform::Mac), [](auto& i) { i.modeStableSec = 1; }), Verdict::Wait },
|
||||
};
|
||||
int wrong = 0;
|
||||
for (const Row& r : rows)
|
||||
{
|
||||
const RefreshNotice::Decision d = RefreshNotice::Decide(r.in);
|
||||
const bool ok = d.verdict == r.want;
|
||||
wrong += !ok;
|
||||
printf(" %-62s %-6s %s%s\n", r.name, Name(d.verdict), d.reason, ok ? "" : " <- WRONG");
|
||||
CHECK(ok, "%s: %s, want %s", r.name, Name(d.verdict), Name(r.want));
|
||||
}
|
||||
printf(" %zu cases, %d wrong\n", std::size(rows), wrong);
|
||||
}
|
||||
|
||||
void WhatItSays()
|
||||
{
|
||||
printf("what it says:\n");
|
||||
const std::string deck = RefreshNotice::Text(Ready(90, Platform::GameMode));
|
||||
RefreshNotice::Inputs docked = Ready(144, Platform::GameMode);
|
||||
docked.externalScreen = true;
|
||||
const std::string tv = RefreshNotice::Text(docked);
|
||||
const std::string desktop = RefreshNotice::Text(Ready(143.9, Platform::Desktop));
|
||||
const std::string mac = RefreshNotice::Text(Ready(165, Platform::Mac));
|
||||
printf(" Game Mode, the Deck's screen: %s\n Game Mode, a monitor: %s\n desktop: %s\n Mac: %s\n", deck.c_str(), tv.c_str(),
|
||||
desktop.c_str(), mac.c_str());
|
||||
CHECK(deck.find("90 Hz") != std::string::npos && deck.find("Quick Access > Performance") != std::string::npos &&
|
||||
deck.find("60 Hz") != std::string::npos && deck.find("per-game profile") != std::string::npos, "%s", deck.c_str());
|
||||
CHECK(tv.find("144 Hz") != std::string::npos && tv.find("Quick Access") == std::string::npos &&
|
||||
tv.find("60 or 120 Hz") != std::string::npos, "%s", tv.c_str());
|
||||
CHECK(desktop.find("144 Hz") != std::string::npos && desktop.find("display settings") != std::string::npos &&
|
||||
desktop.find("variable refresh") != std::string::npos, "%s", desktop.c_str());
|
||||
CHECK(mac == RefreshNotice::Text(Ready(165, Platform::Desktop)), "Mac: %s", mac.c_str());
|
||||
CHECK(RefreshNotice::Key(143.9) == 144 && RefreshNotice::Key(89.6) == 90 && RefreshNotice::Key(165.0) == 165, "Key");
|
||||
}
|
||||
|
||||
// The grid check under it: completions of a 60 fps game on a fixed
|
||||
// display (each frame on the first refresh after it is ready, 1-2 ms
|
||||
// jitter in readiness, 0.2 ms in the waits), against a variable-refresh
|
||||
// display that shows each frame as it comes.
|
||||
void GridCheck()
|
||||
{
|
||||
printf("the grid check (RefreshFit::SetGridHz):\n");
|
||||
std::mt19937_64 rng(77);
|
||||
auto gauss = [&](double sd) { return std::normal_distribution<double>(0.0, sd)(rng); };
|
||||
for (double hz : { 90.0, 143.9, 144.0, 165.0, 100.0 })
|
||||
for (bool variable : { false, true })
|
||||
{
|
||||
video::RefreshFit grid;
|
||||
grid.SetGridHz(hz);
|
||||
const double p = 1e9 / hz;
|
||||
int64_t last = INT64_MIN;
|
||||
double validAt = -1;
|
||||
for (int i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
const double ready = double(i) * 1e9 / 60.0 + 3e6 + gauss(variable ? 0.3e6 : 1.0e6);
|
||||
double on;
|
||||
if (variable)
|
||||
on = ready + 0.5e6;
|
||||
else
|
||||
{
|
||||
int64_t k = std::max(int64_t(std::ceil(ready / p)), last + 1);
|
||||
last = k;
|
||||
on = double(k) * p;
|
||||
}
|
||||
grid.Add(int64_t(1e12 + on + 150'000 + gauss(200'000)));
|
||||
if (grid.Estimate().valid && validAt < 0)
|
||||
validAt = double(i) / 60.0;
|
||||
}
|
||||
const auto& e = grid.Estimate();
|
||||
printf(" %6.1f Hz %-9s: valid %s (after %.1f s), %.3f Hz, rms %.0f us, multiple %d\n", hz, variable ? "variable" : "fixed",
|
||||
e.valid ? "yes" : "no", validAt, e.periodNs > 0 ? 1e9 / e.periodNs : 0.0, e.rmsNs / 1e3, e.multiple);
|
||||
if (variable)
|
||||
CHECK(!e.valid, "%.1f Hz variable refresh taken for a grid", hz);
|
||||
else
|
||||
CHECK(e.valid && std::abs(1e9 / e.periodNs - hz) < 2e-3 && validAt > 0 && validAt < 10.5 && e.multiple == 0,
|
||||
"%.1f Hz fixed: valid %d %.4f Hz after %.1f s", hz, e.valid, 1e9 / e.periodNs, validAt);
|
||||
}
|
||||
// Planted: a grid check told the wrong rate never takes the display.
|
||||
for (auto [told, real] : { std::pair{ 144.0, 165.0 }, std::pair{ 90.0, 144.0 }, std::pair{ 165.0, 144.0 } })
|
||||
{
|
||||
video::RefreshFit grid;
|
||||
grid.SetGridHz(told);
|
||||
int64_t last = INT64_MIN;
|
||||
bool ever = false;
|
||||
for (int i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
const double p = 1e9 / real;
|
||||
int64_t k = std::max(int64_t(std::ceil((double(i) * 1e9 / 60.0 + 3e6 + gauss(1e6)) / p)), last + 1);
|
||||
last = k;
|
||||
grid.Add(int64_t(1e12 + double(k) * p + 150'000 + gauss(200'000)));
|
||||
ever |= grid.Estimate().valid;
|
||||
}
|
||||
printf(" planted: told %.0f Hz on a %.0f Hz display: %s\n", told, real, ever ? "TAKEN" : "never taken (caught)");
|
||||
CHECK(!ever, "told %.0f on %.0f", told, real);
|
||||
}
|
||||
}
|
||||
|
||||
void Dismissal(const std::filesystem::path& scratch)
|
||||
{
|
||||
printf("the dismissal in settings.toml:\n");
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(scratch, ec);
|
||||
std::filesystem::create_directories(scratch, ec);
|
||||
const std::filesystem::path file = scratch / "settings.toml";
|
||||
std::ofstream(file) << "[app]\nlast_version = \"0.1.6\"\n";
|
||||
CHECK(settings::Load(file), "load");
|
||||
CHECK(!settings::RefreshNoticeShown(90), "90 shown before anything");
|
||||
CHECK(settings::RecordRefreshNotice(90), "record 90");
|
||||
CHECK(settings::RefreshNoticeShown(90) && !settings::RefreshNoticeShown(144), "after 90");
|
||||
std::string text = Read(file);
|
||||
CHECK(text.find("refresh_notices = \"90\"") != std::string::npos && text.find("last_version = \"0.1.6\"") != std::string::npos,
|
||||
"file after 90:\n%s", text.c_str());
|
||||
CHECK(settings::RecordRefreshNotice(144) && settings::RecordRefreshNotice(90), "record 144, 90 again");
|
||||
text = Read(file);
|
||||
CHECK(text.find("refresh_notices = \"90,144\"") != std::string::npos, "file after 144:\n%s", text.c_str());
|
||||
// Read back in a new load; a version record and a full save keep it.
|
||||
CHECK(settings::Load(file) && settings::RefreshNoticeShown(90) && settings::RefreshNoticeShown(144) && !settings::RefreshNoticeShown(165),
|
||||
"reload");
|
||||
CHECK(settings::RecordVersion("0.1.7") && settings::Save(), "version + save");
|
||||
text = Read(file);
|
||||
CHECK(text.find("refresh_notices = \"90,144\"") != std::string::npos && text.find("last_version = \"0.1.7\"") != std::string::npos,
|
||||
"after a save:\n%s", text.c_str());
|
||||
// A hand-edited list: junk and repeats dropped.
|
||||
std::ofstream(file) << "[app]\nrefresh_notices = \"90, x,144,90,-3\"\n";
|
||||
CHECK(settings::Load(file) && settings::RefreshNoticeShown(90) && settings::RefreshNoticeShown(144), "hand edit");
|
||||
// No list: the [app] table as before (only the version).
|
||||
std::ofstream(file) << "[app]\nlast_version = \"0.1.6\"\n";
|
||||
CHECK(settings::Load(file) && settings::RecordVersion("0.1.7"), "plain");
|
||||
text = Read(file);
|
||||
CHECK(text.find("refresh_notices") == std::string::npos, "a list appeared:\n%s", text.c_str());
|
||||
printf(" recorded, read back, kept by a save and a version record, junk ignored: %s\n", s_failures ? "see failures" : "ok");
|
||||
std::filesystem::remove_all(scratch, ec);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
const std::filesystem::path scratch = argc > 1 ? std::filesystem::path(argv[1]) : std::filesystem::temp_directory_path() / "refresh_notice_test";
|
||||
WhenItShows();
|
||||
WhatItSays();
|
||||
GridCheck();
|
||||
Dismissal(scratch);
|
||||
printf(s_failures ? "\n%d FAILED\n" : "\nall passed\n", s_failures);
|
||||
return s_failures ? 1 : 0;
|
||||
}
|
||||
+382
-11
@@ -3,7 +3,9 @@
|
||||
// From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/vblank_lock_sim.cpp runtime/video/refresh_fit.cpp \
|
||||
// runtime/video/vblank_lock.cpp -o build/vblank_lock_sim
|
||||
// build/vblank_lock_sim
|
||||
// build/vblank_lock_sim [--multiples]
|
||||
// (--multiples: only the displays at a whole multiple of 60 Hz, and the
|
||||
// planted faults; that part is the ctest. The 60 Hz tables print as in v0.1.6.)
|
||||
//
|
||||
// Model (times in ns):
|
||||
// display refreshes every P (true rate), phase random; a present made
|
||||
@@ -27,6 +29,19 @@
|
||||
// the display shows what was queued, and the next vblank comes
|
||||
// exactly the time suspended later, then VblankLock::Resumed
|
||||
// (gpu/command_processor.cpp's Vsync thread).
|
||||
// multiple (--multiples) a display at 2-4 times 60 Hz (120, 180, 240) with
|
||||
// its multiple told to RefreshFit (the display mode's rate: video/
|
||||
// refresh_multiple.h). A present still shows on the first refresh
|
||||
// after it (the display has spare refreshes, so nothing queues);
|
||||
// the presenter holds a new frame until VblankLock::PresentAt.
|
||||
// Counted per new frame: refreshes on screen against the vblanks'
|
||||
// (uneven: not `multiple` a vblank), and frames that couldn't make
|
||||
// their refresh (ready after the latch of the refresh `multiple`
|
||||
// after the one before their vblank, the 60 Hz frame's budget):
|
||||
// each such miss may cost two uneven frames (one long, the next
|
||||
// short), anything more is the cadence's own fault. Optionally the
|
||||
// vblank thread wakes late (Windows: 1.26 +- 0.45 ms), Frame Rate
|
||||
// 30 (two lock periods a vblank), and planted faults.
|
||||
#include <video/refresh_fit.h>
|
||||
#include <video/vblank_lock.h>
|
||||
|
||||
@@ -36,6 +51,7 @@
|
||||
#include <deque>
|
||||
#include <iterator>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -48,6 +64,18 @@ struct Scene
|
||||
double spikeP, spikeMs; // occasional slow frames
|
||||
};
|
||||
|
||||
// What --multiples changes about a run (all off: the v0.1.6 model, unchanged).
|
||||
struct Options
|
||||
{
|
||||
int multiple = 0; // told to RefreshFit (0: not told, as v0.1.6)
|
||||
int trueMultiple = 0; // the display's own, for the cadence count (0: none: 144 Hz, 90 Hz)
|
||||
bool hold = true; // the presenter holds new frames (VblankLock::PresentAt)
|
||||
int64_t holdShiftNs = 0; // planted: the hold moved this much
|
||||
double wakeMeanMs = 0, wakeSdMs = 0; // the vblank thread wakes this late (clamped 0.3-4 ms)
|
||||
int divider = 1; // 2: Frame Rate 30
|
||||
double vrrMaxHz = 144; // the variable-refresh panel's top
|
||||
};
|
||||
|
||||
struct Result
|
||||
{
|
||||
double lockAtS = -1;
|
||||
@@ -62,10 +90,15 @@ struct Result
|
||||
int64_t rephaseVblanks = -1;
|
||||
uint64_t afterResume = 0;
|
||||
bool lockedAtEnd = false;
|
||||
// --multiples: new frames counted, uneven ones, frames that couldn't make
|
||||
// their refresh, refreshes on screen -> frames, lock changes.
|
||||
uint64_t newFrames = 0, uneven = 0, missed = 0, lockChanges = 0;
|
||||
uint64_t shownFor[8] = {};
|
||||
};
|
||||
|
||||
static Result Run(double displayHz, bool lockOn, const Scene& scene, double seconds, uint32_t seed, double countFromS,
|
||||
bool variable = false, const std::vector<std::pair<double, double>>& suspensions = {}, bool callResumed = true)
|
||||
bool variable = false, const std::vector<std::pair<double, double>>& suspensions = {}, bool callResumed = true,
|
||||
const Options& opt = Options{})
|
||||
{
|
||||
std::mt19937_64 rng(seed);
|
||||
std::normal_distribution<double> nd(0, 1);
|
||||
@@ -77,6 +110,10 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
|
||||
RefreshFit fit;
|
||||
VblankLock lock(16'667'000, lockOn);
|
||||
if (opt.multiple)
|
||||
fit.SetMultiple(opt.multiple);
|
||||
lock.SetHold(opt.hold);
|
||||
const int truth = std::max(opt.trueMultiple, 1);
|
||||
|
||||
struct Frame
|
||||
{
|
||||
@@ -87,6 +124,7 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
Frame pending{};
|
||||
bool held = false; // main thread holds a frame, waiting for an image
|
||||
Frame heldFrame{};
|
||||
int64_t wake = INT64_MAX; // it holds the frame until then (VblankLock::PresentAt)
|
||||
struct Queued
|
||||
{
|
||||
Frame f;
|
||||
@@ -96,8 +134,16 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
Result res;
|
||||
std::vector<double> lat;
|
||||
|
||||
int64_t vblank = t0;
|
||||
int64_t vblank = t0; // scheduled (VblankLock::Next)
|
||||
int64_t fired = t0; // when it fires (Options' late wake-ups)
|
||||
auto wakeLate = [&]() -> int64_t {
|
||||
if (opt.wakeMeanMs <= 0)
|
||||
return 0;
|
||||
return int64_t(std::clamp(opt.wakeMeanMs + opt.wakeSdMs * nd(rng), 0.3, 4.0) * 1e6);
|
||||
};
|
||||
int64_t k = 1;
|
||||
int64_t lastShownK = -1, lastShownVblank = 0;
|
||||
bool wasLocked = false;
|
||||
int64_t refresh = t0 + int64_t(phase);
|
||||
int64_t lastRefresh = refresh;
|
||||
const int64_t end = t0 + int64_t(seconds * 1e9);
|
||||
@@ -117,14 +163,24 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
queue.push_back({ f, at });
|
||||
};
|
||||
auto presenterStep = [&](int64_t now) {
|
||||
// Main thread: present what it holds when an image is free, then
|
||||
// take the pending frame.
|
||||
// Main thread: present what it holds when an image is free (and,
|
||||
// at a multiple, its hold is over), then take the pending frame.
|
||||
while (true)
|
||||
{
|
||||
if (held)
|
||||
{
|
||||
if (queue.size() >= 2)
|
||||
return;
|
||||
if (opt.multiple)
|
||||
{
|
||||
const int64_t at = lock.PresentAt(heldFrame.vblank);
|
||||
if (at && at + opt.holdShiftNs > now)
|
||||
{
|
||||
wake = at + opt.holdShiftNs;
|
||||
return;
|
||||
}
|
||||
wake = INT64_MAX;
|
||||
}
|
||||
present(heldFrame, now);
|
||||
held = false;
|
||||
}
|
||||
@@ -139,20 +195,23 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
{
|
||||
if (variable && k > 1)
|
||||
{
|
||||
const int64_t minGap = int64_t(1e9 / 144), maxGap = int64_t(1e9 / 48);
|
||||
const int64_t minGap = int64_t(1e9 / opt.vrrMaxHz), maxGap = int64_t(1e9 / 48);
|
||||
refresh = lastRefresh + maxGap;
|
||||
if (!queue.empty())
|
||||
refresh = std::clamp(queue.front().presented + int64_t(latch), lastRefresh + minGap, lastRefresh + maxGap);
|
||||
}
|
||||
// Next event: vblank, a frame's arrival, or a flip.
|
||||
int64_t nextArrive = inflight.empty() ? INT64_MAX : inflight.front().arrive;
|
||||
const int64_t nextEvent = std::min({ vblank, nextArrive, refresh });
|
||||
const int64_t nextEvent = std::min({ fired, nextArrive, refresh, wake });
|
||||
if (pause < pauses.size() && !suspended && nextEvent >= pauses[pause].first)
|
||||
{
|
||||
// Everything the game does stands still for the suspension; the
|
||||
// display flips on through what was already queued.
|
||||
suspended = true;
|
||||
vblank += pauses[pause].second - pauses[pause].first;
|
||||
fired += pauses[pause].second - pauses[pause].first;
|
||||
if (wake != INT64_MAX)
|
||||
wake += pauses[pause].second - pauses[pause].first;
|
||||
for (Frame& f : inflight)
|
||||
f.arrive += pauses[pause].second - pauses[pause].first;
|
||||
continue;
|
||||
@@ -170,13 +229,20 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
presenterStep(resumedAt);
|
||||
continue;
|
||||
}
|
||||
if (vblank <= refresh && vblank <= nextArrive)
|
||||
if (wake <= refresh && wake <= nextArrive && wake < fired)
|
||||
{
|
||||
const int64_t at = wake;
|
||||
wake = INT64_MAX;
|
||||
presenterStep(at);
|
||||
continue;
|
||||
}
|
||||
if (fired <= refresh && fired <= nextArrive)
|
||||
{
|
||||
double x = scene.meanMs + scene.sdMs * nd(rng);
|
||||
if (ud(rng) < scene.spikeP)
|
||||
x = scene.spikeMs + 1.0 * nd(rng);
|
||||
x = std::max(1.0, x);
|
||||
Frame f{ vblank, vblank + int64_t(x * 1e6 + 500'000) }; // +0.5 ms pickup
|
||||
Frame f{ fired, fired + int64_t(x * 1e6 + 500'000) }; // +0.5 ms pickup
|
||||
auto it = inflight.end();
|
||||
while (it != inflight.begin() && std::prev(it)->arrive > f.arrive)
|
||||
--it;
|
||||
@@ -187,6 +253,9 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
lastCounted = vblank;
|
||||
}
|
||||
vblank = lock.Next(vblank);
|
||||
for (int d = 1; d < opt.divider; d++) // Frame Rate 30: every other lock period
|
||||
vblank = lock.Next(vblank);
|
||||
fired = vblank + wakeLate();
|
||||
if (resumedAt >= 0 && res.rephaseVblanks < 0)
|
||||
{
|
||||
sinceResume++;
|
||||
@@ -223,7 +292,7 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
// The presenter doesn't time a frame released before the resume
|
||||
// (video/presenter.cpp's OnScreen).
|
||||
if (q.f.vblank >= resumedAt)
|
||||
lock.Observe(q.f.vblank, q.presented, onScreen);
|
||||
lock.Observe(q.f.vblank, q.presented, onScreen, std::min(q.f.arrive, q.presented));
|
||||
if (resumedAt >= 0 && shownAfterResume < 0)
|
||||
shownAfterResume = refresh;
|
||||
if (q.f.vblank >= countFrom)
|
||||
@@ -231,6 +300,25 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
res.frames++;
|
||||
lat.push_back((refresh - q.f.vblank) / 1e6);
|
||||
}
|
||||
if (opt.multiple || opt.trueMultiple)
|
||||
{
|
||||
// Its predecessor's refreshes on screen against the vblanks
|
||||
// between them, and whether it could have made its refresh at
|
||||
// all (the display's true phase; `truth` refreshes a vblank).
|
||||
if (lastShownK >= 0 && q.f.vblank >= countFrom && !afterResume(refresh))
|
||||
{
|
||||
const int64_t shown = k - lastShownK;
|
||||
const int64_t wanted = std::max<int64_t>(1, std::llround(double(q.f.vblank - lastShownVblank) / (P * truth))) * truth;
|
||||
res.newFrames++;
|
||||
res.uneven += shown != wanted;
|
||||
res.shownFor[std::min<int64_t>(shown, 7)]++;
|
||||
const double before = std::floor((double(q.f.vblank) - (double(t0) + phase)) / P);
|
||||
const double target = double(t0) + phase + (before + truth) * P;
|
||||
res.missed += double(q.f.arrive) > target - latch;
|
||||
}
|
||||
lastShownK = k;
|
||||
lastShownVblank = q.f.vblank;
|
||||
}
|
||||
flowing = true;
|
||||
if (!suspended)
|
||||
presenterStep(refresh);
|
||||
@@ -244,6 +332,11 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
}
|
||||
if (res.lockAtS < 0 && lock.Locked())
|
||||
res.lockAtS = (refresh - t0) / 1e9;
|
||||
if (lock.Locked() != wasLocked)
|
||||
{
|
||||
wasLocked = !wasLocked;
|
||||
res.lockChanges++;
|
||||
}
|
||||
if (res.followsAtS < 0 && lock.GetStatus().follows)
|
||||
res.followsAtS = (refresh - t0) / 1e9;
|
||||
k++;
|
||||
@@ -264,8 +357,12 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
return res;
|
||||
}
|
||||
|
||||
int main()
|
||||
static bool Multiples();
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
if (argc > 1 && std::string(argv[1]) == "--multiples")
|
||||
return Multiples() ? 0 : 1;
|
||||
const Scene scenes[] = {
|
||||
{ "light (vblank->presenter 7+-1.5 ms)", 7, 1.5, 0.0, 0 },
|
||||
{ "driving (9+-2 ms, 1% at 20 ms)", 9, 2, 0.01, 20 },
|
||||
@@ -374,3 +471,277 @@ int main()
|
||||
: "FAILED");
|
||||
return ok && resumes ? 0 : 1;
|
||||
}
|
||||
|
||||
// ---- Displays at a whole multiple of 60 Hz (--multiples) ----
|
||||
namespace
|
||||
{
|
||||
const Scene kScenes[] = {
|
||||
{ "fast (1.5+-0.3 ms: Steam Machine)", 1.5, 0.3, 0.0, 0 },
|
||||
{ "light (7+-1.5 ms)", 7, 1.5, 0.0, 0 },
|
||||
{ "driving (9+-2 ms, 1% at 20)", 9, 2, 0.01, 20 },
|
||||
{ "fly-in (14+-1.5 ms, 2% at 20)", 14, 1.5, 0.02, 20 },
|
||||
{ "heavy (16+-1.5 ms)", 16, 1.5, 0.0, 0 },
|
||||
};
|
||||
constexpr double kSeconds = 180, kCountFrom = 60;
|
||||
|
||||
struct Row
|
||||
{
|
||||
Result sum;
|
||||
double lockAt = 0, lat = 0, vblankHz = 0;
|
||||
bool locked = true, follows = false;
|
||||
int seeds = 0;
|
||||
};
|
||||
|
||||
Row RunSeeds(double hz, const Scene& s, const Options& o, int seeds, uint32_t salt, double seconds = kSeconds,
|
||||
bool variable = false)
|
||||
{
|
||||
Row row;
|
||||
for (int seed = 1; seed <= seeds; seed++)
|
||||
{
|
||||
Result r = Run(hz, true, s, seconds, uint32_t(seed) * 7919 + uint32_t(hz * 10) + salt, kCountFrom, variable, {}, true, o);
|
||||
row.sum.newFrames += r.newFrames;
|
||||
row.sum.uneven += r.uneven;
|
||||
row.sum.missed += r.missed;
|
||||
row.sum.slips += r.slips;
|
||||
row.sum.lockChanges += r.lockChanges;
|
||||
for (int i = 0; i < 8; i++)
|
||||
row.sum.shownFor[i] += r.shownFor[i];
|
||||
row.lockAt = std::max(row.lockAt, r.lockAtS);
|
||||
row.lat += r.latMedMs / seeds;
|
||||
row.vblankHz += r.vblankHz / seeds;
|
||||
row.locked &= r.lockAtS > 0 && r.lockedAtEnd;
|
||||
row.follows |= r.followsAtS >= 0;
|
||||
row.seeds++;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
double Pct(uint64_t a, uint64_t b) { return b ? 100.0 * double(a) / double(b) : 0.0; }
|
||||
|
||||
std::string Histogram(const Result& r)
|
||||
{
|
||||
uint64_t all = 0;
|
||||
for (uint64_t c : r.shownFor)
|
||||
all += c;
|
||||
std::string out;
|
||||
for (int i = 1; i < 8; i++)
|
||||
if (r.shownFor[i] * 1000 >= all && r.shownFor[i])
|
||||
{
|
||||
char b[32];
|
||||
snprintf(b, sizeof b, "%s%d:%.1f%%", out.empty() ? "" : " ", i, Pct(r.shownFor[i], all));
|
||||
out += b;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The fix's criteria for a fixed-refresh multiple: locked (and never let
|
||||
// go), the vblank at the display's rate over the multiple, at most 0.3%
|
||||
// of frames uneven, and, for a scene without spikes, the least latency
|
||||
// its frames allow: the first refresh after the vblank's that a frame
|
||||
// ready at the scene's 3 sigma (+0.5 ms pickup, + the wake-up's) makes
|
||||
// with the 2 ms latch (vblank -> screen = that many refreshes less 1 ms).
|
||||
bool Judge(const Row& r, double hz, int m, const Scene& s, const Options& o, std::string* why = nullptr)
|
||||
{
|
||||
const double vblankWant = hz / m;
|
||||
const bool rate = std::abs(r.vblankHz - vblankWant) < 0.01;
|
||||
const bool even = r.sum.uneven * 1000 <= r.sum.newFrames * 3; // 0.3%
|
||||
const double refreshMs = 1e3 / hz;
|
||||
const double need = s.meanMs + 3 * s.sdMs + 0.5 + o.wakeMeanMs + 3 * o.wakeSdMs + 1.0 + 2.0;
|
||||
const double least = std::ceil(need / refreshMs) * refreshMs - 1.0;
|
||||
const bool latency = s.spikeP > 0 || r.lat <= least + 0.5;
|
||||
if (why)
|
||||
*why = std::string(!r.locked ? "not locked; " : "") + (r.follows ? "let go; " : "") + (!rate ? "vblank rate; " : "") +
|
||||
(!even ? "uneven; " : "") + (!latency ? "latency; " : "");
|
||||
return r.locked && !r.follows && rate && even && latency;
|
||||
}
|
||||
|
||||
void PrintRow(const char* display, const Scene& s, const Row& r, const char* verdict)
|
||||
{
|
||||
printf("%-12s %-30s %7.1f %6llu %7.2f %7.2f %6llu %6.1f %10.4f %-24s %s\n", display, s.name, r.lockAt,
|
||||
(unsigned long long)r.sum.lockChanges, Pct(r.sum.uneven, r.sum.newFrames), Pct(r.sum.missed, r.sum.newFrames),
|
||||
(unsigned long long)r.sum.slips, r.lat, r.vblankHz, Histogram(r.sum).c_str(), verdict);
|
||||
}
|
||||
|
||||
void Header()
|
||||
{
|
||||
printf("%-12s %-30s %7s %6s %7s %7s %6s %6s %10s %-24s %s\n", "display", "scene", "lock at", "lockch", "uneven%", "missed%",
|
||||
"slips", "lat ms", "vblank Hz", "refreshes:frames", "");
|
||||
}
|
||||
}
|
||||
|
||||
static bool Multiples()
|
||||
{
|
||||
bool ok = true;
|
||||
printf("displays at a whole multiple of 60 Hz: %.0f s per run, counted from %.0f s; 3 seeds each, summed (lock at: the latest)\n"
|
||||
" uneven%%: new frames not on screen for `multiple` refreshes a vblank; missed%%: frames ready after their refresh's latch\n"
|
||||
" (they're held for later refreshes then: VblankLock::AdaptHold); lat: vblank -> on screen, median\n", kSeconds, kCountFrom);
|
||||
Header();
|
||||
const double rates[] = { 119.88, 120.0, 180.0, 239.76, 240.0 };
|
||||
for (double hz : rates)
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
for (const Scene& s : kScenes)
|
||||
{
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
Row r = RunSeeds(hz, s, o, 3, 0);
|
||||
std::string why;
|
||||
const bool pass = Judge(r, hz, m, s, o, &why);
|
||||
char display[16];
|
||||
snprintf(display, sizeof display, "%.2f/%d", hz, m);
|
||||
PrintRow(display, s, r, pass ? "ok" : ("FAIL: " + why).c_str());
|
||||
ok &= pass;
|
||||
}
|
||||
}
|
||||
|
||||
printf("\nthe vblank thread waking late as on Windows (1.26 +- 0.45 ms, sleep_until after timeBeginPeriod(1)); 3 seeds\n");
|
||||
Header();
|
||||
for (double hz : { 120.0, 240.0 })
|
||||
for (int i : { 0, 1, 2 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
o.wakeMeanMs = 1.26;
|
||||
o.wakeSdMs = 0.45;
|
||||
Row r = RunSeeds(hz, kScenes[i], o, 3, 17);
|
||||
std::string why;
|
||||
const bool pass = Judge(r, hz, m, kScenes[i], o, &why);
|
||||
char display[16];
|
||||
snprintf(display, sizeof display, "%.0f/%d win", hz, m);
|
||||
PrintRow(display, kScenes[i], r, pass ? "ok" : ("FAIL: " + why).c_str());
|
||||
ok &= pass;
|
||||
}
|
||||
|
||||
printf("\nFrame Rate 30 (a guest vblank every 2 lock periods) at 120 and 240 Hz: 4 and 8 refreshes a frame; 3 seeds\n");
|
||||
Header();
|
||||
for (double hz : { 120.0, 240.0 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
o.divider = 2;
|
||||
Row r = RunSeeds(hz, kScenes[2], o, 3, 29);
|
||||
// The vblank comes every 2 guest periods; frames stay 2m refreshes.
|
||||
const bool pass = r.locked && !r.follows && std::abs(r.vblankHz - hz / m / 2) < 0.01 &&
|
||||
r.sum.uneven * 1000 <= r.sum.newFrames * 3 && r.sum.shownFor[std::min(2 * m, 7)] * 100 >= r.sum.newFrames * 99;
|
||||
char display[16];
|
||||
snprintf(display, sizeof display, "%.0f/%d fr30", hz, m);
|
||||
PrintRow(display, kScenes[2], r, pass ? "ok" : "FAIL");
|
||||
ok &= pass;
|
||||
}
|
||||
|
||||
printf("\nno whole multiple (the mode's multiple is 0, so the fit is told nothing, as v0.1.6): never locked, frames 2-3 (or 1-2)\n"
|
||||
"refreshes each as the rate dictates; 3 seeds\n");
|
||||
Header();
|
||||
for (double hz : { 143.9, 144.0, 165.0, 90.0 })
|
||||
for (int i : { 1, 2 })
|
||||
{
|
||||
Options o;
|
||||
o.multiple = std::max(RefreshFit::MultipleOf(hz), 1); // 0 -> 1, what the presenter tells the fit
|
||||
Row r = RunSeeds(hz, kScenes[i], o, 3, 41);
|
||||
const bool pass = r.lockAt <= 0 && std::abs(r.vblankHz - 1e9 / 16'667'000) < 0.01;
|
||||
char display[16];
|
||||
snprintf(display, sizeof display, "%.1f", hz);
|
||||
PrintRow(display, kScenes[i], r, pass ? "ok (free-running)" : "FAIL: locked");
|
||||
ok &= pass;
|
||||
}
|
||||
|
||||
printf("\nvariable refresh up to 120 Hz in a 120 Hz mode (told multiple 2): the display follows the presents; the lock lets go\n"
|
||||
"(or never takes it) in every run and the vblank ends free-running at 60 Hz (counted 120-300 s); 3 seeds\n");
|
||||
{
|
||||
const Scene steady[] = { { "steady (3+-0.05 ms)", 3, 0.05, 0.0, 0 }, kScenes[0], kScenes[1] };
|
||||
Header();
|
||||
for (const Scene& s : steady)
|
||||
{
|
||||
Options o;
|
||||
o.multiple = 2;
|
||||
o.vrrMaxHz = 120;
|
||||
Row row;
|
||||
bool every = true;
|
||||
for (int seed = 1; seed <= 3; seed++)
|
||||
{
|
||||
Result r = Run(60.0, true, s, 300, uint32_t(seed) * 7919 + 600 + 53, 120, true, {}, true, o);
|
||||
every &= r.followsAtS >= 0 || r.lockAtS <= 0;
|
||||
row.sum.newFrames += r.newFrames;
|
||||
row.sum.uneven += r.uneven;
|
||||
row.sum.lockChanges += r.lockChanges;
|
||||
for (int i = 0; i < 8; i++)
|
||||
row.sum.shownFor[i] += r.shownFor[i];
|
||||
row.lockAt = std::max(row.lockAt, std::max(r.followsAtS, r.lockAtS));
|
||||
row.lat += r.latMedMs / 3;
|
||||
row.vblankHz += r.vblankHz / 3;
|
||||
row.follows |= r.followsAtS >= 0;
|
||||
}
|
||||
const bool pass = every && std::abs(row.vblankHz - 1e9 / 16'667'000) < 0.001;
|
||||
PrintRow("VRR 48-120", s, row, pass ? (row.follows ? "ok (let go; lock at: the latest let-go)" : "ok (never locked)")
|
||||
: "FAIL: not free-running at 60 Hz");
|
||||
ok &= pass;
|
||||
}
|
||||
}
|
||||
|
||||
printf("\nsuspended at 90 s for 0.5 / 6.0013 / 12.5 s at 120 and 240 Hz (fast, light): re-phased within half a refresh of\n"
|
||||
"steps and still locked; 3 seeds each, max\n");
|
||||
for (int sc : { 0, 1 })
|
||||
for (double hz : { 120.0, 240.0 })
|
||||
for (double d : { 0.5, 6.0013, 12.5 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
const int64_t limit = int64_t(std::ceil(1e9 / hz / 2 / double(VblankLock::kMaxStepNs))) + 10;
|
||||
int64_t rephase = 0;
|
||||
bool held = true;
|
||||
for (uint32_t seed = 1; seed <= 3; seed++)
|
||||
{
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
Result r = Run(hz, true, kScenes[sc], 90 + d + 10, seed * 6151 + uint32_t(hz * 10), kCountFrom, false, { { 90, d } }, true, o);
|
||||
rephase = r.rephaseVblanks < 0 ? INT64_MAX : std::max(rephase, r.rephaseVblanks);
|
||||
held &= r.lockedAtEnd && r.followsAtS < 0;
|
||||
}
|
||||
const bool pass = rephase <= limit && held;
|
||||
printf(" %-30s %6.1f Hz suspended %7.4f s: rephase %lld vblanks (limit %lld), lock %s: %s\n", kScenes[sc].name, hz, d,
|
||||
rephase == INT64_MAX ? -1LL : (long long)rephase, (long long)limit, held ? "held" : "LOST", pass ? "ok" : "FAIL");
|
||||
ok &= pass;
|
||||
}
|
||||
|
||||
// Planted faults: each must fail the criteria above in at least one of
|
||||
// these rows (the fast, light and driving scenes at 119.88 and 240 Hz).
|
||||
printf("\nplanted faults (each must fail a row):\n");
|
||||
struct Plant
|
||||
{
|
||||
const char* name;
|
||||
void (*apply)(Options&, double hz);
|
||||
};
|
||||
const Plant plants[] = {
|
||||
{ "v0.1.6: the mode's multiple unused (the fit at 1)", [](Options& o, double) { o.multiple = 1; } },
|
||||
{ "the lock without the hold (frames when ready)", [](Options& o, double) { o.hold = false; } },
|
||||
{ "the hold a refresh early", [](Options& o, double hz) { o.holdShiftNs = -int64_t(1e9 / hz); } },
|
||||
{ "the hold a refresh late", [](Options& o, double hz) { o.holdShiftNs = int64_t(1e9 / hz); } },
|
||||
{ "the fit told half the multiple", [](Options& o, double) { o.multiple = std::max(o.multiple / 2, 1); } },
|
||||
};
|
||||
bool plantsOk = true;
|
||||
for (const Plant& p : plants)
|
||||
{
|
||||
int failed = 0, rows = 0;
|
||||
for (double hz : { 119.88, 240.0 })
|
||||
for (int i : { 0, 1, 2 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
p.apply(o, hz);
|
||||
Row r = RunSeeds(hz, kScenes[i], o, 1, 67);
|
||||
std::string why;
|
||||
failed += !Judge(r, hz, m, kScenes[i], o, &why);
|
||||
rows++;
|
||||
printf(" %-52s %7.2f %-30s uneven %6.2f%% missed %5.2f%% lat %5.1f: %s\n", p.name, hz, kScenes[i].name,
|
||||
Pct(r.sum.uneven, r.sum.newFrames), Pct(r.sum.missed, r.sum.newFrames), r.lat, why.empty() ? "passes" : why.c_str());
|
||||
}
|
||||
printf(" plant: %-52s %s (%d of %d rows fail)\n", p.name, failed ? "caught" : "NOT caught", failed, rows);
|
||||
plantsOk &= failed > 0;
|
||||
}
|
||||
printf("%s\n", ok && plantsOk ? "multiples: locked to every 2nd-4th refresh, even, the least latency the frames allow; "
|
||||
"non-multiples and variable refresh free-run; every plant caught: ok"
|
||||
: "FAILED");
|
||||
return ok && plantsOk;
|
||||
}
|
||||
Reference in new issue
Block a user