2 Commits
Author SHA1 Message Date
SpeedBreaker ProjectandClaude Opus 5.5 31849f5f1d SpeedBreaker v0.1.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 03:11:11 +00:00
9d2cb93f2d SpeedBreaker v0.1.6
Co-authored-by: aleszczynskig <36401256+aleszczynskig@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 04:39:51 +00:00
46 changed files with 5313 additions and 283 deletions

No files matched your search

+77
View File
@@ -4,6 +4,83 @@ 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
- No more lost sound followed by a freeze. If your audio output stopped taking sound for a while,
the game's sound stopped for good, and the game later froze, for example on the save screen
after an event. Every version so far had this. Now the game keeps running, unheard, and the
sound comes back as soon as the output takes audio again. Thanks to Omega3komma3 for the report
and the two bug reports (GitHub issue #20).
- No more rectangular borders in the Showcase (where you rotate and zoom around a car, in the car
lot, the customize shop and on a Blacklist rival's car) or around a Blacklist rival's bio. The
Showcase darkens the edges of the garage, and that darkening stopped short of the edges of the
screen; the bio's black panel did the same. On a 16:9 screen that left a thin border, as on an
Xbox 360, whose TV hid it past the edge of its screen; on a taller screen such as a MacBook or a
Steam Deck the Showcase's border was wide at the top and bottom. Both now reach the edges of the
screen, on every screen shape. Thanks to DoubleDips, who reported it, and m9cy884mv6-a11y, who
confirmed it on the Steam Deck (GitHub issue #19).
- Mac: in a window, the picture now runs up under a transparent title bar, and the window's
buttons show only while you move the mouse over it. Thanks to aleszczynskig, who wrote it
(GitHub pull request #23).
- Mac: on a MacBook Pro's 120 Hz ProMotion screen, switching fullscreen on or off, or resizing the
window, could close the game now and then (since v0.1.4; its crash report named "mutex lock
failed"). It no longer does.
- A quit asked for from outside the game is now a normal quit: the stop signal a launcher sends,
Ctrl+C, or closing the terminal the game was started from. Your settings are saved, as with
Quit Game. Closing its terminal used to end it on the spot, and every session, even one ended
with Quit Game, left a crash marker in its logs folder until the next start.
### More about it
- The sound: the log now says when the audio output stopped taking audio and when it came back,
and a hang report includes the audio's state.
- The borders: the few black frames between two shots of the career's opening scene are now
black to the edges of the screen too.
- The title bar: the window's saved size now takes in the title bar, so a window from an earlier
version opens a title bar shorter, with the same picture.
- Quitting: a crash still saves a crash report, and the next start still offers it. A session
that ended without quitting (stopped by force, or the power going) now leaves its marker, and
the next start's log says so.
- 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.5
### What's new
+1 -1
View File
@@ -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.5 LANGUAGES C CXX)
project(SpeedBreaker VERSION 0.1.7 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
+17 -7
View File
@@ -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
@@ -301,8 +303,9 @@ its name>`, e.g. `NFSMW_GPU=intel %command%` in Steam's launch options.
**Settings:** **F1** or **Cmd+,**, or Back + Start on a controller. The game starts in a window;
Display > Fullscreen switches to full screen and decides how the game starts from then on. The
window's green button (or Ctrl+Cmd+F) switches only until you quit. While you play, the mouse
pointer hides after two seconds without moving. SpeedBreaker needs macOS 15 (Sequoia) or later.
window's green button (or Ctrl+Cmd+F) switches only until you quit. In a window, the picture
runs up under a transparent title bar, and the window's buttons show while you move the mouse
over it. While you play, the mouse pointer hides after two seconds without moving. SpeedBreaker needs macOS 15 (Sequoia) or later.
### iPhone and iPad
@@ -439,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
@@ -497,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
@@ -558,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.5-<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.
@@ -595,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
+125 -7
View File
@@ -13,7 +13,12 @@
// left every voice empty for half of each burst: gameplay audio came out
// chopped 50% (two frames on, two off), thin and "tinny". SDL's queue is
// kept near two device buffers to absorb the device's pulls, and the
// cadence gains or skips a frame to follow the device clock. NFSMW_MUTE=1
// cadence gains or skips a frame to follow the device clock, but never stops
// (apu/audio_cadence.h): all of the game's sound, its sound commands
// included, runs on this callback, so a device that stops taking audio must
// not stop it (GitHub issue #20: the sound stopped for good, then the game
// hung after the race). What such a device can't take is dropped, and the
// log says when it stopped and started taking audio again. NFSMW_MUTE=1
// (or no audio device) keeps the same cadence with no output. The driver
// handle and wrapped argument follow Xenia.
//
@@ -24,6 +29,7 @@
// frames owed.
#include <stdafx.h>
#include "audio.h"
#include "audio_cadence.h"
#include <kernel/function.h>
#include <cpu/guest_thread.h>
#include <cpu/guest_time.h>
@@ -33,6 +39,7 @@
#endif
#include <user/settings.h>
#include <video/presenter.h>
#include <report/report.h>
namespace
{
@@ -54,6 +61,19 @@ namespace
constexpr int FRAME_BYTES = SAMPLES_PER_FRAME * CHANNELS * sizeof(float);
std::atomic<int> s_targetFrames{ 4 }; // SDL queue level to hold (frames of 256 samples)
int s_deviceFrames = 0; // the device's buffer at the stream's rate (FollowDevice)
// What the hang report says about the audio (apu::DescribeState): when
// the render callback last ran, when the game's mix last had sound, and
// since when the device hasn't taken audio (0: it has); steady-clock ns.
std::atomic<int64_t> s_lastRenderNs{ 0 }, s_lastSoundNs{ 0 }, s_deviceStalledNs{ 0 };
std::atomic<uint64_t> s_dropped{ 0 }; // frames the device didn't take (dropped)
// The cadence (the audio thread; the frames it renders are submitted on
// that thread too).
apu::AudioCadence s_cadence;
int64_t SteadyNs()
{
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
// The queue level for the stream's device as it is now: two device
// buffers (at the stream's rate), at least 4 frames. Logs it when it
@@ -140,9 +160,42 @@ namespace
paused = guesttime::PausedNs();
next = steady_clock::now();
} while (guesttime::Suspended() || guesttime::PausedNs() != paused);
// NFSMW_TEST_AUDIO_DEVICE_STALL=<s>[+<secs>] (tests): the output
// device stops taking audio <s> seconds after this thread starts
// (paused, as a device that stalls or that another app holds), for
// good or for <secs>.
double stallAt = -1, stallFor = 0;
if (const char* v = std::getenv("NFSMW_TEST_AUDIO_DEVICE_STALL"))
{
char* end = nullptr;
stallAt = std::strtod(v, &end);
if (end && *end == '+')
stallFor = std::strtod(end + 1, nullptr);
}
int stallState = 0; // 0 before, 1 stalled, 2 over
const auto started = steady_clock::now();
while (s_running)
{
std::this_thread::sleep_until(next);
if (stallAt >= 0 && stallState < 2 && s_stream)
{
const double t = duration<double>(steady_clock::now() - started).count();
if (stallState == 0 && t >= stallAt)
{
stallState = 1;
fprintf(stderr, "[test] NFSMW_TEST_AUDIO_DEVICE_STALL: the output device stops taking audio (%.1f s)\n", t);
std::lock_guard lock(s_streamMutex);
SDL_PauseAudioStreamDevice(s_stream);
}
else if (stallState == 1 && stallFor > 0 && t >= stallAt + stallFor)
{
stallState = 2;
fprintf(stderr, "[test] NFSMW_TEST_AUDIO_DEVICE_STALL: the output device takes audio again (%.1f s)\n", t);
std::lock_guard lock(s_streamMutex);
if (!guesttime::Suspended())
SDL_ResumeAudioStreamDevice(s_stream);
}
}
// No guest audio time passes while the game is suspended: render
// nothing (the main thread has paused the device). After it the
// cadence moves on by exactly the time suspended, keeping its
@@ -172,17 +225,31 @@ namespace
FollowDevice(false);
}
// Follow the device clock: one frame more or less when the
// queue drifts from its target.
int queued = SDL_GetAudioStreamQueued(s_stream) / FRAME_BYTES;
if (queued > s_targetFrames + 2)
frames = 0;
else if (queued < s_targetFrames - 2)
frames = 2;
// queue drifts from its target, never stopping the game's
// sound (audio_cadence.h).
int queued = std::max(0, SDL_GetAudioStreamQueued(s_stream)) / FRAME_BYTES;
frames = s_cadence.Frames(queued, s_targetFrames);
if (s_cadence.Stalled() && !s_deviceStalledNs.load(std::memory_order_relaxed))
{
s_deviceStalledNs.store(SteadyNs(), std::memory_order_relaxed);
fprintf(stderr, "[audio] the output device has taken no audio for %.1f s (%.0f ms queued): the game's sound "
"carries on, unheard, until it does\n", apu::AudioCadence::StallTicks(s_targetFrames) * apu::kFrameNs / 1e9,
1000.0 * queued * SAMPLES_PER_FRAME / SAMPLE_RATE);
}
if (s_cadence.Resumed())
{
// What was queued before the stall is stale.
SDL_ClearAudioStream(s_stream);
const int64_t since = s_deviceStalledNs.exchange(0, std::memory_order_relaxed);
fprintf(stderr, "[audio] the output device takes audio again after %.1f s (%llu frames dropped in all)\n",
since ? double(SteadyNs() - since) / 1e9 : 0.0, (unsigned long long)s_dropped.load());
}
}
for (int i = 0; i < frames && !guesttime::Suspended(); i++)
{
RenderFrame(ctx);
s_rendered.fetch_add(1, std::memory_order_relaxed);
s_lastRenderNs.store(SteadyNs(), std::memory_order_relaxed);
}
}
}
@@ -209,6 +276,22 @@ namespace apu
return s_rendered.load(std::memory_order_relaxed);
}
std::string DescribeState()
{
const int64_t now = SteadyNs();
auto ago = [now](int64_t t) { return t ? std::format("{:.1f} s ago", double(now - t) / 1e9) : std::string("never"); };
std::string text = std::format("Render callbacks: {} (the last {}); frames from the game: {}; the game's mix last had sound {}.\n",
s_rendered.load(), ago(s_lastRenderNs.load()), s_framesSubmitted.load(), ago(s_lastSoundNs.load()));
if (!s_stream)
return text + "Output device: none (muted by NFSMW_MUTE, or no device).\n";
const int64_t stalled = s_deviceStalledNs.load();
text += std::format("Output device: {:.1f} ms queued (target {:.1f} ms); {}; {} frames dropped in all.\n", QueuedMs(),
1000.0 * s_targetFrames * SAMPLES_PER_FRAME / SAMPLE_RATE,
stalled ? std::format("not taking audio for {:.1f} s", double(now - stalled) / 1e9) : std::string("taking audio"),
s_dropped.load());
return text;
}
double QueuedMs()
{
std::lock_guard lock(s_streamMutex);
@@ -256,6 +339,34 @@ uint32_t XAudioUnregisterRenderDriverClient(uint32_t driver)
uint32_t XAudioSubmitRenderDriverFrame(uint32_t driver, void* samples)
{
s_framesSubmitted++;
// When the game's mix last had sound (the hang report), and a line once it
// has been silent for 10 s in a row (its sound stopped, or a silent
// screen) and when it sounds again: a player's log shows when it dropped.
if (samples)
{
static uint64_t silentFrames = 0;
static double silentFrom = 0;
const auto* words = static_cast<const uint32_t*>(samples);
bool silent = true;
for (uint32_t i = 0; i < SAMPLES_PER_FRAME * CHANNELS && silent; i++)
silent = (words[i] & 0xFFFFFF7Fu) == 0; // +0.0 or -0.0, big-endian
constexpr uint64_t kSilentLog = 10 * SAMPLE_RATE / SAMPLES_PER_FRAME;
if (!silent)
{
s_lastSoundNs.store(SteadyNs(), std::memory_order_relaxed);
if (silentFrames >= kSilentLog)
fprintf(stderr, "[audio] the game's mix sounds again after %.1f s of silence\n",
double(silentFrames) * SAMPLES_PER_FRAME / SAMPLE_RATE);
silentFrames = 0;
}
else
{
if (silentFrames == 0)
silentFrom = report::SessionSeconds();
if (++silentFrames == kSilentLog)
fprintf(stderr, "[audio] the game's mix has been silent for 10 s (since %.1f s)\n", silentFrom);
}
}
if (!s_stream || !samples)
return 0;
// Volume, mute and mute-in-background, when they or the focus change.
@@ -322,6 +433,13 @@ uint32_t XAudioSubmitRenderDriverFrame(uint32_t driver, void* samples)
nextLog = now + std::chrono::seconds(2);
}
}
// A device that isn't taking audio (audio_cadence.h): the frame is
// dropped, and the game's sound carries on, unheard.
if (s_cadence.Stalled())
{
s_dropped.fetch_add(1, std::memory_order_relaxed);
return 0;
}
SDL_PutAudioStreamData(s_stream, out, sizeof(out));
// NFSMW_AUDIO_DUMP=<file>: the final mix as raw interleaved 6-channel
+6
View File
@@ -5,6 +5,7 @@
// cpu/guest_time.h).
#pragma once
#include <cstdint>
#include <string>
namespace apu
{
@@ -24,4 +25,9 @@ namespace apu
// and the audio queued for the device (ms; 0 without one).
uint64_t RenderedFrames();
double QueuedMs();
// Any thread: the audio's state for a hang report, a line or two: the
// render callbacks (the game's sound runs on them), when the game's mix
// last had sound, and the output device (GitHub issue #20).
std::string DescribeState();
}
+96
View File
@@ -0,0 +1,96 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The render callback's cadence against the output device (apu/audio.cpp),
// header only so that tests/audio_cadence_test.cpp runs it.
//
// All of the game's sound runs on the render callback. XAudio processes its
// voices once per call, and NFSMW's own mixer thread (sub_825E3C30) mixes its
// next 256 samples only when XAudio's pass over its voice wakes it; that
// thread also carries out the game's sound commands (play, stop, unload:
// sub_825CF588). The console's hardware calls the callback every 5.33 ms,
// whatever the TV or speakers do. Here the cadence follows the output
// device's clock: a frame more or less when SDL's queue strays more than
// kSlack frames from its target. Up to v0.1.5 that was all, so a device that
// stopped taking audio (one that stalls, another app holding it) kept the
// queue over its target and the callback skipped for good: the game's sound
// stopped, its sound commands with it, and the game hung the next time it
// waited for a sound to stop (sub_821BD920 polls until the sound system no
// longer lists a stream it stopped, as when it unloads an event's sounds
// after the race): GitHub issue #20.
//
// Now a device that has taken nothing for StallTicks ticks (half a second,
// or four queue targets: far longer than any device takes between its
// pulls) is stalled: the callback runs at real time again, every frame is
// dropped (audio.cpp), and the stall is over the moment the device takes
// some: what was queued before it is stale and cleared (Resumed), and the
// queue fills up again as at the start. A device that does take audio, at
// whatever pace, is followed exactly as before.
#pragma once
#include <algorithm>
#include <cstdint>
namespace apu
{
class AudioCadence
{
public:
static constexpr int kSlack = 2; // frames the queue may stray from its target
static constexpr int kStallTicks = 94; // ~0.5 s
// Ticks without the device taking anything before it counts as
// stalled, for a queue target of `target` frames.
static constexpr int StallTicks(int target) { return std::max(kStallTicks, 4 * target); }
// One tick (every 5.33 ms): how many frames to render now (0, 1 or
// 2), with `queued` frames waiting for the device and a queue target
// of `target` frames.
int Frames(int queued, int target)
{
// What the device took since the last tick: the queue then, plus
// what was queued since, less the queue now.
m_idle = m_primed && m_expected - queued <= 0 ? m_idle + 1 : 0;
m_primed = true;
if (m_stalled)
{
if (m_idle > 0)
{
m_expected = queued; // the frame is dropped
return 1;
}
// It took some: over. The caller clears what is queued.
m_stalled = false;
m_resumed = true;
queued = 0;
}
else if (m_idle >= StallTicks(target))
{
m_stalled = true;
m_stalls++;
m_expected = queued;
return 1;
}
const int frames = queued > target + kSlack ? 0 : queued < target - kSlack ? 2 : 1;
m_expected = queued + frames;
return frames;
}
// The device isn't taking audio: every frame rendered is dropped,
// not queued.
bool Stalled() const { return m_stalled; }
// True once after a stall ends: the audio still queued from before
// it is stale (clear it).
bool Resumed()
{
const bool r = m_resumed;
m_resumed = false;
return r;
}
uint64_t Stalls() const { return m_stalls; }
private:
bool m_primed = false, m_stalled = false, m_resumed = false;
int m_expected = 0; // the queue expected now if the device took nothing
int m_idle = 0; // ticks in a row the device took nothing
uint64_t m_stalls = 0;
};
}
+4 -2
View File
@@ -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
+8
View File
@@ -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)
+11
View File
@@ -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();
}
+22 -5
View File
@@ -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;
}
}
}
+192 -35
View File
@@ -23,14 +23,27 @@
// a thin border of the world shows around it, as on the console; a TV's
// overscan hid it). Under Vert+ the world showed above and below it (63
// rows on the Deck), under Hor+ beside it (720 px a side at 2880x810). The
// intro's start (~57 s) has another, 0.920 of the frame's height.
// intro's start (~57 s) has another, 0.920 of the frame's height. The
// Blacklist's rival bio (SafeHouseRivalBio.fng, "View Rival Movie / View
// Rival Car") sets its text and picture on one more, 0.976 x 0.985 of the
// frame (FF000000, fading in from 11000000), for as long as the screen is
// up: a thin rectangular border of the scene behind it all round it, on
// every screen shape (GitHub #19).
//
// The fix leaves those draws as they are and, right after each side bar or
// black frame, draws it once or twice more, stretched over the whole frame
// and scissored to (its half of) what lies outside the 16:9 band: the rows
// above and below it (Vert+), or the columns beyond its side (Hor+; for a
// bar only when it ends short of the screen edge). Nothing inside the band
// changes, and the strips get the quad's own colour, alpha and blend.
// black frame, draws it once or more again, stretched over the whole frame
// and scissored to what it must fill: for a bar, (its half of) what lies
// outside the 16:9 band, the rows above and below it (Vert+) or the columns
// beyond its side (Hor+; only when it ends short of the screen edge), and
// nothing inside the band changes; for a black frame, everything outside
// the quad itself, the band's thin border too, on every screen shape, 16:9
// included (the period TV's look, as the results screen's overscan rows).
// The copies get the quad's own colour, alpha and blend.
//
// Two pictures the front end draws just short of the 16:9 frame's edges
// get the screen's edges instead, on every screen shape: around the results
// screen's backdrop the rows are cleared to black (PlanBackdrop), and the
// Showcase's vignette is stretched to the screen's edges (PlanVignette).
//
// Units: the frame's clip space after the front-end matrix (w = 1), and
// "band units" = clip / view scale (x / scaleX, y / scaleY): +-1 are the
@@ -157,13 +170,16 @@ namespace gpu::bars
}
// A black frame's thresholds, in band units. Logged on the Deck and at
// 32:9: x +-0.970, y +-0.939, centred, FF000000 (the cut at ~95 s), and
// x +-0.970, y +-0.920, FE000000 (the intro's start, ~57 s). The nearest
// draws it must not take: the fades and dims (+-1.29: past the band,
// the widen, stretch and cover rules' own), the title key art (x
// +-1.104, y +-0.981, grey), the overlay at ~57 s (x +-1.104, B8 alpha),
// the vid cam's noise and dark overlays (x +-0.70; 3C alpha), its dialog
// boxes (x +-0.58), a full-band black layer (+-1.0, the cover rule's).
// 32:9: x +-0.970, y +-0.939, centred, FF000000 (the cut at ~95 s), and x
// +-0.970, y +-0.920, FE000000 (the intro's start, ~57 s); x +-0.976, y
// +-0.985, FB000000 then FF000000 (the Blacklist bio's panel, its fade in
// from 11000000 short of minAlpha: the border shows for those ~15 frames,
// as before). The nearest draws it must not take: the fades and dims
// (+-1.29: past the band, the widen, stretch and cover rules' own), the
// title key art (x +-1.104, y +-0.981, grey), the overlay at ~57 s (x
// +-1.104, B8 alpha), the vid cam's noise and dark overlays (x +-0.70; 3C
// alpha), its dialog boxes (x +-0.58), a full-band black layer (+-1.0, the
// cover rule's).
struct BlackoutRule
{
float centreMax = 0.01f; // |the box's centre|, each axis
@@ -286,29 +302,28 @@ namespace gpu::bars
}
// What a black frame fills, inside the draw's scissor: everything
// outside the band, the rows above and below it across the whole width
// (Vert+) or the columns beside it down the whole height (Hor+; at most
// one of the view scales is below 1). The far edges reach kFillEdge.
inline uint32_t BlackoutRects(const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
Rect out[2])
// outside its box, on every screen shape (16:9 too): the rows above and
// below it across the whole width, then the columns beside it within its
// rows; up to 4 rectangles, which never overlap and meet the box on the
// same pixel edges (Edge) as its own rasterised pixels. The far edges
// reach kFillEdge. (v0.1.1 to v0.1.5 filled only past the band: the
// thin border inside it showed what was behind, as a 360's own output
// does and a period TV's overscan hid: GitHub #19's Blacklist bio.)
inline uint32_t BlackoutRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, Rect out[4])
{
uint32_t n = 0;
auto add = [&](const Rect& r) {
const Rect c = Intersect(r, scissor);
if (!Empty(c))
auto add = [&](float cx0, float cx1, float cy0, float cy1) {
if (cx1 <= cx0 || cy1 <= cy0)
return;
const Rect c = Intersect(ClipToHost(cx0, cx1, cy0, cy1, ps, hostW, hostH), scissor);
if (!Empty(c) && n < 4)
out[n++] = c;
};
const float e = kFillEdge;
if (scaleY < 1.0f)
{
add(ClipToHost(-e, e, scaleY, e, ps, hostW, hostH));
add(ClipToHost(-e, e, -e, -scaleY, ps, hostW, hostH));
}
else if (scaleX < 1.0f)
{
add(ClipToHost(scaleX, e, -e, e, ps, hostW, hostH));
add(ClipToHost(-e, -scaleX, -e, e, ps, hostW, hostH));
}
add(-e, e, b.y1, e); // past its top in clip y (either way up on screen)
add(-e, e, -e, b.y0); // past its bottom
add(-e, b.x0, b.y0, b.y1); // left of it, within its rows
add(b.x1, e, b.y0, b.y1); // right of it
return n;
}
@@ -488,24 +503,27 @@ namespace gpu::bars
uint32_t blend0 = 0; // RB_BLENDCONTROL0 & 0x1FFF1FFF
bool t0Sampled = false; // the pixel shader samples t0
uint32_t t0Width = 0, t0Height = 0; // t0's size in texels
uint32_t t0Format = 0; // t0's fetch constant format (6: k_8_8_8_8, 32-bit texels)
float posScale[4] = {}; // the draw's own: NDC = clip * ps.xy + ps.zw
float hostW = 0.0f, hostH = 0.0f; // the host viewport: the target's size times the render scale
Rect scissor{}; // the draw's scissor (host pixels)
};
enum class Fill : uint8_t { None, LeftBar, RightBar, Blackout, Backdrop };
enum class Fill : uint8_t { None, LeftBar, RightBar, Blackout, Backdrop, Vignette };
// What to draw after the draw: for a bar or a black frame, `rectCount`
// more copies of it (none for a bar that reaches the screen edge), with
// `posScale` and each rectangle as the scissor; for a backdrop, each
// rectangle cleared to opaque black (its picture must not be copied).
// rectangle cleared to opaque black (its picture must not be copied);
// for a vignette, nothing after it: the draw itself is drawn with
// `posScale` instead of its own (no rectangles).
struct Plan
{
Fill fill = Fill::None;
Box box{}; // the quad's, clip space
uint32_t argb = 0; // its colour
uint32_t rectCount = 0;
Rect rects[8]{}; // a bar's 2, a black frame's 2, a backdrop's 8
Rect rects[8]{}; // a bar's 2, a black frame's 4, a backdrop's 8
float posScale[4]{};
};
@@ -573,6 +591,145 @@ namespace gpu::bars
return p;
}
// The Showcase's vignette (Showcase.fng: the "Rotate / Zoom Out / Zoom In"
// car viewer of the car lot, the customize shop and the Blacklist's rival
// cars; GitHub #19): one front-end quad over the 3D garage, centred, x
// +-0.970 and y +-0.958 of the 16:9 band (ten of the front end's 640x480
// units in from each edge), its 256x256 k_8_8_8_8 texture black, clear in
// the middle (every texel there 00000000) and darkening steeply toward its
// edges (alpha 0.1-0.8 at the edge itself, 1.0 toward the corners), alpha-
// blended (vertex colour 7F7F7F, its alpha fading in from 04 to FE). On a
// 16:9 screen the garage shows undimmed in a thin border all round it; a
// real 360's own output has that border too (an HDMI capture of the car
// lot's Showcase: the same step at x +-0.970), and a period TV's overscan
// (2.5-5% a side) hid it. Under Vert+ the garage rendered above and below
// the band showed undimmed as well (7% of a MacBook's height at the top and
// at the bottom, 55 rows of a Deck's 800): a bright rectangular frame round
// the dimmed picture. The quad is stretched to the screen's edges (its box
// to +-kFillEdge: CoverFrame), on every screen shape, 16:9 too, as a period
// TV showed it (the results screen's overscan rows are filled the same way:
// OverscanRows). Its middle is clear, so only its dark edges move out:
// 3.1% in x and 4.4% in y at 16:9, y by 1/sv more under Vert+, x by 1/sx
// more under Hor+. Thresholds in band units. The nearest draws it must not
// take: the intro's black frames (x +-0.970, y +-0.939/0.920: opaque black
// over the 32x32 white texture, and the black frame rule's), the title's
// key art and the results picture (x +-1.104), the vid cam's noise and dark
// overlays (x +-0.70), a full-band picture (+-1.0), an inset picture
// (opaque in the middle: the texel check).
struct VignetteRule
{
float centreMax = 0.01f; // |the box's centre|, each axis
float extentMin = 0.90f, extentMax = 0.99f; // half its width and half its height
uint32_t minTexels = 128; // t0 at least this wide and tall
float cornerTolerance = 1e-3f; // clip units, as Rule's
};
// A vignette's shape: one axis-aligned rectangle, one colour on all four
// vertices (any alpha: it fades in), centred in the band and covering
// most of it in x and y while stopping short of its edges. Its box goes
// to `box`. (Its texture is the plan's check: PlanVignette.)
inline bool MatchVignette(const Vertex v[4], float scaleX, float scaleY, const VignetteRule& rule = {}, Box* box = nullptr)
{
if (!(scaleX > 0.0f && scaleY > 0.0f))
return false;
for (int i = 1; i < 4; i++)
if (v[i].argb != v[0].argb)
return false;
Box b;
if (!RectangleBox(v, rule.cornerTolerance, b))
return false;
const float midX = (b.x0 + b.x1) * 0.5f / scaleX, midY = (b.y0 + b.y1) * 0.5f / scaleY;
const float halfX = (b.x1 - b.x0) * 0.5f / scaleX, halfY = (b.y1 - b.y0) * 0.5f / scaleY;
if (!(std::fabs(midX) <= rule.centreMax && std::fabs(midY) <= rule.centreMax))
return false;
if (!(halfX >= rule.extentMin && halfX <= rule.extentMax && halfY >= rule.extentMin && halfY <= rule.extentMax))
return false;
if (box)
*box = b;
return true;
}
// The byte offset of texel (x, y) in a 32-bit 2D texture: Xenos tiling
// (gpu/renderer.cpp's GLSL tiled2D, pitch in texels, a multiple of 32)
// or linear rows of `pitch` texels.
inline uint32_t TexelOffset32(uint32_t x, uint32_t y, uint32_t pitch, bool tiled)
{
if (!tiled)
return (y * pitch + x) * 4u;
const uint32_t outer = (((y >> 5) * (pitch >> 5)) + (x >> 5)) << 6;
const uint32_t inner = (((y >> 1) & 7u) << 3) | (x & 7u);
const uint32_t oi = (outer | inner) << 2;
const uint32_t bank = (y >> 4) & 1u;
const uint32_t pipe = ((x >> 3) & 3u) ^ (((y >> 3) & 1u) << 1);
return ((y & 1u) << 4) | (pipe << 6) | (bank << 11) | (oi & 0xFu) | (((oi >> 4) & 1u) << 5) | (((oi >> 5) & 7u) << 8) |
((oi >> 8) << 12);
}
// The texels a vignette's middle must hold clear (00000000, whatever the
// endian): the centre and halfway from it to each edge. An inset
// picture, opaque there, is no vignette.
constexpr uint32_t kClearSamples = 5;
inline void ClearSample(uint32_t i, uint32_t width, uint32_t height, uint32_t& x, uint32_t& y)
{
const uint32_t xs[kClearSamples] = { width / 2, width / 4, width * 3 / 4, width / 2, width / 2 };
const uint32_t ys[kClearSamples] = { height / 2, height / 2, height / 2, height / 4, height * 3 / 4 };
x = xs[i % kClearSamples];
y = ys[i % kClearSamples];
}
// The register checks for a vignette, before any vertex is read:
// Candidate's, but alpha-blended (07060706: it is translucent), over a
// large 32-bit t0 (format 6, k_8_8_8_8), at every screen shape.
inline bool VignetteCandidate(const Draw& d, const VignetteRule& rule = {})
{
const float* c = d.c;
const bool yScale = d.frontEndY > 0.0f ? std::fabs(std::fabs(c[5]) - d.frontEndY) <= d.frontEndY * 1e-5f
: d.frontEndY == 0.0f && d.scaleY >= 1.0f;
return !d.occlusion && d.prim == 13 && d.count == 4 && d.frontEndShader && d.leftAlone && c[3] == 0.0f && c[4] == 0.0f &&
c[6] == 0.0f && c[7] == 0.0f && yScale && (d.vte & 0xF) == 0xF && d.blend0 == 0x07060706u && d.t0Sampled && d.t0Format == 6 &&
d.t0Width >= rule.minTexels && d.t0Height >= rule.minTexels;
}
// The plan for a vignette: the draw's posScale stretched so its box
// reaches past every screen edge (CoverFrame), no rectangles. read: as
// PlanFill's; texel(x, y, word): t0's 32-bit texel (x, y), false if it
// can't be read. Vertices first (as PlanBackdrop: one quad over a large
// t0 gets this far), then the texels of its middle.
template <class ReadWord, class ReadTexel>
Plan PlanVignette(const Draw& d, ReadWord&& read, ReadTexel&& texel, const VignetteRule& rule = {})
{
Plan p;
if (!VignetteCandidate(d, rule))
return p;
const float* c = d.c;
Vertex v[4];
for (uint32_t k = 0; k < 4; k++)
{
uint32_t w[4];
for (uint32_t j = 0; j < 4; j++)
if (!read(k, j, w[j]))
return p;
float q[3];
std::memcpy(q, w, sizeof(q));
v[k] = { q[0] * c[0] + q[1] * c[1] + q[2] * c[2] + c[3], q[0] * c[4] + q[1] * c[5] + q[2] * c[6] + c[7], w[3] };
}
Box box{};
if (!MatchVignette(v, d.scaleX, d.scaleY, rule, &box))
return p;
for (uint32_t i = 0; i < kClearSamples; i++)
{
uint32_t x, y, word = 1;
ClearSample(i, d.t0Width, d.t0Height, x, y);
if (!texel(x, y, word) || word != 0)
return p;
}
p.fill = Fill::Vignette;
p.box = box;
p.argb = v[0].argb;
CoverFrame(box, d.posScale, p.posScale);
return p;
}
// The plan for a draw. read(k, j, word): word j of the draw's vertex k
// (k 0..3 in draw order, through its indices; an ePoly's words 0..3 are
// x, y, z and the colour), false past the vertex buffer. Register checks
@@ -608,7 +765,7 @@ namespace gpu::bars
else if (MatchBlackout(v, d.scaleX, d.scaleY, {}, &box))
{
p.fill = Fill::Blackout;
p.rectCount = BlackoutRects(d.posScale, d.hostW, d.hostH, d.scissor, d.scaleX, d.scaleY, p.rects);
p.rectCount = BlackoutRects(box, d.posScale, d.hostW, d.hostH, d.scissor, p.rects);
}
else
return p;
+79 -35
View File
@@ -7832,18 +7832,22 @@ void main()
// stretched in y alone it would be distorted by up to 1/sv. The rest
// of the front end is clipped to the band (below).
// Edge bars (gpu/edge_bars.h): the copies of a police vid-cam side
// bar or the intro's black frame that fill the screen past the 16:9
// band, drawn right after it; and the black around a full-band
// backdrop (the results screen), cleared right after it, at 16:9 too.
// NFSMW_BAR_FILL=0 turns all the edge fills off (an A/B switch).
// bar that fill the screen past the 16:9 band, and of a black frame
// (the intro's cuts, the Blacklist bio's panel) that fill everything
// outside it, at 16:9 too, drawn right after it; the black around a
// full-band backdrop (the results screen), cleared right after it,
// at 16:9 too; and the Showcase's vignette, stretched to the screen's
// edges. NFSMW_BAR_FILL=0 turns all the edge fills off (an A/B
// switch).
// planEdgeFills: the plan for a front-end draw (its registers, what
// the rules below did, a reader for its ePolys): with `bars`,
// PlanFill's bars and black frames first (wide and tall screens
// only), then PlanBackdrop's full-band picture (every screen shape).
// the rules below did, a reader for its ePolys): PlanFill's bars and
// black frames first, then PlanBackdrop's full-band picture, then
// PlanVignette's overlay (every screen shape; a bar fills nothing at
// 16:9).
// logEdgeFill: what a plan does, once per kind, screen shape and
// outcome, in every log (a tester's too).
static const bool barFill = [] { const char* v = std::getenv("NFSMW_BAR_FILL"); return !(v && v[0] == '0'); }();
auto planEdgeFills = [&](const float* c, float feY, float aspectX, float aspectY, bool feShader, bool leftAlone, bool bars) {
auto planEdgeFills = [&](const float* c, float feY, float aspectX, float aspectY, bool feShader, bool leftAlone) {
xenos::xe_gpu_texture_fetch_t t0;
memcpy(&t0, &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0], sizeof(t0));
const gpu::bars::Draw draw{ .prim = uint32_t(initiator.prim_type),
@@ -7860,6 +7864,7 @@ void main()
.t0Sampled = (s_ps->info.texture2DMask & 1) != 0,
.t0Width = uint32_t(t0.size_2d.width) + 1,
.t0Height = uint32_t(t0.size_2d.height) + 1,
.t0Format = uint32_t(t0.format),
.posScale = { pc.posScale[0], pc.posScale[1], pc.posScale[2], pc.posScale[3] },
.hostW = w * float(scaleX),
.hostH = h * float(scaleY),
@@ -7875,7 +7880,7 @@ void main()
word = GpuSwap(LoadPhysical(base + (v * 8 + j) * 4), endian);
return true;
};
gpu::bars::Plan plan = bars ? gpu::bars::PlanFill(draw, read) : gpu::bars::Plan{};
gpu::bars::Plan plan = gpu::bars::PlanFill(draw, read);
// The results screen's backdrop (and any opaque picture of that
// shape: the title's key art): what lies outside the picture,
// past the band and in the band's overscan rows above and below
@@ -7883,6 +7888,22 @@ void main()
// it showed (gpu/edge_bars.h).
if (plan.fill == gpu::bars::Fill::None)
plan = gpu::bars::PlanBackdrop(draw, read);
// The Showcase's vignette (and any front-end overlay of its
// shape, clear in the middle): stretched to the screen's edges,
// where the garage showed undimmed round it (GitHub #19). Its
// texels as the GPU reads them: 32-bit, tiled or linear rows.
if (plan.fill == gpu::bars::Fill::None)
{
const uint32_t t0Base = (uint32_t(t0.base_address) << 12) & 0x1FFFFFFFu;
const uint32_t t0Pitch = uint32_t(t0.pitch) << 5;
auto texel = [&](uint32_t x, uint32_t y, uint32_t& word) {
if (!t0Pitch || x >= draw.t0Width || y >= draw.t0Height || x >= t0Pitch)
return false;
word = GpuSwap(LoadPhysical(t0Base + gpu::bars::TexelOffset32(x, y, t0Pitch, t0.tiled != 0)), uint32_t(t0.endianness));
return true;
};
plan = gpu::bars::PlanVignette(draw, read, texel);
}
return plan;
};
auto logEdgeFill = [&](const gpu::bars::Plan& barPlan, float aspectX, float aspectY) {
@@ -7894,38 +7915,42 @@ void main()
const uint32_t kind = uint32_t(barPlan.fill);
const bool blackout = barPlan.fill == gpu::bars::Fill::Blackout;
const bool backdrop = barPlan.fill == gpu::bars::Fill::Backdrop;
const bool vignette = barPlan.fill == gpu::bars::Fill::Vignette;
const gpu::bars::Box& box = barPlan.box;
const uint64_t key = (0x36ull << 56) | (uint64_t(kind) << 40) | (uint64_t(barPlan.rectCount) << 36) |
(uint64_t(std::lround(aspectX * 4096.0f)) << 18) | uint64_t(std::lround(aspectY * 4096.0f));
static uint64_t loggedKey[5] = { ~0ull, ~0ull, ~0ull, ~0ull, ~0ull };
static uint64_t loggedKey[6] = { ~0ull, ~0ull, ~0ull, ~0ull, ~0ull, ~0ull };
if (loggedKey[kind] != key)
{
loggedKey[kind] = key;
char fills[128];
char fills[192];
if (barPlan.rectCount)
snprintf(fills, sizeof(fills), "fills %u rectangle(s) %s, first [%d,%d)x[%d,%d) (host px)", barPlan.rectCount,
backdrop ? "outside it (past the 16:9 band, and the band's overscan rows)" : "past the 16:9 band", barPlan.rects[0].x0,
backdrop ? "outside it (past the 16:9 band, and the band's overscan rows)"
: blackout ? "outside it (past the 16:9 band, and the band's border round it)" : "past the 16:9 band", barPlan.rects[0].x0,
barPlan.rects[0].x1, barPlan.rects[0].y0, barPlan.rects[0].y1);
else
snprintf(fills, sizeof(fills), "%s", blackout || backdrop ? "fills nothing" : "reaches the screen edge: nothing to fill");
snprintf(fills, sizeof(fills), "%s", vignette ? "stretched to the screen's edges"
: blackout || backdrop ? "fills nothing" : "reaches the screen edge: nothing to fill");
LogOnce(key,
"edge bars: frame %llu vs_%016llx ps_%016llx: %s (clip x %.3f..%.3f y %.3f..%.3f, colour %08X; view scale x %.4f y %.4f) %s",
(unsigned long long)s_frame, (unsigned long long)s_vs->hash, (unsigned long long)s_ps->hash,
backdrop ? "a full-band backdrop (black outside it)" : blackout ? "a black frame"
: kind == uint32_t(gpu::bars::Fill::RightBar) ? "the right vid-cam bar" : "the left vid-cam bar",
vignette ? "the Showcase's vignette (clear in the middle)" : backdrop ? "a full-band backdrop (black outside it)"
: blackout ? "a black frame" : kind == uint32_t(gpu::bars::Fill::RightBar) ? "the right vid-cam bar" : "the left vid-cam bar",
box.x0, box.x1, box.y0, box.y1, barPlan.argb, aspectX, aspectY, fills);
}
// NFSMW_LOG_FRONTEND=1: the first frame of each run of frames
// with a match, bars and black frames apart (one run per vid-cam
// scene and per cut; any other is a draw this should not take).
static uint64_t lastBarFrame[3] = { ~0ull - 1, ~0ull - 1, ~0ull - 1 };
uint64_t& last = lastBarFrame[backdrop ? 2 : blackout ? 1 : 0];
static uint64_t lastBarFrame[4] = { ~0ull - 1, ~0ull - 1, ~0ull - 1, ~0ull - 1 };
uint64_t& last = lastBarFrame[vignette ? 3 : backdrop ? 2 : blackout ? 1 : 0];
if (logFrontEnd && s_frame != last && s_frame != last + 1)
{
xenos::xe_gpu_texture_fetch_t t0;
memcpy(&t0, &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0], sizeof(t0));
fprintf(stderr, "[frontend] edge bars: matching from frame %llu: %s x %.3f..%.3f y %.3f..%.3f colour %08X blend %08X t0 %08X %ux%u, %u rect(s)\n",
(unsigned long long)s_frame, backdrop ? "backdrop" : blackout ? "black frame" : kind == uint32_t(gpu::bars::Fill::RightBar) ? "right bar" : "left bar",
(unsigned long long)s_frame, vignette ? "vignette" : backdrop ? "backdrop" : blackout ? "black frame"
: kind == uint32_t(gpu::bars::Fill::RightBar) ? "right bar" : "left bar",
box.x0, box.x1, box.y0, box.y1, barPlan.argb, s_regs[XE_GPU_REG_RB_BLENDCONTROL0] & 0x1FFF1FFF,
(uint32_t(t0.base_address) << 12) & 0x1FFFFFFFu, uint32_t(t0.size_2d.width) + 1, uint32_t(t0.size_2d.height) + 1,
barPlan.rectCount);
@@ -8225,20 +8250,27 @@ void main()
// past a 2.50:1 screen beside them. The intro's cut
// between two shots (~95 s) is a few frames of one black
// quad 0.970 x 0.939 of the 16:9 frame: the world shows
// all round it. Such a quad (gpu::bars::PlanFill: one
// black rectangle of the front-end shader the rules
// above left alone; a bar a little past both band edges
// in y, from inside the band to well past one side; a
// black frame centred, short of the band's edges) is
// drawn again after itself, stretched over the frame and
// scissored to (its half of) what lies outside the band:
// nothing inside the band changes.
// all round it (and the Blacklist bio's panel, 0.976 x
// 0.985, for as long as it is up). Such a quad
// (gpu::bars::PlanFill: one black rectangle of the
// front-end shader the rules above left alone; a bar a
// little past both band edges in y, from inside the band
// to well past one side; a black frame centred, short of
// the band's edges) is drawn again after itself,
// stretched over the frame and scissored to (its half of)
// what lies outside the band (a bar: nothing inside the
// band changes) or to everything outside itself (a black
// frame). The Showcase's vignette
// (gpu::bars::PlanVignette) is drawn stretched to the
// screen's edges instead (its own posScale).
// NFSMW_BAR_FILL=0 turns it off (an A/B switch: barFill).
const uint32_t blend0 = s_regs[XE_GPU_REG_RB_BLENDCONTROL0] & 0x1FFF1FFF;
if (barFill)
{
barPlan = planEdgeFills(c, feY, s, sv, feShader, kX == 1.0f && kY == 1.0f && kCover == 1.0f && !clipped, true);
barPlan = planEdgeFills(c, feY, s, sv, feShader, kX == 1.0f && kY == 1.0f && kCover == 1.0f && !clipped);
logEdgeFill(barPlan, s, sv);
if (barPlan.fill == gpu::bars::Fill::Vignette)
memcpy(pc.posScale, barPlan.posScale, sizeof(pc.posScale));
}
// The edge candidates (above) that still stop short of
// the screen's edge after the rules, each distinct line
@@ -8289,7 +8321,8 @@ void main()
uint32_t& perT0Edge = perKey[(uint64_t(t0) << 32) | (kind << 3) | edgeCode];
if (perT0Edge >= 4)
continue;
const std::string action = barPlan.rectCount
const std::string action = barPlan.fill == gpu::bars::Fill::Vignette ? std::string("vignette stretched to the screen's edges")
: barPlan.rectCount
? std::string(barPlan.fill == gpu::bars::Fill::Backdrop ? "backdrop fill"
: barPlan.fill == gpu::bars::Fill::Blackout ? "black frame fill" : "bar fill")
: clipped ? std::format("clipped to rows {}..{}", scissor.offset.y, scissor.offset.y + int32_t(scissor.extent.height))
@@ -8380,14 +8413,21 @@ void main()
else if (barFill)
{
// 16:9 (or a 16:9 picture letterboxed on another shape): none of
// the rules above has anything to do, and no bar or black frame
// needs a fill, but a full-band backdrop (the results screen)
// leaves the race showing in the band's overscan rows above and
// below it: those are cleared to black (gpu::bars::OverscanRows).
// the rules above has anything to do, and no bar needs a fill,
// but a black frame leaves a thin border of what is behind it all
// round it: filled with its copies (gpu::bars::BlackoutRects); a
// full-band backdrop (the results screen) leaves the race showing
// in the band's overscan rows above and below it: those are
// cleared to black (gpu::bars::OverscanRows); and the Showcase's
// vignette leaves a thin border of the garage undimmed all round
// it: it is stretched to the screen's edges
// (gpu::bars::PlanVignette).
// A front-end draw as above (the front-end matrix in c0..c3,
// ePolys at fetch constant 95, read by the vertex shader); nothing
// is measured, and only one quad over a large t0 gets as far as
// reading its vertices (gpu::bars::BackdropCandidate).
// is measured, and only one quad gets as far as reading its
// vertices: over a small t0 its first colour only (a black frame
// needs black, gpu::bars::Candidate), over a large t0
// (gpu::bars::BackdropCandidate, VignetteCandidate) all four.
if (const float feX = game::FrontEndScaleX(); feX > 0.0f)
{
float c[16];
@@ -8399,8 +8439,10 @@ void main()
const auto& vi = s_vs->info;
const bool feShader = ((vi.vertexFetchConstants[95 / 32] >> (95 % 32)) & 1) &&
(vi.constRelative || (vi.constUsed[0] & 0xFull) == 0xFull);
barPlan = planEdgeFills(c, game::FrontEndScaleY(), s, sv, feShader, true, false);
barPlan = planEdgeFills(c, game::FrontEndScaleY(), s, sv, feShader, true);
logEdgeFill(barPlan, s, sv);
if (barPlan.fill == gpu::bars::Fill::Vignette)
memcpy(pc.posScale, barPlan.posScale, sizeof(pc.posScale));
}
}
}
@@ -8416,6 +8458,8 @@ void main()
if (barPlan.rectCount)
line += std::format(" +edge {} fill x{}", barPlan.fill == gpu::bars::Fill::Backdrop ? "backdrop"
: barPlan.fill == gpu::bars::Fill::Blackout ? "black frame" : "bar", barPlan.rectCount);
else if (barPlan.fill == gpu::bars::Fill::Vignette)
line += " +vignette stretched";
static std::string last;
static uint32_t repeats = 0;
if (line == last)
+22
View File
@@ -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.
+4
View File
@@ -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
+46 -2
View File
@@ -24,6 +24,7 @@
#include <install/locate.h>
#include <install/placement.h>
#include <install/sha256.h>
#include <platform/quit_signals.h>
#include <report/report.h>
#include <ui/installer_screen.h>
#include <ui/ui.h>
@@ -172,6 +173,7 @@ namespace
uint32_t entry;
uint32_t stackSize;
};
std::atomic<bool> s_guestReturned{ false }; // the game's entry point returned
void* GuestMain(void* arg)
{
@@ -187,6 +189,7 @@ namespace
fprintf(stderr, "[runtime] calling entry %08X\n", params->entry);
uint32_t result = GuestThread::Start({ params->entry, 0, 0 });
fprintf(stderr, "[runtime] entry point returned %08X\n", result);
s_guestReturned = true;
return nullptr;
}
@@ -360,6 +363,18 @@ int main(int argc, char** argv)
sa.sa_flags = SA_SIGINFO;
for (int sig : { SIGSEGV, SIGBUS, SIGILL, SIGTRAP, SIGFPE, SIGABRT })
sigaction(sig, &sa, nullptr);
// SIGTERM, SIGINT and SIGHUP quit the game as its window's close button
// does (platform/quit_signals.h): before SDL starts, which leaves them to
// us.
quitsignals::Install();
// From here on a return from main() is a clean end too (an unknown
// option, a cancelled install, a XEX that can't be read): the session's
// crash marker goes (report.h). The quit paths' std::_Exit runs no
// destructors: they clear it themselves.
struct ClearMarkerOnReturn
{
~ClearMarkerOnReturn() { report::ClearCrashMarker(); }
} clearMarkerOnReturn;
// Anything else starting with '-' is a mistake, not a XEX path.
if (argc > 1 && argv[1][0] == '-')
@@ -393,9 +408,10 @@ int main(int argc, char** argv)
// problem began), the "What's new" note below, and this version recorded
// as the last (user/version_check.h).
version::Change versionChange = version::Change::Same;
std::string lastVersion; // the last session's ("": not known)
if (settingsLoaded)
{
std::string last = settings::LastVersion();
std::string last = lastVersion = settings::LastVersion();
versionChange = version::Classify(last, settings::FileFound(), report::Version());
fprintf(stderr, "[version] %s\n", version::Describe(versionChange, last, report::Version()).c_str());
if (versionChange != version::Change::Same)
@@ -417,7 +433,14 @@ int main(int argc, char** argv)
fprintf(stderr, "[memory] %s\n", notice.c_str());
ui::Toast(notice, 12.0);
}
report::NoticePreviousCrash();
// A crash marker left by a session of this version or a later one is a
// session that ended without quitting; v0.1.5 and older left theirs after
// every session. iOS ends apps without a word (none quits there).
#if defined(__APPLE__) && TARGET_OS_IOS
report::NoticePreviousCrash(false);
#else
report::NoticePreviousCrash(!lastVersion.empty() && version::Compare(lastVersion, report::Version()) >= 0);
#endif
// NFSMW_LIFELINE_PID (scripts/steamos/speedbreaker.sh): the launcher's PID. The
// game runs in a container, not as the launcher's child, so Steam
@@ -431,6 +454,7 @@ int main(int argc, char** argv)
if (settings::Dirty())
settings::Save();
gpu::renderer::Shutdown();
report::ClearCrashMarker();
report::FlushLog();
std::_Exit(0);
}).detach();
@@ -541,6 +565,21 @@ int main(int argc, char** argv)
if (!windowed)
{
// No window (NFSMW_HEADLESS=1): the game runs until its entry point
// returns, or until a requested termination, which ends it as the
// window's quit path below does.
while (!s_guestReturned.load() && !quitsignals::Requested())
std::this_thread::sleep_for(std::chrono::milliseconds(50));
if (int sig = quitsignals::Requested())
{
fprintf(stderr, "[runtime] %s: quitting\n", quitsignals::Name(sig));
if (settings::Dirty())
settings::Save();
gpu::renderer::Shutdown();
report::ClearCrashMarker();
report::FlushLog();
std::_Exit(0);
}
pthread_join(thread, nullptr);
report::FlushLog();
return 0;
@@ -558,11 +597,16 @@ int main(int argc, char** argv)
hid::NoteGameStarting();
while (video::RunFrame())
;
if (int sig = quitsignals::Requested())
fprintf(stderr, "[runtime] %s: quitting, as the window's close button does\n", quitsignals::Name(sig));
fprintf(stderr, "[runtime] window closed\n");
if (settings::Dirty())
settings::Save();
gpu::renderer::Shutdown();
video::Shutdown();
// A clean end: no crash marker left (a crash in the shutdown above still
// reported one).
report::ClearCrashMarker();
// The session log's last lines are still in its pipe: let them out
// before _Exit takes the thread that copies them.
report::FlushLog();
+144
View File
@@ -0,0 +1,144 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ../macos_window.h.
//
// macOS only (AppKit); built for iOS too, as every platform/apple/*.mm is,
// where it is empty. Manual reference counting like the rest of the runtime.
#include <TargetConditionals.h>
#if TARGET_OS_OSX
#include <platform/macos_window.h>
#import <AppKit/AppKit.h>
#include <cmath>
#include <SDL3/SDL.h>
namespace platform::macos
{
namespace
{
NSWindow* s_window = nil; // the game's (SDL's, not retained here)
bool s_inTransition = false; // between a fullscreen transition's "will" and its "did" (or "did fail")
NSMutableArray* s_observers = nil;
NSWindow* CocoaWindow(SDL_Window* window)
{
if (!window)
return nil;
return (__bridge NSWindow*)SDL_GetPointerProperty(SDL_GetWindowProperties(window), SDL_PROP_WINDOW_COCOA_WINDOW_POINTER,
nullptr);
}
// No title, a transparent title bar, the picture under it.
void ApplyLook(NSWindow* window)
{
window.titleVisibility = NSWindowTitleHidden;
window.titlebarAppearsTransparent = YES;
window.styleMask |= NSWindowStyleMaskFullSizeContentView;
}
void Observe(NSWindow* window, NSString* name, void (^block)(NSNotification*))
{
id token = [NSNotificationCenter.defaultCenter addObserverForName:name object:window queue:nil usingBlock:block];
[s_observers addObject:token];
}
}
void ConfigureWindow(SDL_Window* window, bool windowed)
{
NSWindow* w = CocoaWindow(window);
if (!w)
return;
if (s_window != w)
{
ForgetWindow(nullptr);
s_window = w;
s_observers = [[NSMutableArray alloc] init];
// Whether a fullscreen transition is under way (AppKit says
// nothing else): the look is never changed in the middle of one.
for (NSString* name in @[ NSWindowWillEnterFullScreenNotification, NSWindowWillExitFullScreenNotification ])
Observe(w, name, ^(NSNotification*) { s_inTransition = true; });
for (NSString* name in @[ NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification,
@"NSWindowDidFailToEnterFullScreenNotification", @"NSWindowDidFailToExitFullScreenNotification" ])
Observe(w, name, ^(NSNotification*) { s_inTransition = false; });
// SDL takes the full-size content view off as a fullscreen
// transition starts (its windowWillEnterFullScreen), but its
// timing against AppKit's own steps varies, and the bit changing
// after AppKit has set its fullscreen frame crashed AppKit. So it
// goes now, as AppKit announces the transition. (In testing SDL
// had always taken it off already; this is the safety net.)
Observe(w, NSWindowWillEnterFullScreenNotification, ^(NSNotification* n) {
NSWindow* nw = n.object;
if (nw.styleMask & NSWindowStyleMaskFullSizeContentView)
nw.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
});
}
if (windowed)
ApplyLook(w);
}
bool RestoreAfterFullscreen(SDL_Window* window)
{
NSWindow* w = CocoaWindow(window);
if (!w || s_inTransition || (w.styleMask & NSWindowStyleMaskFullScreen))
return false;
// AppKit has put back the frame the window had before fullscreen;
// -setStyleMask: would keep the content (one title bar shorter
// without the bit) and shrink the frame to it, so the frame is set
// back: the content is the whole frame again, as before fullscreen.
const NSRect frame = w.frame;
ApplyLook(w);
[w setFrame:frame display:YES];
return true;
}
void ShowWindowButtons(SDL_Window* window, bool shown)
{
NSWindow* w = CocoaWindow(window);
if (!w)
return;
const NSWindowButton buttons[] = { NSWindowCloseButton, NSWindowMiniaturizeButton, NSWindowZoomButton };
for (NSWindowButton b : buttons)
{
NSButton* button = [w standardWindowButton:b];
if (button && button.hidden == shown)
button.hidden = !shown;
}
}
float TitleStripHeight(SDL_Window* window)
{
NSWindow* w = CocoaWindow(window);
if (!w || (w.styleMask & NSWindowStyleMaskFullScreen) || !(w.styleMask & NSWindowStyleMaskFullSizeContentView))
return 0.0f;
// The layout rect is the part of the window no title bar covers.
const CGFloat strip = NSHeight(w.frame) - NSMaxY(w.contentLayoutRect);
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)
[NSNotificationCenter.defaultCenter removeObserver:token];
[s_observers release];
s_observers = nil;
s_window = nil;
s_inTransition = false;
}
}
#endif
+56
View File
@@ -0,0 +1,56 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// macOS: the game's window with its picture under a transparent title bar
// (apple/macos_window.mm; GitHub pull request #23 by aleszczynskig). The
// title isn't shown, the title bar is drawn over the top of the picture
// (NSWindowStyleMaskFullSizeContentView), and the close, minimise and zoom
// buttons sit over it only while the mouse is in use there (ui.cpp shows and
// hides them with the pointer). Fullscreen is macOS's own, unchanged. Main
// thread only (AppKit).
#pragma once
struct SDL_Window;
namespace platform::macos
{
// Once, after SDL_CreateWindow: watches the window's fullscreen
// transitions (for RestoreAfterFullscreen) and, when `windowed` (not
// made fullscreen), puts the title bar over the picture now. The content
// keeps its size (-setStyleMask: keeps the content rect), so the picture
// is the size the window was made at, the saved size, and the frame
// loses the title bar's height. A window made fullscreen gets the look
// when it leaves fullscreen: not while its Space is opening.
void ConfigureWindow(SDL_Window* window, bool windowed);
// The look again after the window left fullscreen: SDL resets the
// window's style around every fullscreen Space (SDL 3.4's Cocoa
// windowWillEnterFullScreen and windowDidExitFullScreen), which takes
// the full-size content view off. Call it from the game's event loop on
// SDL_EVENT_WINDOW_LEAVE_FULLSCREEN, not from an event watch: those run
// inside SDL's windowDidExitFullScreen, where putting it back left the
// window one title bar shorter after every round trip (AppKit has just
// restored the frame without it, and -setStyleMask: keeps the smaller
// content) and the next entry into fullscreen sometimes crashed in
// AppKit (SDL's reset then landing in the middle of the transition).
// Here it keeps the window's frame. Does nothing (false) while a
// fullscreen transition is under way (a toggle queued behind another):
// the next SDL_EVENT_WINDOW_LEAVE_FULLSCREEN brings it.
bool RestoreAfterFullscreen(SDL_Window* window);
// Shows or hides the close, minimise and zoom buttons.
void ShowWindowButtons(SDL_Window* window, bool shown);
// The height in points of the title bar's strip over the picture (0
// when there is none: fullscreen, or before the look is applied), for
// 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);
}
+84
View File
@@ -0,0 +1,84 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// A requested termination ends the game the way its window's close button
// does: main.cpp's quit path (settings saved, the renderer, UI and window
// shut down, the session's crash marker cleared, the log flushed, exit code 0).
// - SIGTERM: a shutdown or logout (systemd), `kill`, and the way a launcher
// such as Steam asks a game to end (Steam's own reaper only forks, waits
// and reaps: the stop comes from the Steam client; SIGKILL, which can't
// be caught, is a stop that doesn't wait);
// - SIGINT: Ctrl+C in the terminal the game was started from;
// - SIGHUP: that terminal closed.
// Until v0.1.6 SDL turned SIGTERM and SIGINT into its quit event (SDL puts its
// handler only where a signal's action is still the default) once the window
// was up, and SIGHUP, or SIGTERM before the window, ended the game on the
// spot: no settings saved, and the crash marker left as by a crash
// (report/crash_marker.h).
//
// The handler only records the first request (async-signal-safe); the main
// thread's frame loop (video::RunFrame, which also runs the installer and the
// startup error page) reads it and leaves; with no window (NFSMW_HEADLESS=1)
// main() waits for it. Another request while quitting changes nothing, as
// SDL's didn't. A signal the game was started with set to be ignored
// (nohup's SIGHUP) stays ignored. SIGKILL can't be caught: a session it ends
// keeps its crash marker, and the next launch logs that it ended without
// quitting.
//
// Header only (tests/clean_exit_test.cpp).
#pragma once
#include <atomic>
#include <csignal>
namespace quitsignals
{
namespace detail
{
inline std::atomic<int> g_requested{ 0 };
static_assert(std::atomic<int>::is_always_lock_free, "the handler needs a lock-free atomic");
inline void Handle(int sig)
{
int none = 0;
g_requested.compare_exchange_strong(none, sig, std::memory_order_relaxed);
}
}
// The signals a player or the system ends the game with.
inline constexpr int kSignals[] = { SIGTERM, SIGINT, SIGHUP };
// Main thread, before SDL starts (video::Initialize), so SDL leaves these
// signals to us.
inline void Install()
{
for (int sig : kSignals)
{
struct sigaction old{};
sigaction(sig, nullptr, &old);
if (!(old.sa_flags & SA_SIGINFO) && old.sa_handler == SIG_IGN)
continue;
struct sigaction sa{};
sa.sa_handler = detail::Handle;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(sig, &sa, nullptr);
}
}
// The signal that asked the game to quit, 0 if none has. Any thread.
inline int Requested()
{
return detail::g_requested.load(std::memory_order_relaxed);
}
// For the log line.
inline const char* Name(int sig)
{
switch (sig)
{
case SIGTERM: return "SIGTERM";
case SIGINT: return "SIGINT";
case SIGHUP: return "SIGHUP";
default: return "a signal";
}
}
}
+35 -22
View File
@@ -4,7 +4,8 @@
// watch declined the fault) runs on the crashing thread in a signal handler,
// so everything here that it calls is async-signal-safe: the report file is
// opened at startup (logs/.crash-pending-<session>, empty unless the session
// crashes) and renamed to crash-<session>.txt when written; its header, the
// crashes: crash_marker.h, removed when the session ends cleanly) and renamed
// to crash-<session>.txt when written; its header, the
// system summary and the symbol table are ready beforehand; text goes out
// with write(2). The one call outside POSIX's list is dladdr (and the
// backtrace() it follows), which glibc and macOS implement without
@@ -20,6 +21,7 @@
#include <stdafx.h>
#include "report.h"
#include "internal.h"
#include "crash_marker.h"
#include <gpu/command_processor.h>
#include <ui/ui.h>
@@ -158,7 +160,7 @@ namespace report
}
#endif
int s_reportFd = -1;
std::atomic<int> s_reportFd{ -1 }; // -1 once the session has ended cleanly (ClearCrashMarker)
char s_pendingPath[1024];
char s_finalPath[1024];
char s_header[2048];
@@ -411,7 +413,7 @@ namespace report
{
memcpy(s_pendingPath, pending.c_str(), pending.size() + 1);
memcpy(s_finalPath, finalPath.c_str(), finalPath.size() + 1);
s_reportFd = open(s_pendingPath, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
s_reportFd = marker::Arm(s_pendingPath);
}
if (s_reportFd < 0)
fprintf(stderr, "[crash] can't prepare a crash report file in %s: %s\n", dir.c_str(), strerror(errno));
@@ -503,18 +505,18 @@ namespace report
frame(frames[i]);
detail::WriteDirect(text, b.size);
if (s_reportFd >= 0)
if (const int fd = s_reportFd.load(); fd >= 0)
{
detail::WriteAll(s_reportFd, s_header, s_headerSize);
detail::WriteAll(s_reportFd, text + 1, b.size - 1); // (without the leading blank line)
detail::WriteAll(fd, s_header, s_headerSize);
detail::WriteAll(fd, text + 1, b.size - 1); // (without the leading blank line)
static const char recent[] = "\nThe log's last lines:\n";
detail::WriteAll(s_reportFd, recent, sizeof(recent) - 1);
detail::WriteRecentOutput(s_reportFd, 300);
detail::WriteAll(fd, recent, sizeof(recent) - 1);
detail::WriteRecentOutput(fd, 300);
static const char system[] = "\nSystem (at startup):\n";
detail::WriteAll(s_reportFd, system, sizeof(system) - 1);
detail::WriteAll(fd, system, sizeof(system) - 1);
const char* summary = detail::LoggedSystemSummary();
detail::WriteAll(s_reportFd, summary, strlen(summary));
fsync(s_reportFd);
detail::WriteAll(fd, summary, strlen(summary));
fsync(fd);
bool renamed = rename(s_pendingPath, s_finalPath) == 0;
b.size = 0;
b.Str("[crash] crash report: ").Str(renamed ? s_finalPath : s_pendingPath).Char('\n');
@@ -522,23 +524,34 @@ namespace report
}
}
void NoticePreviousCrash()
void ClearCrashMarker()
{
// A crash being reported is how the session ends: its report stays.
if (s_crashing.load())
return;
if (const int fd = s_reportFd.exchange(-1); fd >= 0)
marker::Clear(fd, s_pendingPath);
}
void NoticePreviousCrash(bool lastClearsMarker)
{
std::filesystem::path dir = LogDirectory();
std::string current = std::string(".crash-pending-") + SessionName();
std::error_code ec;
// Pending files of earlier sessions: empty (no crash: removed), or a
// Markers of earlier sessions (crash_marker.h): empty (removed), or a
// report the handler couldn't rename.
for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
for (const marker::Leftover& left : marker::Find(dir, SessionName()))
{
std::string name = it->path().filename().string();
if (!name.starts_with(".crash-pending-") || name == current)
std::error_code fileError;
if (left.ended == marker::Ended::Crashed)
{
std::filesystem::rename(left.path, dir / ("crash-" + left.session + ".txt"), fileError);
continue;
std::error_code sizeError;
if (std::filesystem::file_size(it->path(), sizeError) == 0 || sizeError)
std::filesystem::remove(it->path(), sizeError);
else
std::filesystem::rename(it->path(), dir / ("crash-" + name.substr(15) + ".txt"), sizeError);
}
std::filesystem::remove(left.path, fileError);
// A version before v0.1.6 left one after every session.
if (lastClearsMarker)
fprintf(stderr, "[crash] an earlier session (%s) ended without quitting: it was stopped from outside (SIGKILL: a forced "
"quit, the power going) or crashed too hard to write a report; there is no report to send\n", left.session.c_str());
}
detail::KeepNewest(dir.string(), "crash-", ".txt", 5);
+82
View File
@@ -0,0 +1,82 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// A session's crash marker, logs/.crash-pending-<session> (crash.cpp): made
// empty at startup and kept open for the crash handler, which writes the
// crash report into it and renames it crash-<session>.txt. A session that
// ends the way it is meant to (the window's close button, Quit Game, Cmd+Q,
// a requested termination: platform/quit_signals.h) removes it, so a marker
// left in the logs folder is a session that hasn't ended or didn't end
// cleanly: empty, it was stopped from outside (SIGKILL, the power went, the
// system ended it) or crashed too hard to report; written, the handler's
// rename didn't happen. The next launch reports what it finds (crash.cpp's
// NoticePreviousCrash). Until v0.1.6 every session left its empty marker,
// even after Quit Game, until the next launch removed it, so a marker after
// the game had gone (a Steam Frame session its player closed from Steam,
// 2026-10-15) said nothing about how it ended.
//
// POSIX file calls only. Header only (tests/clean_exit_test.cpp).
#pragma once
#include <cstdint>
#include <filesystem>
#include <string>
#include <system_error>
#include <vector>
#include <fcntl.h>
#include <unistd.h>
namespace report::marker
{
inline constexpr const char kPrefix[] = ".crash-pending-";
inline std::filesystem::path PathFor(const std::filesystem::path& dir, const std::string& session)
{
return dir / (kPrefix + session);
}
// Startup: the session's empty marker, open for the crash handler (-1:
// it couldn't be made).
inline int Arm(const char* path)
{
return open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
}
// The session ended cleanly: the marker closed and removed.
inline void Clear(int fd, const char* path)
{
if (fd >= 0)
close(fd);
unlink(path);
}
// An earlier session's marker, as a launch finds it.
enum class Ended
{
WithoutQuitting, // empty: stopped from outside, or no report could be written
Crashed, // the handler's report (it couldn't rename it)
};
struct Leftover
{
std::filesystem::path path;
std::string session;
Ended ended;
};
// The markers in `dir` other than the `current` session's.
inline std::vector<Leftover> Find(const std::filesystem::path& dir, const std::string& current)
{
std::vector<Leftover> found;
std::error_code ec;
for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
{
const std::string name = it->path().filename().string();
if (!name.starts_with(kPrefix) || name == kPrefix + current)
continue;
std::error_code sizeError;
const uintmax_t size = std::filesystem::file_size(it->path(), sizeError);
found.push_back({ it->path(), name.substr(sizeof(kPrefix) - 1),
(size == 0 || sizeError) ? Ended::WithoutQuitting : Ended::Crashed });
}
return found;
}
}
+10 -2
View File
@@ -98,9 +98,17 @@ namespace report
// The crash handler (a signal handler; async-signal-safe): logs the
// crash and writes the report. The caller exits afterwards.
void WriteCrashReport(int sig, siginfo_t* info, void* context);
// The session is ending cleanly (the quit path, any return from main()):
// its crash marker is removed (crash_marker.h), so one the next launch
// finds is a session that didn't. Last, just before the exit: a crash
// after it is only logged. Not while a crash is being reported.
void ClearCrashMarker();
// Main thread, once toasts can show: tells the player, once, about a
// crash report from an earlier session.
void NoticePreviousCrash();
// crash report from an earlier session, and logs an earlier session that
// ended without quitting. `lastClearsMarker`: the session before this
// one ran a version that clears its marker at a clean end (this one or
// later: v0.1.5 and older left it after every session).
void NoticePreviousCrash(bool lastClearsMarker);
// --- Threads and hangs (watchdog.cpp) ---
+4
View File
@@ -21,6 +21,7 @@
#include "report.h"
#include "internal.h"
#include <apu/audio.h>
#include <cpu/guest_thread.h>
#include <cpu/guest_time.h>
#include <gpu/command_processor.h>
@@ -234,6 +235,9 @@ namespace report
text += std::format("Suspended (the app inactive): {} times, {:.1f} s in all before the last resume{}.\n", suspensions,
double(guesttime::PausedNs()) / 1e9, guesttime::Suspended() ? "; suspended now" : "");
text += "\nCommand processor:\n" + gpu::DescribeState();
// The game's sound runs on the render callback, and some of its
// waits on its sound commands (GitHub issue #20).
text += "\nAudio:\n" + apu::DescribeState();
text += "\nThreads, where each one is (host stacks, innermost first; recompiled guest functions are "
"sub_<guest address>):\n";
for (const ThreadSample& s : samples)
+96
View File
@@ -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;
}
}
+42 -3
View File
@@ -15,6 +15,9 @@
#include <imgui_internal.h> // DragToScroll: the window under a touch, its scroll and the active item
#include <platform/ios_ui.h>
#endif
#if defined(__APPLE__) && !TARGET_OS_IOS
#include <platform/macos_window.h>
#endif
#include <SDL3/SDL.h>
@@ -498,6 +501,33 @@ namespace ui
}
#endif
#if defined(__APPLE__) && !TARGET_OS_IOS
bool s_buttonsShown = true; // as AppKit makes the window
int s_buttonChanges = 0; // logged, the first few
// The window's close, minimise and zoom buttons, over the picture in
// a window (platform/macos_window.h), come and go with the pointer:
// shown while it shows over the window, hidden when it hides (the
// mouse still for 2 s), leaves the window, or the app isn't in front
// (SDL then gives no window the mouse). In fullscreen they stay:
// macOS shows them there only with the menu bar.
void UpdateWindowButtons(bool pointerShown)
{
if (!s_window)
return;
const bool fullscreen = SDL_GetWindowFlags(s_window) & SDL_WINDOW_FULLSCREEN;
const bool shown = fullscreen || (pointerShown && SDL_GetMouseFocus() == s_window);
if (shown == s_buttonsShown)
return;
s_buttonsShown = shown;
platform::macos::ShowWindowButtons(s_window, shown);
if (++s_buttonChanges <= 6)
fprintf(stderr, "[ui] window buttons %s\n",
shown ? (fullscreen ? "shown (fullscreen)" : "shown (the pointer is over the window)")
: "hidden (the pointer hid or left the window)");
}
#endif
// Shows or hides the cursor for now (each frame, and as the menu
// opens or closes). While a menu or modal page has the mouse, ImGui's
// backend shows it and picks its shape; otherwise it must keep its
@@ -516,6 +546,9 @@ namespace ui
io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
}
const bool show = s_cursorIdle.Shows(SDL_GetTicksNS(), menu);
#if defined(__APPLE__)
UpdateWindowButtons(show); // every time: the pointer can leave without hiding
#endif
if (show == s_cursorShown)
return;
s_cursorShown = show;
@@ -971,14 +1004,20 @@ namespace ui
UpdateScale();
ImGui::NewFrame();
// Panels and toasts lay out in the work area: the window's safe area,
// clear of an iPhone's Dynamic Island and home indicator (elsewhere
// the whole window, as before).
// clear of an iPhone's Dynamic Island and home indicator, and below a
// Mac window's title bar where that lies over the picture (the
// window's buttons are there: platform/macos_window.h); elsewhere the
// whole window, as before.
// (The window's, in its points: kept inside the display, which is
// the swapchain's shape while FitUiDisplay overrules the window.)
if (SDL_Rect safe; s_window && SDL_GetWindowSafeArea(s_window, &safe) && safe.w > 0 && safe.h > 0)
{
ImGuiViewport* viewport = ImGui::GetMainViewport();
const float x0 = std::clamp(float(safe.x), 0.0f, io.DisplaySize.x), y0 = std::clamp(float(safe.y), 0.0f, io.DisplaySize.y);
float top = float(safe.y);
#if defined(__APPLE__) && !TARGET_OS_IOS
top = std::max(top, platform::macos::TitleStripHeight(s_window));
#endif
const float x0 = std::clamp(float(safe.x), 0.0f, io.DisplaySize.x), y0 = std::clamp(top, 0.0f, io.DisplaySize.y);
const float x1 = std::clamp(float(safe.x + safe.w), x0, io.DisplaySize.x), y1 = std::clamp(float(safe.y + safe.h), y0, io.DisplaySize.y);
if (x1 - x0 >= 1.0f && y1 - y0 >= 1.0f)
{
+5
View File
@@ -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
View File
@@ -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;
}
}
+7
View File
@@ -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);
}
+311 -41
View File
@@ -9,8 +9,10 @@
#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"
#include "picture_fit.h"
#include "frame_rate.h"
#include "swapchain_policy.h"
@@ -18,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"
@@ -25,9 +29,13 @@
#if defined(__APPLE__) && TARGET_OS_IOS
#include <platform/ios_ui.h>
#endif
#if defined(__APPLE__) && !TARGET_OS_IOS
#include <platform/macos_window.h>
#endif
#include <user/settings.h>
#include <report/report.h>
#include <platform/thermal.h>
#include <platform/quit_signals.h>
#include <kernel/memory_status.h>
#include <SDL3/SDL.h>
@@ -273,17 +281,20 @@ namespace video
// screen for more refreshes than the game gave it vblanks (repeated).
// The waits run beside presents to the same swapchain, as the
// extension is used: the WSIs synchronise the two internally (Mesa's,
// MoltenVK's), though the spec leaves that to the caller. Destroying
// a swapchain waits until no wait is on it.
// MoltenVK's), though the spec leaves that to the caller. The waiter
// uses a swapchain (its waits, the driver's reports) only through
// s_swapchainUse, and destroying one waits until no use is on it
// (video/swapchain_use.h).
bool s_presentTiming = false;
PFN_vkWaitForPresentKHR s_waitForPresent = nullptr;
uint64_t s_presentId = 0; // main thread: the last present's
struct PresentTiming
{
uint64_t id;
VkSwapchainKHR swapchain;
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
@@ -291,9 +302,22 @@ namespace video
std::mutex& s_timingMutex = *new std::mutex;
std::condition_variable& s_timingCv = *new std::condition_variable;
std::deque<PresentTiming>& s_timings = *new std::deque<PresentTiming>; // presented, not yet seen on screen
std::mutex s_waitSwapchainMutex; // held by the waiter while it waits
VkSwapchainKHR s_waitSwapchain = VK_NULL_HANDLE; // the one it may wait on
// The swapchain the waiter may use (never destroyed either), and its
// generation (main thread; 0: none).
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,
@@ -317,8 +341,9 @@ namespace video
uint32_t s_displayTimingId = 0; // main thread
// The decision and the watchdog (main thread: Wanted; the waiter:
// the driver's reports), and the frames shown at a time not reported
// on yet: present id -> the time asked for (steady clock).
std::mutex s_scheduleMutex;
// on yet: present id -> the time asked for (steady clock). The
// mutex is never destroyed (the waiter may be in it at exit).
std::mutex& s_scheduleMutex = *new std::mutex;
PresentSchedule s_schedule;
struct ScheduledPresent
{
@@ -355,18 +380,23 @@ namespace video
// for): the driver's reports on the presents shown since the last
// call, to NFSMW_VBLANK_LOG's .actual.csv and, for frames shown at a
// time, to the watchdog; and `t`, if shown at a time, counted as
// presented (the driver reports it a present or so later).
// presented (the driver reports it a present or so later). The
// reports are read only while `t`'s swapchain is the current one,
// and it isn't destroyed under the read: a swapchain destroyed since
// the wait (a fullscreen toggle, a resize) has none left to read.
void ReadDriverReports(const PresentTiming& t)
{
uint32_t count = 0;
std::vector<VkPastPresentationTimingGOOGLE> past;
if (s_pastTiming(s_device, t.swapchain, &count, nullptr) == VK_SUCCESS && count)
{
past.resize(count);
if (s_pastTiming(s_device, t.swapchain, &count, past.data()) < VK_SUCCESS)
count = 0;
past.resize(count);
}
s_swapchainUse.Use(t.swapchain, [&](VkSwapchainKHR swapchain) {
if (s_pastTiming(s_device, swapchain, &count, nullptr) == VK_SUCCESS && count)
{
past.resize(count);
if (s_pastTiming(s_device, swapchain, &count, past.data()) < VK_SUCCESS)
count = 0;
past.resize(count);
}
});
const int64_t hostToSteady = HostToSteadyNs();
bool tripped = false;
{
@@ -413,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);
@@ -441,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()
@@ -469,12 +529,10 @@ namespace video
t = s_timings.front();
}
int64_t start = NowNs();
VkResult r = VK_ERROR_OUT_OF_DATE_KHR;
{
std::lock_guard lock(s_waitSwapchainMutex);
if (t.swapchain == s_waitSwapchain)
r = s_waitForPresent(s_device, t.swapchain, t.id, 50'000'000);
}
VkResult r = VK_ERROR_OUT_OF_DATE_KHR; // its swapchain is gone
s_swapchainUse.Use(t.swapchain, [&](VkSwapchainKHR swapchain) {
r = s_waitForPresent(s_device, swapchain, t.id, 50'000'000);
});
int64_t now = NowNs();
{
std::lock_guard lock(s_timingMutex);
@@ -541,13 +599,52 @@ 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()
{
{
std::lock_guard wait(s_waitSwapchainMutex); // the waiter is off it after this
s_waitSwapchain = VK_NULL_HANDLE;
s_swapchainUse.Retire(); // the waiter is off it after this, and stays off
std::lock_guard lock(s_timingMutex);
std::erase_if(s_timings, [](const PresentTiming& t) { return t.swapchain == s_swapchain; });
std::erase_if(s_timings, [](const PresentTiming& t) { return t.swapchain == s_swapchainGeneration; });
s_swapchainGeneration = 0;
}
for (VkSemaphore s : s_renderDone)
vkDestroySemaphore(s_device, s, nullptr);
@@ -671,10 +768,7 @@ namespace video
!s_vsync && info.presentMode == VK_PRESENT_MODE_FIFO_KHR ? "; the surface offers nothing else" : "");
}
}
{
std::lock_guard lock(s_waitSwapchainMutex);
s_waitSwapchain = s_swapchain;
}
s_swapchainGeneration = s_swapchainUse.Publish(s_swapchain);
if (s_uiReady)
ui::SetFormat(s_swapFormat);
s_swapPolicy.Created({ s_swapExtent.width, s_swapExtent.height });
@@ -2252,6 +2346,11 @@ void main()
fprintf(stderr, "[video] SDL_CreateWindow: %s\n", SDL_GetError());
return false;
}
#if defined(__APPLE__) && !TARGET_OS_IOS
// The title bar over the picture (platform/macos_window.h); a window
// made fullscreen gets it when it leaves fullscreen.
platform::macos::ConfigureWindow(s_window, !startFullscreen);
#endif
#if defined(__APPLE__) && TARGET_OS_IOS
platform::ios::WatchKeyboards(s_window);
#endif
@@ -2490,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(),
@@ -2618,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;
@@ -3081,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();
@@ -3091,6 +3313,10 @@ void main()
platform::ios::PollBattery(); // every 5 seconds
#endif
ScriptedWindowSizes();
// SIGTERM, SIGINT, SIGHUP (platform/quit_signals.h): the window's
// way out, from the game, the installer or the startup error page.
if (quitsignals::Requested())
return false;
SDL_Event e;
while (SDL_PollEvent(&e))
{
@@ -3111,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)
@@ -3132,6 +3360,14 @@ void main()
fprintf(stderr, "[video] fullscreen %s by the system (the window's fullscreen button or menu, or the window "
"manager), for this session; the Fullscreen setting (%s) is unchanged and decides the next launch\n",
on ? "on" : "off", settings::GetBool(settings::Id::Fullscreen) ? "On" : "Off");
#if defined(__APPLE__) && !TARGET_OS_IOS
// SDL took the title bar off the picture for the fullscreen
// Space: back on here, in the event loop, after SDL's own
// callbacks (platform/macos_window.h says why not sooner).
int windowW = 0, windowH = 0;
if (!on && platform::macos::RestoreAfterFullscreen(s_window) && SDL_GetWindowSize(s_window, &windowW, &windowH))
fprintf(stderr, "[video] the title bar over the picture again (window %dx%d)\n", windowW, windowH);
#endif
}
}
// Without the queue lock: the command processor's submits take it,
@@ -3158,6 +3394,7 @@ void main()
hid::SetBlocked(ui::CapturingInput());
hid::Update();
ApplySettings();
MaybeRefreshNotice();
if (!s_swapchain && !CreateSwapchain())
{
SDL_Delay(16);
@@ -3197,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
@@ -3723,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_swapchain, presented, frameVblank });
s_timings.push_back({ id, s_swapchainGeneration, presented, frameVblank, ready });
s_timingCv.notify_one();
}
s_lastPresent = std::chrono::steady_clock::now();
@@ -3827,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;
}
@@ -3945,10 +4213,12 @@ void main()
s_uiReady = false;
}
hid::Shutdown(); // a rumble under way would otherwise carry on
{
std::lock_guard wait(s_waitSwapchainMutex); // the present waiter is off the swapchain
s_waitSwapchain = VK_NULL_HANDLE;
}
s_swapchainUse.Retire(); // the present waiter is off the swapchain, and stays off
// Then the title bar's styling lets go of the window (Retire first:
// nothing presents to it while it is taken apart), then SDL frees it.
#if defined(__APPLE__) && !TARGET_OS_IOS
platform::macos::ForgetWindow(s_window);
#endif
if (s_window)
SDL_DestroyWindow(s_window);
// No SDL_Quit: the process exits next, and the game's audio thread
+4 -3
View File
@@ -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();
+49 -4
View File
@@ -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
+39 -4
View File
@@ -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;
};
+112
View File
@@ -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;
}
};
}
+148
View File
@@ -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.";
}
};
}
+77
View File
@@ -0,0 +1,77 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The swapchain the present waiter's thread may use, against the main thread
// destroying it and creating another (video/presenter.cpp).
//
// The waiter (PresentWaiter) waits for each present to reach the screen
// (vkWaitForPresentKHR) and, on a Mac showing frames at a time, reads the
// driver's reports on the presents shown (vkGetPastPresentationTimingGOOGLE,
// for present_schedule.h's watchdog). Both take the swapchain. The main
// thread destroys it and makes another whenever the surface changes: a
// fullscreen toggle, a resize, V-Sync. In v0.1.4 and v0.1.5 the wait looked
// at the swapchain under a mutex, but the reports were read after it,
// outside it: when the main thread destroyed the swapchain in between,
// MoltenVK read a freed swapchain and the game aborted ("mutex lock failed"
// in vkGetPastPresentationTimingGOOGLE: a MacBook's 120 Hz panel, the
// default settings, fullscreen toggled again and again). The reports had
// been read that way only for NFSMW_VBLANK_LOG until v0.1.4's watchdog read
// them every frame.
//
// Every use of the swapchain off the main thread goes through Use(), which
// runs it only while the swapchain it is for is still the one published, and
// holds Retire() off until it returns. Retire() (before the swapchain is
// destroyed) returns once no use is under way, and none starts after it.
// Uses name the swapchain by its generation, not its handle: a new swapchain
// may get a destroyed one's handle (MoltenVK's and Mesa's are pointers), and
// a present to the old one must not be waited for on the new one.
//
// Header only (tests/swapchain_use_test.cpp).
#pragma once
#include <cstdint>
#include <mutex>
namespace video
{
template <typename Handle>
class SwapchainUse
{
public:
// Main thread, once `swapchain` is made: uses may take it from now
// on. Its generation (never 0), which they name it by.
uint64_t Publish(Handle swapchain)
{
std::lock_guard lock(m_mutex);
m_swapchain = swapchain;
m_generation = ++m_lastGeneration;
return m_generation;
}
// Main thread, before the published swapchain is destroyed: on
// return no use of it is under way, and none will start.
void Retire()
{
std::lock_guard lock(m_mutex);
m_swapchain = Handle{};
m_generation = 0;
}
// Any thread: use(swapchain) if `generation` is still the published
// one, with Retire() held off until it returns. False: that
// swapchain is gone (or about to be), and `use` didn't run.
template <typename F>
bool Use(uint64_t generation, F&& use)
{
std::lock_guard lock(m_mutex);
if (generation == 0 || generation != m_generation)
return false;
use(m_swapchain);
return true;
}
private:
std::mutex m_mutex;
Handle m_swapchain{};
uint64_t m_generation = 0; // the published one's; 0: none
uint64_t m_lastGeneration = 0;
};
}
+149 -7
View File
@@ -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
View File
@@ -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;
};
}
+49 -2
View File
@@ -30,9 +30,15 @@ sb_test(depth_only_test ARGS ${RT}/gpu/renderer.cpp)
# and the scaled resolve memory's layout (gpu/render_scale.h, header only);
# renderer.cpp is read to check its scaled-memory shaders use it.
sb_test(render_scale_test ARGS ${RT}/gpu/renderer.cpp)
# The police vid-cam's side bars and the fill past the 16:9 band
# (gpu/edge_bars.h, header only).
# The police vid-cam's side bars, the intro's black frames, the results
# screen's backdrop and what fills the screen round them (gpu/edge_bars.h,
# header only).
sb_test(edge_bars_test)
# GitHub #19's menu borders (gpu/edge_bars.h, header only): the Showcase's
# vignette stretched to the screen's edges, the Blacklist bio's black panel
# filled all round, at every screen shape; renderer.cpp is read to check it
# asks for and applies the plans on both paths.
sb_test(menu_borders_test ARGS ${RT}/gpu/renderer.cpp)
# A saved pipeline cache from another GPU, driver or OS version, and the log
# line that says so (gpu/pipeline_cache_header.h, header only).
sb_test(pipeline_cache_header_test)
@@ -80,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).
@@ -88,6 +103,16 @@ sb_test(vblank_timer_test SOURCES ${RT}/video/vblank_timer.cpp)
# When the Mac shows a new frame at a set time after its vblank, and the
# watchdog that turns it off (video/present_schedule.h, header only).
sb_test(present_schedule_test)
# The audio render callback's cadence against the output device
# (apu/audio_cadence.h, header only): a device that stops taking audio
# doesn't stop the game's sound (GitHub issue #20: the sound stopped for good,
# then the game hung after the race), and a working one is followed as before.
sb_test(audio_cadence_test)
# The present waiter uses a swapchain only while it is the current one, and
# none is destroyed under such a use (video/swapchain_use.h, header only):
# fake swapchains destroyed and made again under a waiting thread; presenter.cpp
# is read to check every waiter call that takes a swapchain is inside a use.
sb_test(swapchain_use_test ARGS ${RT}/video/presenter.cpp)
sb_test(frame_rate_policy_test SOURCES ${RT}/video/frame_rate_policy.cpp)
# When the presenter builds its swapchain again and at what size (an iPhone's
# view turned portrait by SDL, sizes seen while inactive, SUBOPTIMAL), and the
@@ -164,9 +189,16 @@ sb_test(memory_status_test SOURCES ${RT}/kernel/memory_status.cpp ${RT}/platform
sb_test(version_check_test)
# The keys a Controls row can hold and their names in settings.toml
# (user/key_names.h, header only), each row against SDL's scancode name.
# The only test that links SDL3, so it keeps the build tree's path to it even
# with -DCMAKE_SKIP_BUILD_RPATH=ON: the Steam Frame's cmake line
# (scripts/steamos/README.md) keeps ~/deps/sdl3/lib out of the game that
# way, and ctest runs the tests without LD_LIBRARY_PATH, so this one couldn't
# load libSDL3 there (v0.1.5). Only this test: the game's own rpath is
# unchanged (none on the Frame).
find_package(SDL3 REQUIRED CONFIG)
sb_test(key_names_test)
target_link_libraries(key_names_test PRIVATE SDL3::SDL3)
set_target_properties(key_names_test PROPERTIES SKIP_BUILD_RPATH OFF)
# The Key options (Settings > Controls): the table, the [keys] names in the
# file, and what Set accepts (user/settings.cpp and the toml++ XenonRecomp
# vendors; no SDL).
@@ -182,6 +214,15 @@ sb_test(update_check_test SOURCES ${RT}/update/check.cpp)
# is read: the button and prompt gated, the focus on Cancel, B and Escape
# cancelling, and the quit taking the window close's way out.
sb_test(quit_game_test ARGS ${RT})
# A requested termination (SIGTERM: a launcher's stop, a shutdown, kill;
# SIGINT; SIGHUP) quits as the window's close button does, and only a session that
# didn't end cleanly leaves its crash marker (platform/quit_signals.h,
# report/crash_marker.h, header only): signals in child processes, the marker
# in a scratch folder, SIGTERM vs SIGKILL end to end; main.cpp, presenter.cpp
# and crash.cpp are read to check the quit paths use them. POSIX only (fork).
if(NOT WIN32)
sb_test(clean_exit_test ARGS ${CMAKE_CURRENT_BINARY_DIR}/clean_exit_scratch ${RT})
endif()
# The jump tables XenonRecomp makes switches of (config/switch_tables.toml)
# against the leaf functions' bounds (config/nfsmw.toml): no label or default
# outside its function, and the tables a borrowed bounds check broke (the
@@ -195,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)
+268
View File
@@ -0,0 +1,268 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// audio_cadence_test: the render callback's cadence against the output
// device (apu/audio_cadence.h), run against a simulated device that takes
// `pull` frames every `pull` ticks of its own clock (as a 1024-sample buffer
// takes 4 frames of 256), a little or a lot fast or slow, or that stops
// taking audio.
// 1. GitHub issue #20: a device that stops taking audio for good. The game's
// sound (its render callback) carries on at real time once the device
// has taken nothing for StallTicks; every frame is dropped from then on,
// so the queue is held. The cadence as it was up to v0.1.5 rendered
// nothing from the moment the queue filled up: that is checked too, as
// the bug's model.
// 2. A device that takes audio, from 5% slow to 5% fast (SDL's dummy driver
// runs ~3% slow), with pulls of 1 to 16 frames: never a stall, nothing
// dropped, and frame for frame what the v0.1.5 cadence rendered, for 10
// minutes.
// 3. A device that stalls for 2 s and takes audio again: the stall ends at
// its first pull, the stale queue is cleared, and the queue is back at
// its target within a second, nothing dropped after.
// 4. A route change to a larger buffer (Bluetooth headphones: target 8 ->
// 32 frames, pulls of 16): as before, frame for frame.
// 5. The stall threshold: StallTicks idle ticks, not one fewer.
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/audio_cadence_test.cpp -o /tmp/audio_cadence_test
#include <apu/audio_cadence.h>
#include <cstdio>
#include <cstdlib>
#include <string>
namespace
{
int s_failed = 0;
void Check(bool ok, const std::string& what)
{
if (!ok)
{
s_failed++;
fprintf(stderr, "FAIL: %s\n", what.c_str());
}
}
// The cadence up to v0.1.5 (apu/audio.cpp): one frame more or less when
// the queue strays more than 2 frames from its target, nothing else.
int OldFrames(int queued, int target)
{
return queued > target + 2 ? 0 : queued < target - 2 ? 2 : 1;
}
struct Device
{
int pull = 4; // frames taken at once
double rate = 1.0; // its clock against ours
bool taking = true;
double due = 0; // ticks of its clock until its next pull
// One tick: what it takes from a queue of `queued` frames.
int Take(int queued)
{
if (!taking)
return 0;
int took = 0;
for (due -= rate; due <= 0; due += pull)
took += std::min(queued - took, pull);
return took;
}
};
struct Sim
{
apu::AudioCadence cadence;
bool oldRule = false;
int target = 8;
int queued = 0;
int lastFrames = 0;
long long rendered = 0, dropped = 0, taken = 0;
int maxQueued = 0;
// One tick (5.33 ms): the device takes what it takes, then the
// render thread renders.
void Tick(Device& d)
{
const int took = d.Take(queued);
queued -= took;
taken += took;
const int frames = oldRule ? OldFrames(queued, target) : cadence.Frames(queued, target);
if (!oldRule && cadence.Resumed())
queued = 0; // audio.cpp clears the stale audio
for (int i = 0; i < frames; i++)
{
rendered++;
if (!oldRule && cadence.Stalled())
dropped++;
else
queued++;
}
lastFrames = frames;
maxQueued = std::max(maxQueued, queued);
}
};
constexpr long long kTicksPerSecond = 48000 / 256; // 187.5 -> 187
void DeviceStopsForGood(bool oldRule)
{
Sim s;
s.oldRule = oldRule;
Device d;
for (int i = 0; i < 1000; i++) // ~5 s of normal play
s.Tick(d);
d.taking = false;
const long long before = s.rendered;
const long long ticks = 60 * kTicksPerSecond; // a minute of a device that takes nothing
for (long long i = 0; i < ticks; i++)
s.Tick(d);
const long long rendered = s.rendered - before;
if (oldRule)
{
// The bug's model: once the queue is over its target, no render
// at all, so the game's mixer never runs again (#20).
Check(rendered <= 8, "old rule: a stalled device stops the render callback (rendered " + std::to_string(rendered) + ")");
return;
}
// Before the stall shows, the queue sits over its target and frames
// are skipped (as before); from then on, one a tick.
const long long allowed = apu::AudioCadence::StallTicks(s.target) + 1;
Check(rendered >= ticks - allowed, "a stalled device: the game's sound carries on at real time (rendered " +
std::to_string(rendered) + " of " + std::to_string(ticks) + " ticks)");
Check(s.cadence.Stalled() && s.cadence.Stalls() == 1, "a stalled device is one stall, still on");
Check(s.maxQueued <= s.target + apu::AudioCadence::kSlack + 2,
"a stalled device: the queue is held (max " + std::to_string(s.maxQueued) + ")");
Check(s.dropped >= rendered - (s.target + apu::AudioCadence::kSlack + 2),
"a stalled device: what it can't take is dropped (dropped " + std::to_string(s.dropped) + ")");
}
// A device that takes audio: the cadence renders frame for frame what
// the v0.1.5 one did, and never sees a stall.
void TakesAudio(double rate, int pull, int target, long long ticks)
{
Sim s, old;
old.oldRule = true;
s.target = old.target = target;
Device d, od;
d.rate = od.rate = rate;
d.pull = od.pull = pull;
long long differ = 0;
for (long long i = 0; i < ticks; i++)
{
s.Tick(d);
old.Tick(od);
differ += s.lastFrames != old.lastFrames;
}
const std::string what = "a device at " + std::to_string(rate) + "x, pulls of " + std::to_string(pull) + ", target " +
std::to_string(target) + ": ";
Check(s.cadence.Stalls() == 0 && s.dropped == 0, what + "no stall, nothing dropped (" + std::to_string(s.cadence.Stalls()) +
" stalls, " + std::to_string(s.dropped) + " dropped)");
Check(differ == 0, what + "rendered as v0.1.5 did, frame for frame (" + std::to_string(differ) + " ticks differ)");
Check(s.rendered == s.taken + s.queued, what + "rendered = taken + queued");
}
void StallsAndResumes()
{
Sim s;
Device d;
for (int i = 0; i < 1000; i++)
s.Tick(d);
d.taking = false;
int stalledAt = -1;
for (long long i = 0; i < 2 * kTicksPerSecond; i++)
{
s.Tick(d);
if (stalledAt < 0 && s.cadence.Stalled())
stalledAt = int(i);
}
Check(stalledAt == apu::AudioCadence::StallTicks(s.target) - 1, "the stall shows after StallTicks idle ticks (" +
std::to_string(stalledAt + 1) + ")");
Check(s.dropped > 0, "a 2 s stall drops frames");
d.taking = true;
long long droppedAtResume = -1;
int over = -1, back = -1;
for (long long i = 0; i < 60 * kTicksPerSecond; i++)
{
s.Tick(d);
if (over < 0 && !s.cadence.Stalled())
{
over = int(i);
droppedAtResume = s.dropped;
s.maxQueued = 0;
}
if (over >= 0 && back < 0 && s.queued >= s.target - apu::AudioCadence::kSlack - d.pull &&
s.queued <= s.target + apu::AudioCadence::kSlack)
back = int(i);
}
Check(over >= 0 && over <= d.pull, "the stall ends at the device's first pull (" + std::to_string(over) + " ticks)");
Check(!s.cadence.Stalled() && s.cadence.Stalls() == 1, "one stall, over");
Check(back >= 0 && back <= kTicksPerSecond, "the queue is back at its target within a second (" + std::to_string(back) + " ticks)");
Check(s.dropped == droppedAtResume, "nothing dropped once the device takes audio again");
Check(s.maxQueued <= s.target + apu::AudioCadence::kSlack + 2, "no stale audio left after the stall (max queue " +
std::to_string(s.maxQueued) + ")");
}
void RouteToLargerBuffer()
{
Sim s, old;
old.oldRule = true;
Device d, od;
for (int i = 0; i < 1000; i++)
{
s.Tick(d);
old.Tick(od);
}
// Bluetooth headphones: a 4096-sample buffer (16 frames), target 32.
s.target = old.target = 32;
d.pull = od.pull = 16;
long long differ = 0;
for (long long i = 0; i < 60 * kTicksPerSecond; i++)
{
s.Tick(d);
old.Tick(od);
differ += s.lastFrames != old.lastFrames;
}
Check(differ == 0 && s.cadence.Stalls() == 0 && s.dropped == 0,
"a route change to a larger buffer: as before, frame for frame (" + std::to_string(differ) + " ticks differ)");
}
void StallThreshold()
{
using C = apu::AudioCadence;
for (int target : { 4, 8, 32 })
{
// A queue over its target that stays put: nothing rendered, and
// the device takes nothing.
const int queued = target + C::kSlack + 1;
C c;
c.Frames(queued, target);
for (int idle = 1; idle < C::StallTicks(target); idle++)
c.Frames(queued, target);
Check(!c.Stalled(), "target " + std::to_string(target) + ": one idle tick short of StallTicks isn't a stall");
c.Frames(queued, target);
Check(c.Stalled(), "target " + std::to_string(target) + ": StallTicks idle ticks are a stall");
c.Frames(queued, target);
Check(c.Stalled(), "a stalled queue that stays put is still a stall");
c.Frames(queued - 1, target);
Check(!c.Stalled() && c.Resumed() && !c.Resumed(), "a stall ends when the device takes a frame, reported once");
}
Check(C::StallTicks(8) == C::kStallTicks && C::StallTicks(64) == 256, "StallTicks: half a second, or four targets");
}
}
int main()
{
DeviceStopsForGood(true);
DeviceStopsForGood(false);
for (double rate : { 0.95, 0.966, 0.998, 1.0, 1.002, 1.05 })
for (int pull : { 1, 2, 4, 16 })
TakesAudio(rate, pull, pull == 16 ? 32 : 8, 600 * kTicksPerSecond);
StallsAndResumes();
RouteToLargerBuffer();
StallThreshold();
if (s_failed)
{
fprintf(stderr, "%d checks failed\n", s_failed);
return 1;
}
printf("audio_cadence_test: all checks passed\n");
return 0;
}
+13 -8
View File
@@ -6,9 +6,10 @@
// build/band_filter_test
// What it checks:
// - BandOf's rows and columns are those whose centres lie inside the band
// (a pixel-centre oracle), and with the edge-bar fills' rows
// (gpu::bars::BlackoutRects) they split the frame with no gap and no
// overlap, at every internal resolution (a fuzz over screen shapes too);
// (a pixel-centre oracle), and with the edge-bar fills' rows (gpu::
// bars::BlackoutRects of a frame as big as the band) they split the
// frame with no gap and no overlap, at every internal resolution (a fuzz
// over screen shapes too);
// - the scaling pass, modelled along the axis that is split (bilinear,
// Catmull-Rom with its 2x2 clamp and sharpening, and the 7-fetch
// variant), on a frame whose strips are black and whose band is white:
@@ -245,9 +246,10 @@ static int AllCases(const Model& m)
}
// BandOf against the pixel-centre oracle, and against the edge-bar fills:
// with the black frame's fill rows (gpu::bars::BlackoutRects, through a
// full-frame front-end viewport: NDC y = -clip y) every row is in exactly
// one of the band and the fill. Columns likewise under Hor+.
// with the fill of a black frame as big as the band (gpu::bars::
// BlackoutRects, through a full-frame front-end viewport: NDC y = -clip y)
// every row is in exactly one of the band and the fill. Columns likewise
// under Hor+.
static void BandRowsCase(uint32_t w, uint32_t h, float sx, float sy)
{
const video::FrameBand b = video::BandOf(w, h, sx, sy);
@@ -271,8 +273,11 @@ static void BandRowsCase(uint32_t w, uint32_t h, float sx, float sy)
CHECK(wrong == 0, "%ux%u scale %.4f: %d %s differ from the pixel-centre oracle", w, h, scale, wrong, axis ? "rows" : "columns");
}
const float ps[4] = { 1.0f, -1.0f, 0.0f, 0.0f };
gpu::bars::Rect r[2]{};
const uint32_t n = gpu::bars::BlackoutRects(ps, float(w), float(h), { 0, 0, int32_t(w), int32_t(h) }, sx, sy, r);
// The fill of a black frame as big as the band itself: everything past
// the band (the rows above and below it, or the columns beside it).
const gpu::bars::Box band{ -sx, sx, -sy, sy };
gpu::bars::Rect r[4]{};
const uint32_t n = gpu::bars::BlackoutRects(band, ps, float(w), float(h), { 0, 0, int32_t(w), int32_t(h) }, r);
int wrongPx = 0;
for (int32_t y = 0; y < int32_t(h); y += (y % 97 == 0 ? 1 : 7))
for (int32_t x = 0; x < int32_t(w); x += 13)
+416
View File
@@ -0,0 +1,416 @@
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
//
// A requested termination quits the game cleanly, and only a session that
// didn't end cleanly leaves its crash marker (platform/quit_signals.h,
// report/crash_marker.h). v0.1.5 left the empty marker after every session
// (a Steam Frame session closed from Steam left one, as if it had crashed),
// and SIGHUP ended it at once, saving nothing.
// - Signals, each case in a child process: SIGTERM, SIGINT and SIGHUP
// become a quit request and the process lives on to quit; the first
// request is the one kept; a signal ignored at launch (nohup) stays
// ignored; SDL's own handler would leave them to us (it takes a signal
// only while its action is the default); SIGKILL still kills. Controls:
// without the handlers SIGHUP kills (v0.1.5).
// - The marker: made empty, removed by a clean end; what the next launch
// finds after a clean end, a SIGKILL, a crash report it couldn't rename,
// and beside the current session's own. Then main()'s order end to end in
// child processes: SIGTERM -> the quit -> no marker; SIGKILL -> marker.
// - The optional argument is the runtime folder: main.cpp, presenter.cpp
// and crash.cpp are read to check the handlers come before SDL, the frame
// loop leaves on a request, every clean end clears the marker (last, just
// before the exit) and a crash being reported keeps it; then planted
// mutations of them that must fail.
//
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/clean_exit_test.cpp -o build/clean_exit_test
// build/clean_exit_test [<scratch folder>] [runtime]
#include <platform/quit_signals.h>
#include <report/crash_marker.h>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <functional>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#include <sys/wait.h>
#include <unistd.h>
static int g_failures = 0;
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
namespace fs = std::filesystem;
namespace
{
// `body` in a child process: its exit code (0: passed), or 128 + the
// signal that killed it.
int InChild(const std::function<int()>& body)
{
fflush(stdout);
const pid_t pid = fork();
if (pid == 0)
_exit(body());
int status = 0;
waitpid(pid, &status, 0);
return WIFEXITED(status) ? WEXITSTATUS(status) : WIFSIGNALED(status) ? 128 + WTERMSIG(status) : 255;
}
bool WaitFor(const std::function<bool()>& done, int ms)
{
for (int i = 0; i < ms; i++)
{
if (done())
return true;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return done();
}
void Signals()
{
printf("signals:\n");
for (int sig : quitsignals::kSignals)
{
const int rc = InChild([sig] {
quitsignals::Install();
raise(sig);
return quitsignals::Requested() == sig ? 0 : 1;
});
CHECK(rc == 0, "%s: a quit request, and the game lives on to quit (child: %d)", quitsignals::Name(sig), rc);
}
// From another process (as Steam, systemd or kill send it), while
// the game runs.
{
fflush(stdout);
const pid_t pid = fork();
if (pid == 0)
{
quitsignals::Install();
const bool seen = WaitFor([] { return quitsignals::Requested() != 0; }, 5000);
_exit(seen && quitsignals::Requested() == SIGTERM ? 0 : 1);
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
kill(pid, SIGTERM);
int status = 0;
waitpid(pid, &status, 0);
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0, "SIGTERM from another process: seen, the child quit by itself");
}
{
const int rc = InChild([] {
quitsignals::Install();
raise(SIGTERM);
raise(SIGHUP);
raise(SIGINT);
return quitsignals::Requested() == SIGTERM ? 0 : 1;
});
CHECK(rc == 0, "the first request is kept; later ones change nothing (child: %d)", rc);
}
{
const int rc = InChild([] {
signal(SIGHUP, SIG_IGN); // nohup
quitsignals::Install();
raise(SIGHUP);
struct sigaction now{};
sigaction(SIGHUP, nullptr, &now);
return quitsignals::Requested() == 0 && now.sa_handler == SIG_IGN ? 0 : 1;
});
CHECK(rc == 0, "SIGHUP ignored at launch (nohup) stays ignored (child: %d)", rc);
}
{
// SDL_EventSignal_Init (SDL 3.4.16, events/SDL_quit.c) puts its
// handler only where the action is the default.
const int rc = InChild([] {
quitsignals::Install();
for (int sig : quitsignals::kSignals)
{
struct sigaction action{};
sigaction(sig, nullptr, &action);
if (action.sa_handler == SIG_DFL && reinterpret_cast<void*>(action.sa_sigaction) == reinterpret_cast<void*>(SIG_DFL))
return 1; // SDL would take it
}
return 0;
});
CHECK(rc == 0, "SDL leaves the three signals to the game's handler (child: %d)", rc);
}
{
const int rc = InChild([] {
quitsignals::Install();
raise(SIGKILL);
return 0;
});
CHECK(rc == 128 + SIGKILL, "SIGKILL still kills (child: %d)", rc);
}
{
const int rc = InChild([] {
raise(SIGHUP);
return 0;
});
CHECK(rc == 128 + SIGHUP, "control: without the handlers SIGHUP kills, as in v0.1.5 (child: %d)", rc);
}
}
bool Exists(const fs::path& p)
{
std::error_code ec;
return fs::exists(p, ec);
}
const report::marker::Leftover* FindSession(const std::vector<report::marker::Leftover>& found, const std::string& session)
{
for (const auto& l : found)
if (l.session == session)
return &l;
return nullptr;
}
void Marker(const fs::path& scratch)
{
printf("the crash marker:\n");
namespace marker = report::marker;
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch, ec);
CHECK(!ec, "can't make %s", scratch.c_str());
const std::string clean = "2026-10-15_11-00-00", killed = "2026-10-15_11-10-00", crashed = "2026-10-15_11-20-00",
current = "2026-10-15_11-30-00";
auto arm = [&](const std::string& session) {
const std::string path = marker::PathFor(scratch, session).string();
return std::pair{ marker::Arm(path.c_str()), path };
};
{
auto [fd, path] = arm(clean);
CHECK(fd >= 0 && Exists(path) && fs::file_size(path, ec) == 0, "armed: an empty %s", path.c_str());
marker::Clear(fd, path.c_str());
CHECK(!Exists(path), "a clean end removes it");
}
{
auto [fd, path] = arm(killed);
close(fd); // the process gone without a word (SIGKILL)
}
{
auto [fd, path] = arm(crashed);
const char report[] = "SpeedBreaker (NFS: Most Wanted, Xbox 360): crash report\n";
CHECK(write(fd, report, sizeof(report) - 1) > 0, "write");
close(fd); // the handler's rename didn't happen
}
auto [currentFd, currentPath] = arm(current);
const std::vector<marker::Leftover> found = marker::Find(scratch, current);
CHECK(found.size() == 2, "the next launch finds 2 earlier markers (%zu)", found.size());
const marker::Leftover* k = FindSession(found, killed);
const marker::Leftover* c = FindSession(found, crashed);
CHECK(k && k->ended == marker::Ended::WithoutQuitting, "SIGKILL's empty marker: ended without quitting");
CHECK(c && c->ended == marker::Ended::Crashed, "a written marker: a crash report");
CHECK(!FindSession(found, clean), "the clean end left nothing");
CHECK(!FindSession(found, current), "the current session's own marker isn't an earlier one's");
marker::Clear(currentFd, currentPath.c_str());
// main()'s order, end to end: arm the marker, install the handlers,
// run frames until a quit is requested, then the quit path clears
// the marker and exits 0.
auto session = [&](const std::string& name) {
fflush(stdout);
const pid_t pid = fork();
if (pid == 0)
{
const std::string path = marker::PathFor(scratch, name).string();
const int fd = marker::Arm(path.c_str());
quitsignals::Install();
std::ofstream(scratch / (name + ".ready")) << "ready\n";
while (!quitsignals::Requested()) // RunFrame
std::this_thread::sleep_for(std::chrono::milliseconds(2));
marker::Clear(fd, path.c_str()); // report::ClearCrashMarker
_exit(0);
}
CHECK(WaitFor([&] { return Exists(scratch / (name + ".ready")); }, 5000), "%s: the child started", name.c_str());
return pid;
};
for (int sig : quitsignals::kSignals)
{
const std::string name = std::string("signal-") + quitsignals::Name(sig);
const pid_t pid = session(name);
kill(pid, sig);
int status = 0;
waitpid(pid, &status, 0);
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0, "%s: the session quit by itself, exit 0", quitsignals::Name(sig));
CHECK(!Exists(marker::PathFor(scratch, name)), "%s: no marker left", quitsignals::Name(sig));
}
{
const pid_t pid = session("signal-SIGKILL");
kill(pid, SIGKILL);
int status = 0;
waitpid(pid, &status, 0);
CHECK(WIFSIGNALED(status) && WTERMSIG(status) == SIGKILL, "SIGKILL: killed");
const std::vector<marker::Leftover> after = marker::Find(scratch, current);
const marker::Leftover* l = FindSession(after, "signal-SIGKILL");
CHECK(l && l->ended == marker::Ended::WithoutQuitting, "SIGKILL: its marker stays, and the next launch finds it ended without quitting");
}
fs::remove_all(scratch, ec);
}
std::string Read(const fs::path& path)
{
std::ifstream in(path);
std::stringstream ss;
ss << in.rdbuf();
return ss.str();
}
struct Sources
{
std::string main, presenter, crash;
};
// A function's body: from `head` to the brace at its own indent.
std::string Body(const std::string& src, const std::string& head)
{
const size_t at = src.find(head);
if (at == std::string::npos)
return {};
const size_t line = src.rfind('\n', at) + 1;
const std::string end = "\n" + src.substr(line, at - line) + "}";
const size_t stop = src.find(end, at);
return src.substr(at, stop == std::string::npos ? std::string::npos : stop - at);
}
// `a` before `b` in `text`, both there.
bool Before(const std::string& text, const std::string& a, const std::string& b, size_t from = 0)
{
const size_t x = text.find(a, from), y = text.find(b, from);
return x != std::string::npos && y != std::string::npos && x < y;
}
std::vector<std::string> SourceProblems(const Sources& s)
{
std::vector<std::string> problems;
// main.cpp: the handlers before SDL (the windowed start), and after
// the crash handlers' marker exists.
if (!Before(s.main, "report::PrepareCrashReports();", "quitsignals::Install();") ||
!Before(s.main, "quitsignals::Install();", "bool windowed = video::Initialize();"))
problems.push_back("main.cpp: quitsignals::Install() isn't between PrepareCrashReports and the window's video::Initialize");
// Any return from main() after the marker exists clears it.
const size_t prepared = s.main.find("report::PrepareCrashReports();");
const size_t guard = s.main.find("~ClearMarkerOnReturn() { report::ClearCrashMarker(); }");
const size_t firstReturnAfter = prepared == std::string::npos ? std::string::npos : s.main.find(" return ", prepared);
if (guard == std::string::npos || prepared == std::string::npos || guard < prepared || guard > firstReturnAfter)
problems.push_back("main.cpp: no marker-clearing guard between PrepareCrashReports and the first return after it");
// The window's quit path: the marker cleared after the shutdown, just
// before the log flush and the exit.
const size_t loop = s.main.find("while (video::RunFrame())");
if (loop == std::string::npos)
problems.push_back("main.cpp: no game loop");
else
{
const std::string tail = s.main.substr(loop, s.main.find("\n}", loop) - loop);
if (!Before(tail, "video::Shutdown();", "report::ClearCrashMarker();") ||
!Before(tail, "report::ClearCrashMarker();", "report::FlushLog();") ||
!Before(tail, "report::ClearCrashMarker();", "std::_Exit(0);") || tail.find("settings::Save()") == std::string::npos)
problems.push_back("main.cpp: the quit path doesn't save settings and clear the marker after the shutdown, before the exit");
}
// The launcher lifeline's exit and the headless one clear it too.
for (const char* head : { "launcher (pid %d) is gone: exiting", "quitting\\n\", quitsignals::Name(sig));" })
{
const size_t at = s.main.find(head);
if (at == std::string::npos || !Before(s.main, "report::ClearCrashMarker();", "std::_Exit(0);", at))
problems.push_back(std::string("main.cpp: the exit after \"") + head + "\" doesn't clear the marker first");
}
if (s.main.find("while (!s_guestReturned.load() && !quitsignals::Requested())") == std::string::npos)
problems.push_back("main.cpp: without a window, a quit request isn't waited for");
// presenter.cpp: RunFrame leaves on a request, before taking events.
const std::string run = Body(s.presenter, "bool RunFrame()");
const size_t request = run.find("if (quitsignals::Requested())");
if (request == std::string::npos || run.find("return false;", request) > run.find("while (SDL_PollEvent(&e))") ||
run.find("while (SDL_PollEvent(&e))") == std::string::npos)
problems.push_back("presenter.cpp: RunFrame doesn't leave on a quit request before its event loop");
// crash.cpp: a crash being reported keeps its report.
const std::string clear = Body(s.crash, "void ClearCrashMarker()");
if (!Before(clear, "if (s_crashing.load())", "marker::Clear("))
problems.push_back("crash.cpp: ClearCrashMarker doesn't leave a crash being reported alone");
const std::string notice = Body(s.crash, "void NoticePreviousCrash(bool lastClearsMarker)");
if (notice.find("marker::Ended::Crashed") == std::string::npos || notice.find("ended without quitting") == std::string::npos)
problems.push_back("crash.cpp: the next launch doesn't rename crash reports and log sessions that ended without quitting");
return problems;
}
std::string Replaced(std::string s, const std::string& from, const std::string& to)
{
const size_t at = s.find(from);
if (at == std::string::npos)
return {};
return s.replace(at, from.size(), to);
}
void Runtime(const fs::path& runtime)
{
printf("main.cpp, presenter.cpp, crash.cpp:\n");
const Sources src{ Read(runtime / "main.cpp"), Read(runtime / "video/presenter.cpp"), Read(runtime / "report/crash.cpp") };
CHECK(!src.main.empty() && !src.presenter.empty() && !src.crash.empty(), "can't read the sources in %s", runtime.c_str());
if (src.main.empty() || src.presenter.empty() || src.crash.empty())
return;
for (const std::string& p : SourceProblems(src))
CHECK(false, "%s", p.c_str());
struct Plant
{
const char* name;
std::string Sources::*file;
std::vector<std::pair<std::string, std::string>> edits; // each: the first `from` becomes `to`
};
const Plant plants[] = {
{ "v0.1.5's quit path (no marker cleared)", &Sources::main,
{ { " // A clean end: no crash marker left (a crash in the shutdown above still\n // reported one).\n"
" report::ClearCrashMarker();\n", "" } } },
{ "the marker cleared before the shutdown", &Sources::main,
{ { " gpu::renderer::Shutdown();\n video::Shutdown();\n",
" report::ClearCrashMarker();\n gpu::renderer::Shutdown();\n video::Shutdown();\n" } } },
{ "no handlers (SDL's, and SIGHUP's default)", &Sources::main, { { " quitsignals::Install();\n", "" } } },
{ "the handlers after SDL starts", &Sources::main,
{ { " quitsignals::Install();\n", "" },
{ " bool windowed = video::Initialize();\n", " bool windowed = video::Initialize();\n quitsignals::Install();\n" } } },
{ "no guard for returns", &Sources::main, { { "~ClearMarkerOnReturn() { report::ClearCrashMarker(); }", "~ClearMarkerOnReturn() {}" } } },
{ "the lifeline's exit keeps the marker", &Sources::main,
{ { " gpu::renderer::Shutdown();\n report::ClearCrashMarker();\n",
" gpu::renderer::Shutdown();\n" } } },
{ "headless: no wait for a request", &Sources::main,
{ { "while (!s_guestReturned.load() && !quitsignals::Requested())", "while (!s_guestReturned.load())" } } },
{ "RunFrame ignores the request", &Sources::presenter, { { " if (quitsignals::Requested())\n return false;\n", "" } } },
{ "a crash being reported cleared", &Sources::crash, { { " if (s_crashing.load())\n return;\n", "" } } },
};
for (const Plant& p : plants)
{
Sources mutated = src;
bool applied = true;
for (const auto& [from, to] : p.edits)
{
std::string next = Replaced(mutated.*p.file, from, to);
applied = applied && !next.empty();
if (!next.empty())
mutated.*p.file = next;
}
CHECK(applied, "plant \"%s\": its text isn't in the source", p.name);
if (!applied)
continue;
const bool caught = !SourceProblems(mutated).empty();
CHECK(caught, "plant \"%s\" not caught", p.name);
printf(" plant: %s: %s\n", p.name, caught ? "caught" : "NOT caught");
}
}
}
int main(int argc, char** argv)
{
Signals();
fs::path scratch = argc > 1 ? fs::path(argv[1]) : fs::temp_directory_path() / ("clean_exit_test." + std::to_string(getpid()));
Marker(scratch);
if (argc > 2)
Runtime(argv[2]);
else
printf("(the runtime folder not given: its checks skipped)\n");
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
return g_failures ? 1 : 0;
}
+171 -68
View File
@@ -1,6 +1,7 @@
// Tests for gpu::bars (runtime/gpu/edge_bars.h): which front-end quads are
// the police vid-cam's side bars or the intro's black frame, what they fill
// past the 16:9 band, the stretched copy's posScale, the band clip's rows,
// (a bar past the 16:9 band, a black frame everything outside it at every
// shape: GitHub #19), the stretched copy's posScale, the band clip's rows,
// and the whole plan the renderer asks for (PlanFill) on the draws as
// dumped. From the repo root:
// clang++ -std=c++20 -O2 -UNDEBUG -Iruntime tests/edge_bars_test.cpp -o build/edge_bars_test
@@ -325,8 +326,13 @@ static void BandOracle(const char* what, float sy, const float ps[4], float host
CHECK(wrong == 0, "%s: %d band rows differ from the pixel-centre oracle", what, wrong);
}
// The intro's black frame (the cut at ~95 s; ~57 s: kFrameY57), band units.
constexpr float kFrameX = 0.970f, kFrameY = 0.939f, kFrameY57 = 0.920f;
constexpr uint32_t kFrameColour = 0xFF000000;
// Under Vert+ the fills and the band clip's rows split the scissor's rows
// with no gap and no overlap: a bar's half and a black frame's whole width.
// with no gap and no overlap (a bar's half); and the black frame's fill and
// the frame's own pixels split the scissor's pixels the same way.
static void Complement(const char* what, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor)
{
const Rows band = BandRows(-sy, sy, ps[1], ps[3], hostH);
@@ -343,22 +349,25 @@ static void Complement(const char* what, float sy, const float ps[4], float host
fill = fill || (y >= r[i].y0 && y < r[i].y1);
wrong += band == fill;
}
// The black frame's fill, every column: each pixel of the scissor is in
// exactly one of the band's rows and the fill.
Rect k[2]{};
const uint32_t kn = BlackoutRects(ps, hostW, hostH, scissor, 1.0f, sy, k);
// The black frame's fill: each pixel of the scissor is in exactly one of
// the frame's own pixels (its box through the same Edge rule as the
// rasteriser's pixel centres) and the fill.
const Box frame{ -kFrameX, kFrameX, -kFrameY * sy, kFrameY * sy };
const Rect own = ClipToHost(frame.x0, frame.x1, frame.y0, frame.y1, ps, hostW, hostH);
Rect k[4]{};
const uint32_t kn = BlackoutRects(frame, ps, hostW, hostH, scissor, k);
int wrongPx = 0;
for (int32_t y = scissor.y0; y < scissor.y1; y++)
for (int32_t x = scissor.x0; x < scissor.x1; x++)
{
int in = (y >= top && y < bottom) ? 1 : 0;
int in = (x >= own.x0 && x < own.x1 && y >= own.y0 && y < own.y1) ? 1 : 0;
for (uint32_t i = 0; i < kn; i++)
in += (x >= k[i].x0 && x < k[i].x1 && y >= k[i].y0 && y < k[i].y1) ? 1 : 0;
wrongPx += in != 1;
}
printf("complement %s: band rows [%d,%d), %d row(s) wrong; black frame %d pixel(s) wrong\n", what, top, bottom, wrong, wrongPx);
CHECK(wrong == 0, "%s: %d rows both or neither in the band clip and the fill", what, wrong);
CHECK(wrongPx == 0, "%s: %d pixels both or neither in the band clip and the black frame's fill", what, wrongPx);
CHECK(wrongPx == 0, "%s: %d pixels both or neither in the black frame and its fill", what, wrongPx);
}
static void Cover(const char* what, float bx0, float bx1, float sx, float sy, const float ps[4])
@@ -379,10 +388,11 @@ static void Cover(const char* what, float bx0, float bx1, float sx, float sy, co
// ---- The intro's black frame (the cut between two shots, ~95 s) ----
// Its box in band units, as logged (NFSMW_LOG_FRONTEND "inset candidate",
// the Deck and 32:9): x +-0.970, y +-0.939, FF000000; and the one at the
// intro's start (~57 s): y +-0.920, FE000000.
constexpr float kFrameX = 0.970f, kFrameY = 0.939f, kFrameY57 = 0.920f;
constexpr uint32_t kFrameColour = 0xFF000000;
// the Deck and 32:9): x +-0.970, y +-0.939, FF000000 (kFrameX, kFrameY); and
// the one at the intro's start (~57 s): y +-0.920, FE000000 (kFrameY57). The
// Blacklist bio's panel (GitHub #19): x +-0.976, y +-0.985, FB000000 then
// FF000000, fading in from 11000000.
constexpr float kBioX = 0.976f, kBioY = 0.985f;
static void MatchFrame(const char* what, const Quad& q, float sx, float sy, bool want, const BlackoutRule& rule)
{
@@ -477,10 +487,11 @@ static void PlantedBlackout(const char* what, const BlackoutRule& wrong)
}
static void FillFrame(const char* what, float sx, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor,
uint32_t wantN, const Rect* want)
uint32_t wantN, const Rect* want, float fx = kFrameX, float fy = kFrameY)
{
Rect got[2]{};
const uint32_t n = BlackoutRects(ps, hostW, hostH, scissor, sx, sy, got);
Rect got[4]{};
const Box b{ -fx * sx, fx * sx, -fy * sy, fy * sy };
const uint32_t n = BlackoutRects(b, ps, hostW, hostH, scissor, got);
printf("%s: %u rect(s)", what, n);
for (uint32_t i = 0; i < n; i++)
printf(" [%d,%d)x[%d,%d)", got[i].x0, got[i].x1, got[i].y0, got[i].y1);
@@ -491,13 +502,50 @@ static void FillFrame(const char* what, float sx, float sy, const float ps[4], f
got[i].y1, want[i].x0, want[i].x1, want[i].y0, want[i].y1);
}
// The black frame's fill against pixel centres: a pixel of the scissor is
// filled exactly when its centre is outside the band (|y| > sy under Vert+,
// |x| > sx under Hor+); ties within 1/256 pixel skipped, as above.
static void FrameOracle(const char* what, float sx, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor)
// v0.1.1-v0.1.5's black frame fill, kept here as a planted fault: only what
// lies past the band (the rows above and below it under Vert+, the columns
// beside it under Hor+, nothing at 16:9), so the band's thin border round
// the frame showed what was behind (GitHub #19's Blacklist bio).
static uint32_t PastBandOnly(const Box&, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
Rect out[4])
{
Rect r[2]{};
const uint32_t n = BlackoutRects(ps, hostW, hostH, scissor, sx, sy, r);
uint32_t n = 0;
auto add = [&](const Rect& r) {
const Rect c = Intersect(r, scissor);
if (!Empty(c))
out[n++] = c;
};
const float e = kFillEdge;
if (scaleY < 1.0f)
{
add(ClipToHost(-e, e, scaleY, e, ps, hostW, hostH));
add(ClipToHost(-e, e, -e, -scaleY, ps, hostW, hostH));
}
else if (scaleX < 1.0f)
{
add(ClipToHost(scaleX, e, -e, e, ps, hostW, hostH));
add(ClipToHost(-e, -scaleX, -e, e, ps, hostW, hostH));
}
return n;
}
// The black frame's fill against pixel centres: a pixel of the scissor is
// filled, exactly once, when its centre lies outside the frame's box (past
// the band and in the band's border round it, at every shape, 16:9 too),
// never inside it; ties within 1/256 pixel skipped, as above. `rects` makes
// the rectangles (BlackoutRects, or a planted fault). Returns the wrong
// pixels.
using FrameRectsFn = uint32_t (*)(const Box&, const float*, float, float, const Rect&, float, float, Rect*);
static uint32_t RingRects(const Box& b, const float* ps, float w, float h, const Rect& sc, float, float, Rect* out)
{
return BlackoutRects(b, ps, w, h, sc, out);
}
static int FrameOracle(const char* what, float sx, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor,
FrameRectsFn rects = RingRects, bool print = true, float fx = kFrameX, float fy = kFrameY)
{
const Box b{ -fx * sx, fx * sx, -fy * sy, fy * sy };
Rect r[4]{};
const uint32_t n = rects(b, ps, hostW, hostH, scissor, sx, sy, r);
const float pxX = std::fabs(ps[0]) * hostW * 0.5f, pxY = std::fabs(ps[1]) * hostH * 0.5f;
auto tie = [](float d, float px) { return std::fabs(d) * px < 1.0f / 256.0f; };
int wrong = 0, ties = 0;
@@ -507,19 +555,24 @@ static void FrameOracle(const char* what, float sx, float sy, const float ps[4],
for (int32_t x = scissor.x0; x < scissor.x1; x++)
{
const float cx = ((float(x) + 0.5f) / hostW * 2.0f - 1.0f - ps[2]) / ps[0];
const bool want = sy < 1.0f ? std::fabs(cy) > sy : sx < 1.0f ? std::fabs(cx) > sx : false;
const bool onEdge = sy < 1.0f ? tie(std::fabs(cy) - sy, pxY) : sx < 1.0f && tie(std::fabs(cx) - sx, pxX);
bool got = false;
for (uint32_t i = 0; i < n; i++)
got = got || (x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1);
if (onEdge)
if (tie(cx - b.x0, pxX) || tie(cx - b.x1, pxX) || tie(cy - b.y0, pxY) || tie(cy - b.y1, pxY))
{
ties++;
else
wrong += got != want;
continue;
}
const bool want = !(cx > b.x0 && cx < b.x1 && cy > b.y0 && cy < b.y1);
int got = 0;
for (uint32_t i = 0; i < n; i++)
got += x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1;
wrong += got != (want ? 1 : 0);
}
}
printf("oracle black frame %s: %d pixel(s) wrong (%d on an edge, skipped)\n", what, wrong, ties);
CHECK(wrong == 0, "black frame %s: %d pixels differ from the pixel-centre oracle", what, wrong);
if (print)
{
printf("oracle black frame %s: %d pixel(s) wrong (%d on an edge, skipped)\n", what, wrong, ties);
CHECK(wrong == 0, "black frame %s: %d pixels differ from the pixel-centre oracle", what, wrong);
}
return wrong;
}
// ---- PlanFill on the draws as the renderer sees them ----
@@ -906,8 +959,8 @@ static void BackdropTests()
printf("planted %s: %d wrong pixel(s)\n", what, bad);
CHECK(bad > 0, "planted %s: no wrong pixel", what);
};
run("the whole strips (the black frame's fill)", [](const Box&, const float* ps, float w, float h, const Rect& sc, float sx, float sy, Rect* out) {
return BlackoutRects(ps, w, h, sc, sx, sy, out);
run("the whole strips (v0.1.5's black frame fill)", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy, Rect* out) {
return PastBandOnly(b, ps, w, h, sc, sx, sy, out);
});
run("past the picture only (no corners)", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy, Rect* out) {
Rect r[8]{};
@@ -1292,42 +1345,82 @@ int main()
printf("\nblack frame fill:\n");
{
const Rect deck[2] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 } };
FillFrame("Deck, 1x", 1.0f, 0.9f, ps, 1280, 2048, frame, 2, deck);
FillFrame("Deck, 1x, half-pixel", 1.0f, 0.9f, half, 1280, 2048, frame, 2, deck);
const Rect deck2[2] = { { 0, 0, 2560, 72 }, { 0, 1368, 2560, 1440 } };
FillFrame("Deck, 2x", 1.0f, 0.9f, ps, 2560, 4096, frame2, 2, deck2);
const Rect deck2h[2] = { { 0, 0, 2560, 73 }, { 0, 1369, 2560, 1440 } };
FillFrame("Deck, 2x, half-pixel", 1.0f, 0.9f, half, 2560, 4096, frame2, 2, deck2h);
const Rect r43[2] = { { 0, 0, 1280, 90 }, { 0, 630, 1280, 720 } };
FillFrame("4:3", 1.0f, 0.75f, ps, 1280, 2048, frame, 2, r43);
FillFrame("iPad Pro", 1.0f, 0.750366f, ps, 1280, 2048, frame, 2, r43);
const Rect r32[2] = { { 0, 0, 1280, 56 }, { 0, 664, 1280, 720 } };
FillFrame("3:2", 1.0f, 0.84375f, ps, 1280, 2048, frame, 2, r32);
const Rect t[1] = { { 0, 428, 1280, 464 } };
FillFrame("Deck, tile at y -256", 1.0f, 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 }, 1, t);
const Rect g[2] = { { 100, 10, 1180, 36 }, { 100, 684, 1180, 700 } };
// Everything outside the frame: the rows above and below it, then
// the columns beside it within its rows (v0.1.5: only past the band,
// the Deck's rows 0..35 and 684..719, nothing at 16:9).
const Rect deck[4] = { { 0, 0, 1280, 56 }, { 0, 664, 1280, 720 }, { 0, 56, 19, 664 }, { 1261, 56, 1280, 664 } };
FillFrame("Deck, 1x", 1.0f, 0.9f, ps, 1280, 2048, frame, 4, deck);
const Rect deckh[4] = { { 0, 0, 1280, 56 }, { 0, 665, 1280, 720 }, { 0, 56, 20, 665 }, { 1261, 56, 1280, 665 } };
FillFrame("Deck, 1x, half-pixel", 1.0f, 0.9f, half, 1280, 2048, frame, 4, deckh);
const Rect deck2[4] = { { 0, 0, 2560, 112 }, { 0, 1328, 2560, 1440 }, { 0, 112, 38, 1328 }, { 2522, 112, 2560, 1328 } };
FillFrame("Deck, 2x", 1.0f, 0.9f, ps, 2560, 4096, frame2, 4, deck2);
const Rect deck2h[4] = { { 0, 0, 2560, 113 }, { 0, 1329, 2560, 1440 }, { 0, 113, 39, 1329 }, { 2523, 113, 2560, 1329 } };
FillFrame("Deck, 2x, half-pixel", 1.0f, 0.9f, half, 2560, 4096, frame2, 4, deck2h);
const Rect r43[4] = { { 0, 0, 1280, 106 }, { 0, 614, 1280, 720 }, { 0, 106, 19, 614 }, { 1261, 106, 1280, 614 } };
FillFrame("4:3", 1.0f, 0.75f, ps, 1280, 2048, frame, 4, r43);
FillFrame("iPad Pro", 1.0f, 0.750366f, ps, 1280, 2048, frame, 4, r43);
const Rect r32[4] = { { 0, 0, 1280, 75 }, { 0, 645, 1280, 720 }, { 0, 75, 19, 645 }, { 1261, 75, 1280, 645 } };
FillFrame("3:2", 1.0f, 0.84375f, ps, 1280, 2048, frame, 4, r32);
const Rect t[3] = { { 0, 408, 1280, 464 }, { 0, 0, 19, 408 }, { 1261, 0, 1280, 408 } };
FillFrame("Deck, tile at y -256", 1.0f, 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 }, 3, t);
const Rect g[2] = { { 100, 10, 1180, 56 }, { 100, 664, 1180, 700 } };
FillFrame("Deck, game scissor", 1.0f, 0.9f, ps, 1280, 2048, Rect{ 100, 10, 1180, 700 }, 2, g);
const Rect fl[2] = { { 0, 684, 1280, 720 }, { 0, 0, 1280, 36 } };
FillFrame("Deck, y flipped", 1.0f, 0.9f, flipped, 1280, 2048, frame, 2, fl);
const Rect w329[2] = { { 1920, 0, 2560, 1440 }, { 0, 0, 640, 1440 } };
FillFrame("32:9, 2x", 0.5f, 1.0f, ps, 2560, 4096, frame2, 2, w329);
const Rect w329h[2] = { { 1921, 0, 2560, 1440 }, { 0, 0, 641, 1440 } };
FillFrame("32:9, 2x, half-pixel", 0.5f, 1.0f, half, 2560, 4096, frame2, 2, w329h);
const Rect uw[2] = { { 1120, 0, 1280, 720 }, { 0, 0, 160, 720 } };
FillFrame("21:9", 0.75f, 1.0f, ps, 1280, 2048, frame, 2, uw);
const Rect u3440[2] = { { 1116, 0, 1280, 720 }, { 0, 0, 164, 720 } };
FillFrame("3440x1440", 0.744186f, 1.0f, ps, 1280, 2048, frame, 2, u3440);
FillFrame("16:9", 1.0f, 1.0f, ps, 1280, 2048, frame, 0, nullptr);
const Rect fl[4] = { { 0, 664, 1280, 720 }, { 0, 0, 1280, 56 }, { 0, 56, 19, 664 }, { 1261, 56, 1280, 664 } };
FillFrame("Deck, y flipped", 1.0f, 0.9f, flipped, 1280, 2048, frame, 4, fl);
const Rect w329[4] = { { 0, 0, 2560, 44 }, { 0, 1396, 2560, 1440 }, { 0, 44, 659, 1396 }, { 1901, 44, 2560, 1396 } };
FillFrame("32:9, 2x", 0.5f, 1.0f, ps, 2560, 4096, frame2, 4, w329);
const Rect w329h[4] = { { 0, 0, 2560, 45 }, { 0, 1397, 2560, 1440 }, { 0, 45, 660, 1397 }, { 1902, 45, 2560, 1397 } };
FillFrame("32:9, 2x, half-pixel", 0.5f, 1.0f, half, 2560, 4096, frame2, 4, w329h);
const Rect uw[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 174, 698 }, { 1106, 22, 1280, 698 } };
FillFrame("21:9", 0.75f, 1.0f, ps, 1280, 2048, frame, 4, uw);
const Rect u3440[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 178, 698 }, { 1102, 22, 1280, 698 } };
FillFrame("3440x1440", 0.744186f, 1.0f, ps, 1280, 2048, frame, 4, u3440);
const Rect r169[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 19, 698 }, { 1261, 22, 1280, 698 } };
FillFrame("16:9", 1.0f, 1.0f, ps, 1280, 2048, frame, 4, r169);
const Rect r169h[4] = { { 0, 0, 2560, 45 }, { 0, 1397, 2560, 1440 }, { 0, 45, 39, 1397 }, { 2523, 45, 2560, 1397 } };
FillFrame("16:9, 2x, half-pixel", 1.0f, 1.0f, half, 2560, 4096, frame2, 4, r169h);
}
FrameOracle("Deck 1x", 1.0f, 0.9f, ps, 1280, 2048, frame);
FrameOracle("Deck 3x, half-pixel", 1.0f, 0.9f, half, 3840, 6144, frame3);
FrameOracle("4:3 2x, y flipped, half-pixel", 1.0f, 0.75f, flippedHalf, 2560, 4096, frame2);
FrameOracle("Deck, tile", 1.0f, 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 });
FrameOracle("Deck, game scissor", 1.0f, 0.9f, ps, 1280, 2048, Rect{ 100, 10, 1180, 700 });
FrameOracle("32:9 2x, half-pixel", 0.5f, 1.0f, half, 2560, 4096, frame2);
FrameOracle("21:9 1x", 0.75f, 1.0f, ps, 1280, 2048, frame);
FrameOracle("3440x1440 2x", 0.744186f, 1.0f, ps, 2560, 4096, frame2);
FrameOracle("16:9", 1.0f, 1.0f, ps, 1280, 2048, frame);
FrameOracle("16:9 2x, half-pixel", 1.0f, 1.0f, half, 2560, 4096, frame2);
FrameOracle("~57 s frame, Deck", 1.0f, 0.9f, ps, 1280, 2048, frame, RingRects, true, kFrameX, kFrameY57);
FrameOracle("Blacklist bio panel, 16:9", 1.0f, 1.0f, ps, 1280, 2048, frame, RingRects, true, kBioX, kBioY);
FrameOracle("Blacklist bio panel, Deck 2x, half-pixel", 1.0f, 0.9f, half, 2560, 4096, frame2, RingRects, true, kBioX, kBioY);
// Planted: the oracle catches v0.1.5's fill (past the band only) at
// every shape, 16:9 included, and a ring that leaves the frame's own
// rows' sides out.
{
struct Shape2
{
const char* name;
float sx, sy;
};
const Shape2 shapes[] = { { "Deck", 1.0f, 0.9f }, { "4:3", 1.0f, 0.75f }, { "16:9", 1.0f, 1.0f }, { "21:9", 0.75f, 1.0f }, { "32:9", 0.5f, 1.0f } };
int failing = 0;
for (const Shape2& sh : shapes)
failing += FrameOracle(sh.name, sh.sx, sh.sy, ps, 1280, 2048, frame, PastBandOnly, false) > 0;
printf("planted black frame fill 'past the band only (v0.1.5)': %d of %zu shapes fail\n", failing, std::size(shapes));
CHECK(failing == int(std::size(shapes)), "planted 'past the band only': %d of %zu shapes fail", failing, std::size(shapes));
const int rowsOnly = FrameOracle("rows only", 1.0f, 0.9f, ps, 1280, 2048, frame,
[](const Box& b, const float* q, float w, float h, const Rect& sc, float, float, Rect* out) {
Rect r[4]{};
const uint32_t n = BlackoutRects(b, q, w, h, sc, r);
uint32_t m = 0;
for (uint32_t i = 0; i < n && i < 2; i++)
out[m++] = r[i];
return m;
},
false);
printf("planted black frame fill 'rows above and below only': %d wrong pixel(s)\n", rowsOnly);
CHECK(rowsOnly > 0, "planted 'rows above and below only' passed");
}
Cover("black frame, Deck", -kFrameX, kFrameX, 1.0f, 0.9f, ps);
Cover("black frame, 32:9, half-pixel", -kFrameX, kFrameX, 0.5f, 1.0f, half);
@@ -1342,8 +1435,14 @@ int main()
const Rect right[2] = { { 640, 0, 1280, 36 }, { 640, 684, 1280, 720 } }, left[2] = { { 0, 0, 640, 36 }, { 0, 684, 640, 720 } };
PlanCase("Deck right bar", d, rightDeck, Fill::RightBar, 2, right);
PlanCase("Deck left bar", d, leftDeck, Fill::LeftBar, 2, left);
const Rect black[2] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 } };
PlanCase("Deck black frame", d, frameDeck, Fill::Blackout, 2, black);
const Rect black[4] = { { 0, 0, 1280, 56 }, { 0, 664, 1280, 720 }, { 0, 56, 19, 664 }, { 1261, 56, 1280, 664 } };
PlanCase("Deck black frame", d, frameDeck, Fill::Blackout, 4, black);
// The Blacklist bio's panel (GitHub #19), as logged on the Deck:
// "black frame x -0.976..0.976 y -0.887..0.887 colour FB000000".
const Rect bio[4] = { { 0, 0, 1280, 41 }, { 0, 679, 1280, 720 }, { 0, 41, 15, 679 }, { 1265, 41, 1280, 679 } };
PlanCase("Deck Blacklist bio panel", d, Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, 1.0f, 0.9f, 0xFB000000), deck), Fill::Blackout, 4, bio);
// ... fading in (11000000): left as it is, as before.
PlanCase("Deck Blacklist bio panel fading in", d, Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, 1.0f, 0.9f, 0x11000000), deck), Fill::None);
{
const Plan p = RunPlan(d, rightDeck);
CHECK(std::fabs(p.box.x0 - 0.651f) < 1e-5f && std::fabs(p.box.x1 - 1.406f) < 1e-5f && std::fabs(p.box.y0 + 1.024f * 0.9f) < 1e-5f &&
@@ -1357,22 +1456,26 @@ int main()
const EPolys right329 = Polys(Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 0.5f, 1.0f, 0xFD000000), w329);
const EPolys frame329 = Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 0.5f, 1.0f, kFrameColour), w329);
const Draw d329 = S2Draw(w329, 0.5f, 1.0f, ps, 2560, 4096, frame2);
const Rect r329[1] = { { 1920, 0, 2560, 1440 } }, f329[2] = { { 1920, 0, 2560, 1440 }, { 0, 0, 640, 1440 } };
const Rect r329[1] = { { 1920, 0, 2560, 1440 } };
const Rect f329[4] = { { 0, 0, 2560, 44 }, { 0, 1396, 2560, 1440 }, { 0, 44, 659, 1396 }, { 1901, 44, 2560, 1396 } };
PlanCase("32:9 right bar", d329, right329, Fill::RightBar, 1, r329);
PlanCase("32:9 black frame", d329, frame329, Fill::Blackout, 2, f329);
PlanCase("32:9 black frame", d329, frame329, Fill::Blackout, 4, f329);
// 21:9: the bars reach the screen edge (nothing to fill), the black
// frame does not.
const Matrix uw = FrontEndMatrix(0.75f, 1.0f);
const Draw duw = S2Draw(uw, 0.75f, 1.0f, ps);
PlanCase("21:9 right bar", duw, Polys(Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 0.75f, 1.0f, 0xFD000000), uw), Fill::RightBar, 0, nullptr);
const Rect fuw[2] = { { 1120, 0, 1280, 720 }, { 0, 0, 160, 720 } };
PlanCase("21:9 black frame", duw, Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 0.75f, 1.0f, kFrameColour), uw), Fill::Blackout, 2,
const Rect fuw[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 174, 698 }, { 1106, 22, 1280, 698 } };
PlanCase("21:9 black frame", duw, Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 0.75f, 1.0f, kFrameColour), uw), Fill::Blackout, 4,
fuw);
// 16:9 never gets here (the renderer's block needs a view scale
// below 1), and a plan there fills nothing.
// 16:9 (the renderer asks PlanFill there too): the thin border round
// the frame; a vid-cam bar there fills nothing.
const Matrix m169 = FrontEndMatrix(1.0f, 1.0f);
const Rect f169[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 19, 698 }, { 1261, 22, 1280, 698 } };
PlanCase("16:9 black frame", S2Draw(m169, 1.0f, 1.0f, ps),
Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 1.0f, 1.0f, kFrameColour), m169), Fill::Blackout, 0, nullptr);
Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 1.0f, 1.0f, kFrameColour), m169), Fill::Blackout, 4, f169);
PlanCase("16:9 right bar", S2Draw(m169, 1.0f, 1.0f, ps), Polys(Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 1.0f, 1.0f, 0xFD000000), m169),
Fill::RightBar, 0, nullptr);
// A ppc/ from before the Vert+ hooks reports no front-end y scale
// (0): Hor+ still fills (c5 the unnarrowed 0.92), a narrowed view
// can't happen there and is refused.
+753
View File
@@ -0,0 +1,753 @@
// Tests for GitHub #19, "rectangular borders in specific places in menus"
// (gpu/edge_bars.h, and gpu/renderer.cpp using it), at every screen shape:
// - the Showcase (the car lot's, customize shop's and Blacklist rival cars'
// "Rotate / Zoom" viewer) darkens the 3D garage with one front-end quad,
// centred, x +-0.970 and y +-0.958 of the 16:9 band, its 256x256 texture
// clear in the middle and dark toward its edges (MatchVignette,
// TexelOffset32, PlanVignette): it is drawn stretched to the screen's
// edges;
// - the Blacklist rival bio (SafeHouseRivalBio.fng) sets its text and
// picture on a black panel, x +-0.976 and y +-0.985 (a black frame,
// PlanFill): its copies now fill everything outside it, the band's thin
// border too, at 16:9 as well (v0.1.5: only past the band).
// Both stopped short of every screen edge: a thin border of what was behind
// at 16:9, as on a real 360's own output (a period TV's overscan hid it);
// under Vert+ the Showcase's garage above and below the band showed
// undimmed too, a bright rectangular frame.
// From the repo root:
// clang++ -std=c++20 -O2 -UNDEBUG -Iruntime tests/menu_borders_test.cpp -o build/menu_borders_test
// build/menu_borders_test runtime/gpu/renderer.cpp
// Planted faults: the match cases run again under deliberately wrong
// thresholds (each must fail a case), the texel and register checks are
// broken one at a time (each must refuse the plan), the pixel oracles run on
// wrong fills (each must fail), and the renderer checks run on copies of
// renderer.cpp with the wiring cut (each must fail).
#include <gpu/edge_bars.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <iterator>
#include <utility>
#include <sstream>
#include <string>
#include <vector>
using namespace gpu::bars;
static int g_failures = 0;
static bool g_quiet = false;
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; if (!g_quiet) { printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } } while (0)
// The screen shapes (view scale x, y), as video/picture_fit gives them, and
// the host size each is drawn at (Auto internal resolution: 2x on the big
// ones; posScale covers it).
struct Shape
{
const char* name;
float sx, sy;
};
static const Shape kShapes[] = {
{ "1280x720 (16:9)", 1.0f, 1.0f },
{ "1280x800 (Deck)", 1.0f, 0.9f },
{ "3024x1898 (MacBook Pro 14)", 1.0f, 0.896207f },
{ "3600x2338 less the notch (the report's MacBook, 3600x2260)", 1.0f, 0.896f },
{ "1200x800 (3:2)", 1.0f, 0.84375f },
{ "1024x768 (4:3)", 1.0f, 0.75f },
{ "2732x2048 (iPad Pro)", 1.0f, 0.750366f },
{ "2868x1320 (iPhone 18 Pro Max)", 0.818182f, 1.0f },
{ "2560x1080 (21:9)", 0.75f, 1.0f },
{ "3440x1440", 0.744186f, 1.0f },
{ "2880x810 (32:9)", 0.5f, 1.0f },
};
// The vignette as logged (NFSMW_LOG_FRONTEND, "inset candidate": band x
// -0.970..0.970 y -0.958..0.958, t0 14749000 256x256 f6, blend 07060706,
// colour 047F7F7F fading in to FE7F7F7F) at every shape.
constexpr float kVigX = 0.970f, kVigY = 0.958f;
constexpr uint32_t kVigColour = 0xFE7F7F7F;
struct Quad
{
Vertex v[4];
};
static Quad Rectangle(float bx0, float bx1, float by0, float by1, float sx, float sy, uint32_t argb)
{
const float x0 = bx0 * sx, x1 = bx1 * sx, y0 = by0 * sy, y1 = by1 * sy;
return { { { x0, y0, argb }, { x1, y0, argb }, { x1, y1, argb }, { x0, y1, argb } } };
}
// ---- The shape ----
static void Match(const char* what, const Quad& q, float sx, float sy, bool want, const VignetteRule& rule)
{
Box box{};
const bool got = MatchVignette(q.v, sx, sy, rule, &box);
CHECK(got == want, "%s at x %.4f y %.4f: %s, want %s", what, sx, sy, got ? "vignette" : "none", want ? "vignette" : "none");
if (got && want)
{
const float x0 = std::min(q.v[0].x, q.v[2].x), x1 = std::max(q.v[0].x, q.v[2].x);
const float y0 = std::min(q.v[0].y, q.v[2].y), y1 = std::max(q.v[0].y, q.v[2].y);
CHECK(box.x0 == x0 && box.x1 == x1 && box.y0 == y0 && box.y1 == y1, "%s: box %.4f..%.4f %.4f..%.4f", what, box.x0, box.x1, box.y0,
box.y1);
}
}
// Every shape case. Returns the failures it added (for the planted rules).
static int VignetteCases(const VignetteRule& rule)
{
const int before = g_failures;
for (const Shape& s : kShapes)
{
auto at = [&](float x0, float x1, float y0, float y1, uint32_t argb) { return Rectangle(x0, x1, y0, y1, s.sx, s.sy, argb); };
Match("Showcase vignette", at(-kVigX, kVigX, -kVigY, kVigY, kVigColour), s.sx, s.sy, true, rule);
Match("Showcase vignette fading in (alpha 04)", at(-kVigX, kVigX, -kVigY, kVigY, 0x047F7F7F), s.sx, s.sy, true, rule);
Match("an overlay 0.92 of the band", at(-0.92f, 0.92f, -0.92f, 0.92f, kVigColour), s.sx, s.sy, true, rule);
Match("an overlay 0.985 of the band", at(-0.985f, 0.985f, -0.985f, 0.985f, kVigColour), s.sx, s.sy, true, rule);
// Its vertices in another order around it (y flipped).
Quad flip = at(-kVigX, kVigX, kVigY, -kVigY, kVigColour);
Match("Showcase vignette, y flipped", flip, s.sx, s.sy, true, rule);
// What must keep its own shape (or isn't this overlay).
Match("title key art / results picture", at(-1.104f, 1.104f, -0.981f, 0.981f, 0xFF7F7F7F), s.sx, s.sy, false, rule);
Match("a picture over the whole band", at(-1.0f, 1.0f, -1.0f, 1.0f, 0xFF808080), s.sx, s.sy, false, rule);
Match("an overlay 0.995 of the band", at(-0.995f, 0.995f, -0.995f, 0.995f, kVigColour), s.sx, s.sy, false, rule);
Match("an overlay 0.88 of the band", at(-0.88f, 0.88f, -0.88f, 0.88f, kVigColour), s.sx, s.sy, false, rule);
Match("vignette width, half the height", at(-kVigX, kVigX, -0.5f, 0.5f, kVigColour), s.sx, s.sy, false, rule);
Match("vignette height, the whole band's width", at(-1.0f, 1.0f, -kVigY, kVigY, kVigColour), s.sx, s.sy, false, rule);
Match("fade", at(-1.29f, 1.29f, -1.30f, 1.30f, 0xFF000000), s.sx, s.sy, false, rule);
Match("vid cam noise overlay", at(-0.692f, 0.709f, -1.058f, 1.050f, 0x3C727272), s.sx, s.sy, false, rule);
Match("vid cam dark overlay", at(-0.712f, 0.668f, -0.893f, 0.908f, 0x3C000000), s.sx, s.sy, false, rule);
Match("pause dim centre", at(-0.53f, 0.57f, -1.91f, 1.91f, 0xC8000000), s.sx, s.sy, false, rule);
Match("cam dialog box", at(-0.578f, 0.586f, -0.167f, 0.569f, 0xFD000000), s.sx, s.sy, false, rule);
Match("vignette off-centre by 0.03 in x", at(-kVigX + 0.03f, kVigX + 0.03f, -kVigY, kVigY, kVigColour), s.sx, s.sy, false, rule);
Match("vignette off-centre by 0.03 in y", at(-kVigX, kVigX, -kVigY + 0.03f, kVigY + 0.03f, kVigColour), s.sx, s.sy, false, rule);
Quad grad = at(-kVigX, kVigX, -kVigY, kVigY, kVigColour);
grad.v[1].argb = grad.v[2].argb = 0x007F7F7F; // a gradient, not one colour
Match("a gradient of the vignette's shape", grad, s.sx, s.sy, false, rule);
Quad bent = at(-kVigX, kVigX, -kVigY, kVigY, kVigColour);
bent.v[2].x -= 0.05f;
Match("a bent quad", bent, s.sx, s.sy, false, rule);
}
return g_failures - before;
}
static void Planted(const char* what, const VignetteRule& wrong)
{
const int saved = g_failures;
g_quiet = true;
const int failed = VignetteCases(wrong);
g_quiet = false;
g_failures = saved;
printf("planted %s: %d case(s) failed\n", what, failed);
CHECK(failed > 0, "planted %s passed every vignette case", what);
}
// ---- Texel addressing ----
static void TexelTests()
{
// Reference offsets: gpu/renderer.cpp's GLSL tiled2D, as a separate
// Python port computed them (that port untiled the vignette's texture
// dumped from guest memory into the clear-middle picture).
struct Ref
{
uint32_t x, y, pitch, offset;
};
const Ref refs[] = { { 0, 0, 256, 0x0u }, { 1, 0, 256, 0x4u }, { 0, 1, 256, 0x10u }, { 7, 0, 256, 0x2Cu }, { 8, 0, 256, 0x40u },
{ 0, 8, 256, 0x480u }, { 0, 16, 256, 0x800u }, { 31, 31, 256, 0xF7Cu }, { 32, 0, 256, 0x1000u }, { 0, 32, 256, 0x8000u },
{ 128, 128, 256, 0x24000u }, { 255, 255, 256, 0x3FF7Cu }, { 5, 3, 256, 0x134u }, { 100, 200, 256, 0x334A0u },
{ 13, 77, 128, 0x86F4u } };
for (const Ref& r : refs)
CHECK(TexelOffset32(r.x, r.y, r.pitch, true) == r.offset, "tiled (%u,%u) pitch %u: %X, want %X", r.x, r.y, r.pitch,
TexelOffset32(r.x, r.y, r.pitch, true), r.offset);
CHECK(TexelOffset32(5, 3, 256, false) == (3u * 256u + 5u) * 4u, "linear (5,3) pitch 256");
// A 256x256 tiled texture: every texel at its own word, all of them
// within the 256 KB.
std::vector<uint8_t> seen(256 * 256, 0);
int bad = 0;
for (uint32_t y = 0; y < 256; y++)
for (uint32_t x = 0; x < 256; x++)
{
const uint32_t o = TexelOffset32(x, y, 256, true);
if (o % 4 || o / 4 >= seen.size() || seen[o / 4]++)
bad++;
}
CHECK(bad == 0, "tiled 256x256: %d texel(s) outside, unaligned or on another's word", bad);
printf("texel addressing: %zu reference offsets, 65536 texels each on its own word\n", std::size(refs));
}
// ---- The plan on the draw as the renderer sees it ----
// The front end's matrix at a shape (edge_bars_test's): c0 0.69 x scaleX,
// c5 0.92 x scaleY, the rest 0.
struct Matrix
{
float c[8];
};
static Matrix FrontEndMatrix(float sx, float sy)
{
return { { 0.69f * sx, 0.0f, 0.0f, 0.0f, 0.0f, 0.92f * sy, 0.0f, 0.0f } };
}
struct EPolys
{
uint32_t w[4][4];
};
static EPolys Polys(const Quad& q, const Matrix& m)
{
EPolys e{};
for (int k = 0; k < 4; k++)
{
const float p[3] = { q.v[k].x / m.c[0], q.v[k].y / m.c[5], 0.0f };
std::memcpy(e.w[k], p, sizeof(p));
e.w[k][3] = q.v[k].argb;
}
return e;
}
// The front end's posScale (renderer.cpp: 2 sx / w, 2 sy / h, 2 ox / w - 1,
// 2 oy / h - 1): viewport s(640,-360) o(640,360) in the 1280x2048 target, as
// edge_bars_test's; `ox`, `oy` (D3D's half-pixel centres: 640.5, 360.5) and
// `vy` (flipped: +360) move it. The screen is the target's top 720 rows.
static void FrontEndPs(float ps[4], float ox = 640.0f, float oy = 360.0f, float vy = -360.0f)
{
ps[0] = 2.0f * 640.0f / 1280.0f;
ps[1] = 2.0f * vy / 2048.0f;
ps[2] = 2.0f * ox / 1280.0f - 1.0f;
ps[3] = 2.0f * oy / 2048.0f - 1.0f;
}
// The vignette's draw as dumped: blend 07060706, t0 256x256 format 6.
static Draw ShowcaseDraw(const Matrix& m, float sx, float sy, const float ps[4], float hostW, float hostH)
{
return Draw{ .prim = 13,
.count = 4,
.c = m.c,
.frontEndY = std::fabs(m.c[5]),
.scaleX = sx,
.scaleY = sy,
.frontEndShader = true,
.leftAlone = true,
.occlusion = false,
.vte = 0x43F,
.blend0 = 0x07060706,
.t0Sampled = true,
.t0Width = 256,
.t0Height = 256,
.t0Format = 6,
.posScale = { ps[0], ps[1], ps[2], ps[3] },
.hostW = hostW,
.hostH = hostH,
.scissor = { 0, 0, int32_t(hostW), int32_t(hostH) } };
}
// A 256x256 tiled k_8_8_8_8 texture in guest byte order: the vignette's
// (black, alpha 0 inside an ellipse, rising to the edges; its dumped texture
// is all 00000000 in the middle) or a picture's (opaque everywhere).
struct Texture
{
uint32_t w = 256, h = 256;
std::vector<uint32_t> words = std::vector<uint32_t>(256 * 256, 0);
void Set(uint32_t x, uint32_t y, uint32_t v) { words[TexelOffset32(x, y, w, true) / 4] = v; }
uint32_t Get(uint32_t x, uint32_t y) const { return words[TexelOffset32(x, y, w, true) / 4]; }
};
static Texture VignetteTexture()
{
Texture t;
for (uint32_t y = 0; y < t.h; y++)
for (uint32_t x = 0; x < t.w; x++)
{
const float u = (float(x) + 0.5f) / t.w * 2.0f - 1.0f, v = (float(y) + 0.5f) / t.h * 2.0f - 1.0f;
const float r = std::sqrt(u * u * 1.1f + v * v);
const uint32_t a = r < 0.8f ? 0u : uint32_t(std::min(255.0f, (r - 0.8f) * 600.0f));
t.Set(x, y, a); // A in byte 0 as stored (big-endian ARGB word), RGB 0
}
return t;
}
static Texture PictureTexture()
{
Texture t;
for (uint32_t y = 0; y < t.h; y++)
for (uint32_t x = 0; x < t.w; x++)
t.Set(x, y, 0xFF000000u | (x << 16) | (y << 8) | 0x40u);
return t;
}
static Plan RunPlan(const Draw& d, const EPolys& e, const Texture& t, uint32_t vertexLimit = 4, bool texelsReadable = true)
{
return PlanVignette(
d,
[&](uint32_t k, uint32_t j, uint32_t& word) {
word = e.w[k & 3][j & 3];
return k < vertexLimit;
},
[&](uint32_t x, uint32_t y, uint32_t& word) {
if (!texelsReadable || x >= t.w || y >= t.h)
return false;
word = t.Get(x, y);
return true;
});
}
static const char* Name(Fill f)
{
return f == Fill::Vignette ? "vignette" : f == Fill::None ? "none" : "another fill";
}
// The stretched quad against pixel centres: every host pixel of the screen
// (the target's top `screenH` rows) has its centre inside the quad as the
// new posScale draws it, and the box lands at +-kFillEdge of the viewport
// (clip space through the new posScale = the old one's at +-kFillEdge).
static void CoverOracle(const char* what, const Plan& p, const float ps[4], float hostW, float hostH, float screenH)
{
const float* q = p.posScale;
// The box's corners through the new posScale, and clip +-kFillEdge
// through the draw's own: the same NDC.
const float nx0 = p.box.x0 * q[0] + q[2], nx1 = p.box.x1 * q[0] + q[2];
const float ny0 = p.box.y0 * q[1] + q[3], ny1 = p.box.y1 * q[1] + q[3];
const float ex0 = -kFillEdge * ps[0] + ps[2], ex1 = kFillEdge * ps[0] + ps[2];
const float ey0 = -kFillEdge * ps[1] + ps[3], ey1 = kFillEdge * ps[1] + ps[3];
CHECK(std::fabs(nx0 - ex0) < 1e-4f && std::fabs(nx1 - ex1) < 1e-4f && std::fabs(ny0 - ey0) < 1e-4f && std::fabs(ny1 - ey1) < 1e-4f,
"%s: the box lands at NDC x %.5f..%.5f y %.5f..%.5f, want x %.5f..%.5f y %.5f..%.5f", what, nx0, nx1, ny0, ny1, ex0, ex1, ey0, ey1);
const float lx = std::min(nx0, nx1), hx = std::max(nx0, nx1), ly = std::min(ny0, ny1), hy = std::max(ny0, ny1);
int outside = 0;
for (int32_t y = 0; y < int32_t(screenH); y++)
for (int32_t x = 0; x < int32_t(hostW); x++)
{
const float ndcX = (float(x) + 0.5f) / hostW * 2.0f - 1.0f, ndcY = (float(y) + 0.5f) / hostH * 2.0f - 1.0f;
outside += !(ndcX > lx && ndcX < hx && ndcY > ly && ndcY < hy);
}
CHECK(outside == 0, "%s: %d host pixel(s) left outside the stretched vignette", what, outside);
}
static void PlanTests()
{
const Texture vignette = VignetteTexture(), picture = PictureTexture();
struct Host
{
const char* name;
float scale, ox, oy;
};
// 1x, and 2x with D3D's half-pixel centres (as edge_bars_test's: the
// viewport offset 640.5, 360.5).
const Host hosts[] = { { "1x", 1.0f, 640.0f, 360.0f }, { "2x half-pixel", 2.0f, 640.5f, 360.5f } };
int planned = 0;
for (const Shape& s : kShapes)
for (const Host& h : hosts)
for (int flipped = 0; flipped < 2; flipped++)
{
float ps[4];
// Flipped: the viewport's y scale positive, its offset the
// band's far edge (the screen rows stay 0..720).
FrontEndPs(ps, h.ox, flipped ? 360.0f - (h.oy - 360.0f) : h.oy, flipped ? 360.0f : -360.0f);
const float hostW = 1280.0f * h.scale, hostH = 2048.0f * h.scale, screenH = 720.0f * h.scale;
const Matrix m = FrontEndMatrix(s.sx, s.sy);
const Quad q = Rectangle(-kVigX, kVigX, -kVigY, kVigY, s.sx, s.sy, kVigColour);
const EPolys e = Polys(q, m);
const Draw d = ShowcaseDraw(m, s.sx, s.sy, ps, hostW, hostH);
const Plan p = RunPlan(d, e, vignette);
char what[160];
snprintf(what, sizeof(what), "%s, %s%s", s.name, h.name, flipped ? ", y flipped" : "");
CHECK(p.fill == Fill::Vignette, "%s: %s, want vignette", what, Name(p.fill));
if (p.fill != Fill::Vignette)
continue;
CHECK(p.rectCount == 0, "%s: %u rects, want none (the draw itself is stretched)", what, p.rectCount);
CHECK(p.argb == kVigColour, "%s: colour %08X", what, p.argb);
float want[4];
CoverFrame(p.box, d.posScale, want);
CHECK(std::memcmp(want, p.posScale, sizeof(want)) == 0, "%s: posScale %.5f %.5f %.5f %.5f, want CoverFrame's", what, p.posScale[0],
p.posScale[1], p.posScale[2], p.posScale[3]);
CoverOracle(what, p, ps, hostW, hostH, screenH);
planned++;
}
printf("plan: the Showcase's draw at %zu shapes x 1x/2x x y-flipped: %d stretched, every host pixel covered\n", std::size(kShapes), planned);
// Planted: the oracle catches a vignette left as the game draws it, and
// one stretched only to clip +-1 at 2x (D3D's half-pixel centres: the
// outermost column's centre lies past it).
{
const Shape deck = kShapes[1];
float ps[4];
FrontEndPs(ps, 640.5f, 360.5f);
const Matrix m = FrontEndMatrix(deck.sx, deck.sy);
const Draw d = ShowcaseDraw(m, deck.sx, deck.sy, ps, 2560.0f, 4096.0f);
const Plan good = RunPlan(d, Polys(Rectangle(-kVigX, kVigX, -kVigY, kVigY, deck.sx, deck.sy, kVigColour), m), vignette);
Plan unstretched = good, toOne = good;
std::memcpy(unstretched.posScale, d.posScale, sizeof(unstretched.posScale));
CoverFrame(good.box, d.posScale, toOne.posScale, 1.0f);
for (const auto& [what, plan] : { std::pair{ "left as the game draws it", unstretched }, std::pair{ "stretched to clip +-1 only", toOne } })
{
const int saved = g_failures;
g_quiet = true;
CoverOracle(what, plan, ps, 2560.0f, 4096.0f, 1440.0f);
g_quiet = false;
const int failed = g_failures - saved;
g_failures = saved;
printf("planted oracle '%s' (Deck, 2x): %d check(s) failed\n", what, failed);
CHECK(failed > 0, "planted oracle '%s' passed", what);
}
}
// A ppc/ without the Vert+ hooks (front-end y scale 0) at 16:9.
{
float ps[4];
FrontEndPs(ps);
const Matrix m = FrontEndMatrix(1.0f, 1.0f);
Draw d = ShowcaseDraw(m, 1.0f, 1.0f, ps, 1280.0f, 2048.0f);
d.frontEndY = 0.0f;
const Plan p = RunPlan(d, Polys(Rectangle(-kVigX, kVigX, -kVigY, kVigY, 1.0f, 1.0f, kVigColour), m), vignette);
CHECK(p.fill == Fill::Vignette, "16:9 with an old ppc/: %s", Name(p.fill));
}
// What must be left as it is: each breaks one condition of the
// vignette's draw (planted register and texel faults).
float ps[4];
FrontEndPs(ps);
const Shape deck = kShapes[1];
const Matrix m = FrontEndMatrix(deck.sx, deck.sy);
const EPolys e = Polys(Rectangle(-kVigX, kVigX, -kVigY, kVigY, deck.sx, deck.sy, kVigColour), m);
const Draw base = ShowcaseDraw(m, deck.sx, deck.sy, ps, 1280.0f, 2048.0f);
struct Case
{
const char* what;
void (*change)(Draw&);
};
const Case refuse[] = {
{ "opaque, no blending (00010001)", [](Draw& d) { d.blend0 = 0x00010001; } },
{ "additive blending", [](Draw& d) { d.blend0 = 0x07010701; } },
{ "the 32x32 white texture", [](Draw& d) { d.t0Width = d.t0Height = 32; } },
{ "a 64x256 texture", [](Draw& d) { d.t0Width = 64; } },
{ "a DXT1 texture (format 18)", [](Draw& d) { d.t0Format = 18; } },
{ "no texture sampled", [](Draw& d) { d.t0Sampled = false; } },
{ "inside an occlusion query", [](Draw& d) { d.occlusion = true; } },
{ "widened, covered or clipped by the other rules", [](Draw& d) { d.leftAlone = false; } },
{ "not the front-end shader", [](Draw& d) { d.frontEndShader = false; } },
{ "two quads", [](Draw& d) { d.count = 8; } },
{ "a triangle list", [](Draw& d) { d.prim = 4; } },
{ "no viewport transform", [](Draw& d) { d.vte = 0x300; } },
{ "c5 not the front end's y scale", [](Draw& d) { d.frontEndY = 0.5f; } },
};
for (const Case& c : refuse)
{
Draw d = base;
c.change(d);
const Plan p = RunPlan(d, e, vignette);
CHECK(p.fill == Fill::None, "%s: %s, want none", c.what, Name(p.fill));
}
// A translation in the matrix (c3 / c7).
{
Matrix t = m;
t.c[3] = 0.1f;
const Plan p = RunPlan(ShowcaseDraw(t, deck.sx, deck.sy, ps, 1280.0f, 2048.0f), e, vignette);
CHECK(p.fill == Fill::None, "x translation: %s, want none", Name(p.fill));
}
// The texture: an opaque picture of the same shape, a vignette with any
// one of its middle samples dark, an unreadable texture, a vertex past
// the buffer.
CHECK(RunPlan(base, e, picture).fill == Fill::None, "an opaque picture of the vignette's shape was stretched");
for (uint32_t i = 0; i < kClearSamples; i++)
{
Texture t = VignetteTexture();
uint32_t x, y;
ClearSample(i, t.w, t.h, x, y);
CHECK(t.Get(x, y) == 0, "the test vignette is clear at sample %u", i);
t.Set(x, y, 0x01000000u);
CHECK(RunPlan(base, e, t).fill == Fill::None, "middle sample %u (%u,%u) not clear: still stretched", i, x, y);
}
CHECK(RunPlan(base, e, vignette, 4, false).fill == Fill::None, "unreadable texels: stretched anyway");
CHECK(RunPlan(base, e, vignette, 2).fill == Fill::None, "a vertex past the buffer: stretched anyway");
// And it is the samples that decide: a vignette whose middle is clear
// only between the samples is refused, one clear only at them is taken.
{
Texture t = PictureTexture();
for (uint32_t i = 0; i < kClearSamples; i++)
{
uint32_t x, y;
ClearSample(i, t.w, t.h, x, y);
t.Set(x, y, 0);
}
CHECK(RunPlan(base, e, t).fill == Fill::Vignette, "the five samples clear: not taken");
}
printf("plan: %zu register faults, an opaque picture, %u dark samples, unreadable texels and a short vertex buffer refused\n",
std::size(refuse) + 1, kClearSamples);
}
// ---- The Blacklist bio's panel (a black frame, PlanFill) ----
// As logged on the Deck (NFSMW_LOG_FRONTEND, the bio pushed from the main
// menu by a test-only hook): "black frame x -0.976..0.976 y -0.887..0.887
// colour FB000000 blend 07060706 t0 1AD6E000 32x32", fading in from
// 11000000 over ~15 frames; the picture inset on it (x +-0.970, y +-0.920,
// blend 01060106) and the text are drawn after it.
constexpr float kBioX = 0.976f, kBioY = 0.985f;
static Draw PanelDraw(const Matrix& m, float sx, float sy, const float ps[4], float hostW, float hostH)
{
Draw d = ShowcaseDraw(m, sx, sy, ps, hostW, hostH);
d.t0Width = d.t0Height = 32; // the game's white texture
d.t0Format = 20;
return d;
}
static Plan RunPanel(const Draw& d, const EPolys& e)
{
return PlanFill(d, [&](uint32_t k, uint32_t j, uint32_t& word) {
word = e.w[k & 3][j & 3];
return k < 4;
});
}
// The panel's fill against pixel centres, over the screen (the target's top
// `screenH` rows): each pixel is in exactly one of the panel (its centre
// inside the box) and the fill rectangles; ties within 1/256 pixel skipped.
// Returns the wrong pixels.
static int PanelOracle(const Box& b, const Rect* r, uint32_t n, const float ps[4], float hostW, float hostH, float screenH)
{
const float pxX = std::fabs(ps[0]) * hostW * 0.5f, pxY = std::fabs(ps[1]) * hostH * 0.5f;
auto tie = [](float d, float px) { return std::fabs(d) * px < 1.0f / 256.0f; };
int wrong = 0;
for (int32_t y = 0; y < int32_t(screenH); y++)
{
const float cy = ((float(y) + 0.5f) / hostH * 2.0f - 1.0f - ps[3]) / ps[1];
for (int32_t x = 0; x < int32_t(hostW); x++)
{
const float cx = ((float(x) + 0.5f) / hostW * 2.0f - 1.0f - ps[2]) / ps[0];
if (tie(cx - b.x0, pxX) || tie(cx - b.x1, pxX) || tie(cy - b.y0, pxY) || tie(cy - b.y1, pxY))
continue;
int in = cx > b.x0 && cx < b.x1 && cy > b.y0 && cy < b.y1;
for (uint32_t i = 0; i < n; i++)
in += x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1;
wrong += in != 1;
}
}
return wrong;
}
// v0.1.1-v0.1.5's fill (a planted fault): only past the band.
static uint32_t PastBandOnly(const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY, Rect out[4])
{
uint32_t n = 0;
auto add = [&](const Rect& r) {
const Rect c = Intersect(r, scissor);
if (!Empty(c))
out[n++] = c;
};
const float e = kFillEdge;
if (scaleY < 1.0f)
{
add(ClipToHost(-e, e, scaleY, e, ps, hostW, hostH));
add(ClipToHost(-e, e, -e, -scaleY, ps, hostW, hostH));
}
else if (scaleX < 1.0f)
{
add(ClipToHost(scaleX, e, -e, e, ps, hostW, hostH));
add(ClipToHost(-e, -scaleX, -e, e, ps, hostW, hostH));
}
return n;
}
static void BioTests()
{
struct Host
{
const char* name;
float scale, ox, oy;
};
const Host hosts[] = { { "1x", 1.0f, 640.0f, 360.0f }, { "2x half-pixel", 2.0f, 640.5f, 360.5f } };
int planned = 0, oldFails = 0, cases = 0;
for (const Shape& s : kShapes)
for (const Host& h : hosts)
for (int flipped = 0; flipped < 2; flipped++)
{
float ps[4];
FrontEndPs(ps, h.ox, flipped ? 360.0f - (h.oy - 360.0f) : h.oy, flipped ? 360.0f : -360.0f);
const float hostW = 1280.0f * h.scale, hostH = 2048.0f * h.scale, screenH = 720.0f * h.scale;
const Matrix m = FrontEndMatrix(s.sx, s.sy);
const EPolys e = Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, s.sx, s.sy, 0xFF000000), m);
const Draw d = PanelDraw(m, s.sx, s.sy, ps, hostW, hostH);
const Plan p = RunPanel(d, e);
char what[160];
snprintf(what, sizeof(what), "bio panel, %s, %s%s", s.name, h.name, flipped ? ", y flipped" : "");
CHECK(p.fill == Fill::Blackout, "%s: %s, want a black frame", what, Name(p.fill));
if (p.fill != Fill::Blackout)
continue;
float want[4];
CoverFrame(p.box, d.posScale, want);
CHECK(std::memcmp(want, p.posScale, sizeof(want)) == 0, "%s: the copies' posScale is not CoverFrame's", what);
const int wrong = PanelOracle(p.box, p.rects, p.rectCount, ps, hostW, hostH, screenH);
CHECK(wrong == 0, "%s: %d pixel(s) in both or neither of the panel and its fill", what, wrong);
Rect old[4]{};
const uint32_t n = PastBandOnly(ps, hostW, hostH, d.scissor, s.sx, s.sy, old);
oldFails += PanelOracle(p.box, old, n, ps, hostW, hostH, screenH) > 0;
cases++;
planned++;
}
printf("bio panel: %d plans at %zu shapes x 1x/2x x y-flipped, the panel and its fill split every pixel once\n", planned, std::size(kShapes));
printf("planted bio panel fill 'past the band only (v0.1.5)': %d of %d cases fail\n", oldFails, cases);
CHECK(oldFails == cases && cases > 0, "planted 'past the band only' passed %d of %d cases", cases - oldFails, cases);
// Fading in (11000000): below the black frame's alpha, left alone (as
// before); over a picture (a large t0), never a black frame, and not a
// vignette (opaque in the middle).
float ps[4];
FrontEndPs(ps);
const Matrix m = FrontEndMatrix(1.0f, 0.9f);
const Draw d = PanelDraw(m, 1.0f, 0.9f, ps, 1280.0f, 2048.0f);
CHECK(RunPanel(d, Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, 1.0f, 0.9f, 0x11000000), m)).fill == Fill::None, "the panel fading in was filled");
const EPolys picture = Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, 1.0f, 0.9f, 0xFF000000), m);
Draw big = d;
big.t0Width = big.t0Height = 256;
big.t0Format = 6;
CHECK(RunPanel(big, picture).fill == Fill::None, "a black quad over a large picture taken for a black frame");
CHECK(RunPlan(big, picture, PictureTexture()).fill == Fill::None, "a picture of the panel's shape taken for a vignette");
}
// ---- gpu/renderer.cpp uses it ----
static std::string Squash(const std::string& source)
{
std::string code, r;
std::istringstream in(source);
for (std::string line; std::getline(in, line);)
{
size_t c = line.find("//");
code += (c == std::string::npos ? line : line.substr(0, c)) + "\n";
}
for (char c : code)
if (c != ' ' && c != '\n' && c != '\t')
r += c;
return r;
}
static size_t Count(const std::string& s, const std::string& what)
{
size_t n = 0;
for (size_t at = s.find(what); at != std::string::npos; at = s.find(what, at + 1))
n++;
return n;
}
// The renderer's wiring; returns the failures (quiet: for the planted copies).
static int RendererChecks(const std::string& source, bool quiet)
{
const int before = g_failures;
const bool was = g_quiet;
g_quiet = quiet;
const std::string all = Squash(source);
// planEdgeFills: bars and black frames, then the backdrop, then the
// vignette, each only when nothing earlier took the draw.
const size_t fill = all.find("gpu::bars::Planplan=gpu::bars::PlanFill(draw,read);");
const size_t backdrop = all.find("if(plan.fill==gpu::bars::Fill::None)plan=gpu::bars::PlanBackdrop(draw,read);");
const size_t vignette = all.find("plan=gpu::bars::PlanVignette(draw,read,texel);");
CHECK(fill != std::string::npos && backdrop != std::string::npos && vignette != std::string::npos && fill < backdrop &&
backdrop < vignette,
"planEdgeFills asks PlanFill, then PlanBackdrop, then PlanVignette");
// Both paths (wide or tall screens, and 16:9) ask it: the black frame's
// fill and the vignette at every shape.
CHECK(Count(all, "barPlan=planEdgeFills(c,") == 2, "planEdgeFills asked on both paths: %zu", Count(all, "barPlan=planEdgeFills(c,"));
CHECK(vignette != std::string::npos && all.rfind("if(plan.fill==gpu::bars::Fill::None){", vignette) != std::string::npos &&
vignette - all.rfind("if(plan.fill==gpu::bars::Fill::None){", vignette) < 600,
"PlanVignette only for a draw nothing else took");
// Its texture as the GPU reads it.
CHECK(all.find(".t0Format=uint32_t(t0.format),") != std::string::npos, "the draw's t0 format is handed over");
CHECK(all.find("word=GpuSwap(LoadPhysical(t0Base+gpu::bars::TexelOffset32(x,y,t0Pitch,t0.tiled!=0)),uint32_t(t0.endianness));") !=
std::string::npos,
"texels read through TexelOffset32 at t0's base, pitch, tiling and endian");
// The plan is applied to the draw itself on both paths (wide or tall
// screens, and 16:9), before its constants are pushed.
const std::string apply = "if(barPlan.fill==gpu::bars::Fill::Vignette)memcpy(pc.posScale,barPlan.posScale,sizeof(pc.posScale));";
CHECK(Count(all, apply) == 2, "the vignette's posScale applied on both paths: %zu", Count(all, apply));
const size_t lastApply = all.rfind(apply);
const size_t push = lastApply == std::string::npos ? lastApply : all.find("pushConstants(pc);", lastApply);
const size_t draw = lastApply == std::string::npos ? lastApply : all.find("vkCmdDraw", lastApply);
CHECK(push != std::string::npos && draw != std::string::npos && push < draw,
"the posScale is set before the draw's constants are pushed and it is drawn");
// Both behind the A/B switch, as every edge fill.
CHECK(all.find("staticconstboolbarFill=[]{constchar*v=std::getenv(\"NFSMW_BAR_FILL\");return!(v&&v[0]=='0');}();") != std::string::npos,
"NFSMW_BAR_FILL=0 still turns the edge fills off");
g_quiet = was;
return g_failures - before;
}
static void RendererTests(const char* path)
{
std::ifstream f(path);
CHECK(f.good(), "cannot read %s", path);
if (!f.good())
return;
std::stringstream ss;
ss << f.rdbuf();
const std::string source = ss.str();
CHECK(RendererChecks(source, false) == 0, "renderer.cpp's vignette wiring");
// Planted: copies with the wiring cut, each must fail.
struct Cut
{
const char* what;
const char* from;
const char* to;
bool all;
};
const Cut cuts[] = {
{ "PlanVignette never asked", "plan = gpu::bars::PlanVignette(draw, read, texel);", "(void)texel;", false },
{ "PlanFill only on wide or tall screens (v0.1.5)", "gpu::bars::Plan plan = gpu::bars::PlanFill(draw, read);",
"gpu::bars::Plan plan = aspectX < 1.0f || aspectY < 1.0f ? gpu::bars::PlanFill(draw, read) : gpu::bars::Plan{};", false },
{ "the 16:9 path never plans", "barPlan = planEdgeFills(c, game::FrontEndScaleY(), s, sv, feShader, true);", ";", false },
{ "the 16:9 path doesn't apply it", " if (barPlan.fill == gpu::bars::Fill::Vignette)\n memcpy(pc.posScale, barPlan.posScale, sizeof(pc.posScale));\n }\n }\n }", " }\n }\n }", false },
{ "no posScale applied at all", "memcpy(pc.posScale, barPlan.posScale, sizeof(pc.posScale));", ";", true },
{ "texels read linear", "t0.tiled != 0", "false", false },
{ "texels read without the endian", "uint32_t(t0.endianness)", "0u", false },
};
for (const Cut& c : cuts)
{
std::string copy = source;
size_t at = copy.find(c.from), n = 0;
while (at != std::string::npos)
{
copy.replace(at, std::strlen(c.from), c.to);
n++;
if (!c.all)
break;
at = copy.find(c.from, at + std::strlen(c.to));
}
CHECK(n > 0, "planted '%s': its text is not in renderer.cpp", c.what);
const int saved = g_failures;
const int failed = RendererChecks(copy, true);
g_failures = saved;
printf("planted renderer '%s': %d check(s) failed\n", c.what, failed);
CHECK(failed > 0, "planted renderer '%s' passed every check", c.what);
}
}
int main(int argc, char** argv)
{
printf("menu borders (GitHub #19): the Showcase's vignette\n");
VignetteCases(VignetteRule{});
printf("shape cases at %zu screen shapes checked\n", std::size(kShapes));
printf("\nplanted, shape (each must fail a case):\n");
auto plant = [](const char* what, auto set) { VignetteRule r; set(r); Planted(what, r); };
plant("centreMax 0.05", [](VignetteRule& r) { r.centreMax = 0.05f; });
plant("extentMin 0.96", [](VignetteRule& r) { r.extentMin = 0.96f; });
plant("extentMin 0.85", [](VignetteRule& r) { r.extentMin = 0.85f; });
plant("extentMax 0.96", [](VignetteRule& r) { r.extentMax = 0.96f; });
plant("extentMax 1.0", [](VignetteRule& r) { r.extentMax = 1.0f; });
plant("cornerTolerance 0.1", [](VignetteRule& r) { r.cornerTolerance = 0.1f; });
printf("\n");
TexelTests();
printf("\n");
PlanTests();
printf("\n");
BioTests();
printf("\n");
if (argc > 1)
RendererTests(argv[1]);
else
printf("(no renderer.cpp given: its checks skipped)\n");
if (g_failures)
{
printf("menu_borders_test: %d failure(s)\n", g_failures);
return 1;
}
printf("menu_borders_test: all passed\n");
return 0;
}
+229 -2
View File
@@ -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;
}
+278
View File
@@ -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;
}
+379
View File
@@ -0,0 +1,379 @@
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
//
// video::SwapchainUse (runtime/video/swapchain_use.h): the present waiter's
// thread uses a swapchain (its present waits, the driver's reports) only
// while it is the current one, and the main thread never destroys one under
// such a use. v0.1.4 and v0.1.5 read the driver's reports
// (vkGetPastPresentationTimingGOOGLE) after the wait's lock, so a fullscreen
// toggle on a 120 Hz Mac could destroy the swapchain under the read: an abort,
// "mutex lock failed". Fake swapchains carry a lifetime canary: a use that
// reaches a destroyed one, or another one than its present's, is counted.
// The optional argument is runtime/video/presenter.cpp: every waiter call that
// takes a swapchain must be inside a use, and the swapchain retired before it
// is destroyed (checked, then planted mutations of it that must fail).
//
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/swapchain_use_test.cpp -o build/swapchain_use_test
#include <video/swapchain_use.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
static int g_failures = 0;
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
namespace
{
// A swapchain as the waiter sees it. Never freed (a pool), so a use after
// its destruction is seen, not undefined: `alive` is the canary.
struct FakeSwapchain
{
std::atomic<bool> alive{ false };
std::atomic<uint64_t> generation{ 0 }; // the one it was published as
std::atomic<int> users{ 0 };
};
using Use = video::SwapchainUse<FakeSwapchain*>;
// A use of `s` for a present of generation `generation`: true if it was
// sound (alive, the present's own swapchain) throughout.
bool Touch(FakeSwapchain* s, uint64_t generation, int spins = 0)
{
s->users++;
bool ok = s->alive.load() && s->generation.load() == generation;
for (volatile int i = 0; i < spins; i = i + 1)
;
ok = ok && s->alive.load();
s->users--;
return ok;
}
void Basics()
{
printf("publish, use, retire:\n");
FakeSwapchain a, b;
Use use;
int ran = 0;
CHECK(!use.Use(0, [&](FakeSwapchain*) { ran++; }) && ran == 0, "nothing published: no use");
const uint64_t ga = use.Publish(&a);
CHECK(ga != 0, "a generation is never 0");
FakeSwapchain* seen = nullptr;
CHECK(use.Use(ga, [&](FakeSwapchain* s) { seen = s; }) && seen == &a, "the published one's generation: used, with it");
CHECK(!use.Use(0, [&](FakeSwapchain*) { ran++; }) && ran == 0, "generation 0 (no swapchain): no use");
CHECK(!use.Use(ga + 1, [&](FakeSwapchain*) { ran++; }) && ran == 0, "a generation not published yet: no use");
use.Retire();
CHECK(!use.Use(ga, [&](FakeSwapchain*) { ran++; }) && ran == 0, "retired: no use");
const uint64_t gb = use.Publish(&b);
CHECK(gb != ga, "each swapchain its own generation");
CHECK(!use.Use(ga, [&](FakeSwapchain*) { ran++; }) && ran == 0, "an older swapchain's present: no use of the new one");
CHECK(use.Use(gb, [&](FakeSwapchain* s) { seen = s; }) && seen == &b, "the new one: used");
use.Retire();
use.Retire();
CHECK(!use.Use(gb, [&](FakeSwapchain*) { ran++; }) && ran == 0, "retired twice (Shutdown after a destroy): no use");
}
// MoltenVK's and Mesa's handles are pointers: a new swapchain can get a
// destroyed one's. A present to the old one is not the new one's.
void HandleReuse()
{
printf("a new swapchain with a destroyed one's handle:\n");
FakeSwapchain a;
Use use;
const uint64_t g1 = use.Publish(&a);
use.Retire();
const uint64_t g2 = use.Publish(&a); // the same handle again
int ran = 0;
CHECK(g1 != g2, "a new generation");
CHECK(!use.Use(g1, [&](FakeSwapchain*) { ran++; }) && ran == 0, "the old present isn't waited for on the new swapchain");
CHECK(use.Use(g2, [&](FakeSwapchain*) { ran++; }) && ran == 1, "the new one's present is");
}
// Retire() must not return while a use is under way (the main thread
// destroys the swapchain right after it), and none may start after it.
void RetireWaits()
{
printf("retire waits for a use under way:\n");
FakeSwapchain a;
a.alive = true;
Use use;
const uint64_t g = use.Publish(&a);
a.generation = g;
std::atomic<bool> inside{ false }, done{ false };
std::thread user([&] {
use.Use(g, [&](FakeSwapchain*) {
inside = true;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
done = true;
});
});
while (!inside)
std::this_thread::yield();
const auto t0 = std::chrono::steady_clock::now();
use.Retire();
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
CHECK(done, "Retire returned while the use was still under way (after %.1f ms)", ms);
user.join();
int ran = 0;
CHECK(!use.Use(g, [&](FakeSwapchain*) { ran++; }) && ran == 0, "no use after the retire");
}
struct StressCounts
{
uint64_t unsound = 0, destroyedInUse = 0, used = 0, refused = 0;
};
// The presenter's own shape: the main thread destroys the swapchain and
// makes another, again and again (fullscreen toggles); the waiter waits
// for presents and reads the driver's reports after each wait, for
// presents of the current swapchain and of older ones still queued. The
// new swapchain mostly gets the old one's handle (slot) again.
// `v015Order`: the reports read after the wait's use, with the present's
// handle, as v0.1.4 and v0.1.5 did.
StressCounts RunStress(bool v015Order, int cycles)
{
FakeSwapchain pool[3];
Use use;
std::atomic<uint64_t> lastPresent{ 0 }; // the generation the latest present was for
std::atomic<bool> stop{ false };
std::atomic<uint64_t> unsound{ 0 }, used{ 0 }, refused{ 0 };
uint64_t destroyedInUse = 0;
std::thread waiter([&] {
uint64_t seen[4] = {};
for (uint64_t n = 0; !stop; n++)
{
// A present just queued, or (every other time) one queued a
// few presents ago, perhaps to a swapchain since destroyed.
seen[n % 4] = lastPresent.load();
const uint64_t g = (n % 2) ? seen[(n + 1) % 4] : seen[n % 4];
FakeSwapchain* handle = nullptr;
const bool waited = use.Use(g, [&](FakeSwapchain* s) { // the wait
handle = s;
if (!Touch(s, g, 200))
unsound++;
});
(waited ? used : refused)++;
if (!v015Order)
{
const bool read = use.Use(g, [&](FakeSwapchain* s) { // the driver's reports
if (!Touch(s, g, 200))
unsound++;
});
(read ? used : refused)++;
}
else if (handle && !Touch(handle, g, 200))
unsound++;
std::this_thread::yield();
}
});
FakeSwapchain* current = nullptr;
for (int i = 0; i < cycles; i++)
{
// DestroySwapchain: retire, then destroy.
use.Retire();
if (current)
{
destroyedInUse += current->users.load() != 0;
current->alive = false;
}
// CreateSwapchain: make one and publish it; presents to it follow.
size_t slot = current ? size_t(current - pool) : 0;
if (i % 4 == 0)
slot = (slot + 1) % 3;
current = &pool[slot];
current->alive = true;
const uint64_t g = use.Publish(current);
current->generation = g;
for (int p = 0; p < 3; p++)
{
lastPresent = g;
for (volatile int k = 0; k < 300; k = k + 1)
;
}
}
stop = true;
waiter.join();
return { unsound.load(), destroyedInUse, used.load(), refused.load() };
}
void Stress()
{
printf("the waiter against swapchains destroyed and made again:\n");
constexpr int kCycles = 20000;
const StressCounts c = RunStress(false, kCycles);
CHECK(c.unsound == 0, "%llu uses reached a destroyed swapchain, or another than their present's", (unsigned long long)c.unsound);
CHECK(c.destroyedInUse == 0, "%llu swapchains destroyed while in use", (unsigned long long)c.destroyedInUse);
CHECK(c.used > 1000 && c.refused > 0, "the waiter both used swapchains (%llu) and was refused stale ones (%llu)",
(unsigned long long)c.used, (unsigned long long)c.refused);
printf(" %d recreations: %llu uses, %llu refused, %llu unsound\n", kCycles, (unsigned long long)c.used,
(unsigned long long)c.refused, (unsigned long long)c.unsound);
// The same with v0.1.5's order must reach destroyed swapchains (it
// is a race: up to 5 tries), or the canary proves nothing.
uint64_t old = 0;
int tries = 0;
while (old == 0 && tries < 5)
{
old += RunStress(true, kCycles).unsound;
tries++;
}
CHECK(old > 0, "v0.1.5's order (the reports read after the wait's lock) never reached a destroyed swapchain in %d tries", tries);
printf(" v0.1.5's order: %llu unsound uses (%d tr%s)\n", (unsigned long long)old, tries, tries == 1 ? "y" : "ies");
}
// The orders v0.1.5 had, step by step: Touch() catches each, so the
// canary would have caught the bug.
void OldOrders()
{
printf("v0.1.5's orders, step by step (each must be caught):\n");
// 1. The wait checked the swapchain under the lock, and the reports
// were read after it: the swapchain destroyed in between.
{
FakeSwapchain a;
Use use;
const uint64_t g = use.Publish(&a);
a.alive = true;
a.generation = g;
FakeSwapchain* waited = nullptr;
use.Use(g, [&](FakeSwapchain* s) { waited = s; }); // the wait
use.Retire(); // DestroySwapchain
a.alive = false;
CHECK(!Touch(waited, g), "the report read after the lock reaches the destroyed swapchain: not caught");
}
// 2. The present named its swapchain by handle: the waiter, holding a
// present of the destroyed one, found the new one at that handle.
{
FakeSwapchain a;
Use use;
const uint64_t g1 = use.Publish(&a);
a.alive = true;
a.generation = g1;
FakeSwapchain* presentHandle = &a;
use.Retire();
a.alive = false;
const uint64_t g2 = use.Publish(&a);
a.alive = true;
a.generation = g2;
FakeSwapchain* current = &a; // v0.1.5: if (t.swapchain == s_waitSwapchain) wait on it
CHECK(presentHandle == current && !Touch(current, g1), "the old present waited for on the new swapchain: not caught");
}
}
// presenter.cpp's calls that take the waiter's swapchain, and what the
// main thread does around destroying one; the problems found.
std::vector<std::string> SourceProblems(const std::string& src)
{
std::vector<std::string> problems;
const std::string use = "s_swapchainUse.Use(";
for (const char* call : { "s_waitForPresent(s_device", "s_pastTiming(s_device" })
{
int found = 0;
for (size_t at = src.find(call); at != std::string::npos; at = src.find(call, at + 1))
{
found++;
const size_t open = src.rfind(use, at);
if (open == std::string::npos || src.find("});", open) < at)
problems.push_back(std::string(call) + " outside a use, at offset " + std::to_string(at));
}
if (!found)
problems.push_back(std::string("no ") + call + " call found");
}
// A function's body: from its head to the brace at its own indent.
auto body = [&](const char* head) {
const size_t at = src.find(head);
if (at == std::string::npos)
return std::string();
const size_t line = src.rfind('\n', at) + 1;
const std::string end = "\n" + src.substr(line, at - line) + "}\n";
return src.substr(at, src.find(end, at) - at);
};
const std::string destroy = body("void DestroySwapchain()");
const size_t retire = destroy.find("s_swapchainUse.Retire();");
const size_t destroyCall = destroy.find("vkDestroySwapchainKHR(");
if (destroy.empty() || retire == std::string::npos || destroyCall == std::string::npos || retire > destroyCall)
problems.push_back("DestroySwapchain doesn't retire the swapchain before destroying it");
size_t destroys = 0;
for (size_t at = src.find("vkDestroySwapchainKHR("); at != std::string::npos; at = src.find("vkDestroySwapchainKHR(", at + 1))
destroys++;
if (destroys != 1)
problems.push_back("vkDestroySwapchainKHR called " + std::to_string(destroys) + " times (only DestroySwapchain may)");
const std::string create = body("bool CreateSwapchain()");
const size_t made = create.find("vkCreateSwapchainKHR(");
const size_t published = create.find("s_swapchainGeneration = s_swapchainUse.Publish(s_swapchain);");
if (made == std::string::npos || published == std::string::npos || published < made)
problems.push_back("CreateSwapchain doesn't publish the swapchain it made");
if (src.find("s_timings.push_back({ id, s_swapchainGeneration,") == std::string::npos)
problems.push_back("a present's timing doesn't name its swapchain's generation");
if (body("void Shutdown()").find("s_swapchainUse.Retire();") == std::string::npos)
problems.push_back("Shutdown doesn't retire the swapchain");
return problems;
}
std::string Replaced(std::string s, const std::string& from, const std::string& to)
{
const size_t at = s.find(from);
if (at == std::string::npos)
return {};
return s.replace(at, from.size(), to);
}
void Presenter(const char* path)
{
printf("presenter.cpp:\n");
std::ifstream in(path);
std::stringstream ss;
ss << in.rdbuf();
const std::string src = ss.str();
CHECK(!src.empty(), "can't read %s", path);
if (src.empty())
return;
for (const std::string& p : SourceProblems(src))
CHECK(false, "%s", p.c_str());
// Planted mutations, each a way back to the bug: each must be found.
struct Plant
{
const char* name;
std::string from, to;
};
const Plant plants[] = {
{ "the reports read after the use (v0.1.5's order)", " const int64_t hostToSteady = HostToSteadyNs();",
" s_pastTiming(s_device, VK_NULL_HANDLE, &count, nullptr);\n const int64_t hostToSteady = HostToSteadyNs();" },
{ "a present wait outside a use", " VkResult r = VK_ERROR_OUT_OF_DATE_KHR; // its swapchain is gone\n",
" VkResult r = s_waitForPresent(s_device, VK_NULL_HANDLE, t.id, 0);\n" },
{ "no retire before the destroy", " s_swapchainUse.Retire(); // the waiter is off it after this", "" },
{ "a second destroy elsewhere", " s_swapchainUse.Retire(); // the present waiter is off the swapchain",
" vkDestroySwapchainKHR(s_device, s_swapchain, nullptr); // the present waiter is off the swapchain" },
{ "never published", "s_swapchainGeneration = s_swapchainUse.Publish(s_swapchain);", "s_swapchainGeneration = 0;" },
{ "presents named by handle", "s_timings.push_back({ id, s_swapchainGeneration,", "s_timings.push_back({ id, s_swapchain," },
};
for (const Plant& p : plants)
{
const std::string mutated = Replaced(src, p.from, p.to);
CHECK(!mutated.empty(), "plant \"%s\": its text isn't in presenter.cpp", p.name);
if (!mutated.empty())
{
const bool caught = !SourceProblems(mutated).empty();
CHECK(caught, "plant \"%s\" not caught", p.name);
printf(" plant: %s: %s\n", p.name, caught ? "caught" : "NOT caught");
}
}
}
}
int main(int argc, char** argv)
{
Basics();
HandleReuse();
RetireWaits();
Stress();
OldOrders();
if (argc > 1)
Presenter(argv[1]);
else
printf("(presenter.cpp not given: its checks skipped)\n");
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
return g_failures ? 1 : 0;
}
+382 -11
View File
@@ -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;
}