mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-18 12:01:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31849f5f1d | ||
|
|
9d2cb93f2d |
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -32,8 +32,10 @@
|
||||
// #9). Further behind, the queue ran dry and the display waited, and the
|
||||
// frame gets the rest; a frame slower than the vblank (GPU-bound) gets its
|
||||
// count, its length rounded either way. Game time adds up to the vblanks
|
||||
// that fired, to within those few. It runs at the display's rate over 60:
|
||||
// 0.044% slow on a 59.97 Hz monitor, at most 2.5% (the lock's range). Frames
|
||||
// that fired, to within those few. It runs at the display's rate over 60
|
||||
// (over 120, 180 or 240 on such a display, whose refreshes a vblank is 2-4
|
||||
// of: video/vblank_lock.h): 0.044% slow on a 59.97 Hz monitor, at most 2.5%
|
||||
// (the lock's range). Frames
|
||||
// of more than 6 vblanks (a hitch, loading) keep their measured time; below
|
||||
// 20 fps, where they are the frame rate's own, only those over half a second
|
||||
// do, the rest get 6 (the stepper's own cap) and later frames give back what
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
// guard (default 10, at most 12). Each movie leaves a [movie] line: its
|
||||
// frames by vblanks shown, and those held.
|
||||
#include <stdafx.h>
|
||||
#include "movie_pacing.h"
|
||||
|
||||
#include <cpu/guest_time.h>
|
||||
#include <kernel/function.h>
|
||||
@@ -78,6 +79,7 @@ namespace
|
||||
constexpr uint32_t kDeviceVblanks = 15600, kDeviceReleased = 15604, kDeviceCountdown = 15608;
|
||||
|
||||
std::atomic<uint64_t> s_movieRenders{ 0 }; // XMV frames rendered
|
||||
std::atomic<int64_t> s_lastMovieFrame{ 0 }; // when the last one was (LastMovieFrameNs)
|
||||
|
||||
// The swap callback's thread only: the command processor's (with
|
||||
// NFSMW_EAGER_WRITES=0 also the renderer's completion thread, one swap at
|
||||
@@ -109,6 +111,12 @@ PPC_FUNC(sub_826E8CF8)
|
||||
{
|
||||
__imp__sub_826E8CF8(ctx, base);
|
||||
s_movieRenders.fetch_add(1, std::memory_order_release);
|
||||
s_lastMovieFrame.store(NowNs(), std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
int64_t game::LastMovieFrameNs()
|
||||
{
|
||||
return s_lastMovieFrame.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
PPC_FUNC(sub_825985A0)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See movie_pacing.cpp.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace game
|
||||
{
|
||||
// Any thread: when the XMV player last rendered a movie frame (ns, steady
|
||||
// clock; 0: none yet). A movie (the boot logos, the intro, a cutscene)
|
||||
// plays while these keep coming.
|
||||
int64_t LastMovieFrameNs();
|
||||
}
|
||||
@@ -1126,7 +1126,23 @@ namespace gpu
|
||||
{
|
||||
const video::RefreshEstimate& d = s.display;
|
||||
std::string line;
|
||||
if (s.locked)
|
||||
if (s.locked && d.multiple >= 2)
|
||||
{
|
||||
// Every 2nd-4th refresh of a 120-240 Hz display (video/vblank_lock.h),
|
||||
// and how long new frames stayed on screen since the lock.
|
||||
const uint64_t frames = s.even + s.shorter + s.longer;
|
||||
line = std::format("locked to every {} refresh of the display: {:.4f} Hz, the vblank at {:.4f} Hz (fit {} presents over {:.1f} s, "
|
||||
"rms {:.2f} ms, {} set aside); vblank {:.2f} ms before its refresh, error {:+.3f} ms; vblank to screen {:.1f} ms (median), "
|
||||
"to ready {:.1f} ms (99%); new frames shown on the refresh {} after their vblank's ({}); on screen for {} refreshes "
|
||||
"{:.2f}%, fewer {}, more {} (of {})",
|
||||
d.multiple == 2 ? "2nd" : d.multiple == 3 ? "3rd" : "4th", 1e9 / d.periodNs, 1e9 / s.periodNs, d.samples, d.spanNs / 1e9,
|
||||
d.rmsNs / 1e6, d.rejected, s.leadNs / 1e6, s.errorNs / 1e6, s.latencyNs / 1e6, s.readyNs / 1e6, d.multiple + s.extra,
|
||||
s.extraChanges == 0 ? std::string("the next, as at first")
|
||||
: s.extraMissed ? std::format("later: {} of the last 120 missed theirs", s.extraMissed)
|
||||
: std::string("earlier: the last 600 were ready in time for it"),
|
||||
d.multiple, frames ? 100.0 * double(s.even) / double(frames) : 0.0, s.shorter, s.longer, frames);
|
||||
}
|
||||
else if (s.locked)
|
||||
line = std::format("locked to the display: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside); "
|
||||
"vblank {:.2f} ms before its refresh, error {:+.3f} ms; vblank to screen {:.1f} ms (median), to present {:.1f} ms (99%); {} slips",
|
||||
1e9 / d.periodNs, d.samples, d.spanNs / 1e9, d.rmsNs / 1e6, d.rejected, s.leadNs / 1e6, s.errorNs / 1e6,
|
||||
@@ -1135,9 +1151,9 @@ namespace gpu
|
||||
line = std::format("free-running every {:.3f} ms: the display follows the vblank (variable refresh?), "
|
||||
"so there is no refresh to lock to", s.periodNs / 1e6);
|
||||
else if (d.samples)
|
||||
line = std::format("free-running every {:.3f} ms; display {}: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside)",
|
||||
line = std::format("free-running every {:.3f} ms; display {}: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside{})",
|
||||
s.periodNs / 1e6, d.valid ? "measured (lock off)" : "not accepted", 1e9 / d.periodNs, d.samples, d.spanNs / 1e9,
|
||||
d.rmsNs / 1e6, d.rejected);
|
||||
d.rmsNs / 1e6, d.rejected, d.multiple >= 2 ? std::format("; fitted as {} refreshes a vblank", d.multiple) : std::string());
|
||||
else
|
||||
line = std::format("free-running every {:.3f} ms", s.periodNs / 1e6);
|
||||
if (video::VblankTiming::Summary t = timing.Summarize(); t.count)
|
||||
@@ -1173,7 +1189,7 @@ namespace gpu
|
||||
next = now();
|
||||
} while (guesttime::Suspended() || guesttime::PausedNs() != paused);
|
||||
bool locked = false;
|
||||
uint64_t slips = 0, count = 0;
|
||||
uint64_t slips = 0, count = 0, holdChanges = 0;
|
||||
while (true)
|
||||
{
|
||||
next = clock.Next(next);
|
||||
@@ -1201,11 +1217,12 @@ namespace gpu
|
||||
DispatchInterrupt(thread.ppcContext, 0, 2);
|
||||
timing.Add(fired - next, now() - fired);
|
||||
auto status = clock.GetStatus();
|
||||
if (status.locked != locked || status.slips != slips || ++count % 3600 == 0)
|
||||
if (status.locked != locked || status.slips != slips || status.extraChanges != holdChanges || ++count % 3600 == 0)
|
||||
{
|
||||
LogVblank(status, timing, timerName);
|
||||
locked = status.locked;
|
||||
slips = status.slips;
|
||||
holdChanges = status.extraChanges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+192
-35
@@ -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
@@ -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)
|
||||
|
||||
@@ -51,6 +51,8 @@ namespace
|
||||
uint16_t s_latchedButtons = 0;
|
||||
bool s_wasBlocked = false;
|
||||
bool s_scriptSuppressed = false; // under s_mutex: no scripted input while a menu is open
|
||||
std::chrono::steady_clock::time_point s_lastInput; // main thread: a control last in use (SecondsSinceInput)
|
||||
bool s_inputSeen = false;
|
||||
|
||||
// The controller's Back is held back from the game until it's clear it
|
||||
// isn't the start of the Back + Start settings chord: released (then
|
||||
@@ -575,6 +577,13 @@ namespace hid
|
||||
s_rumbleDirty = true;
|
||||
}
|
||||
|
||||
double SecondsSinceInput()
|
||||
{
|
||||
if (!s_inputSeen)
|
||||
return 0.0;
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - s_lastInput).count();
|
||||
}
|
||||
|
||||
void SetBlocked(bool blocked)
|
||||
{
|
||||
s_blocked.store(blocked, std::memory_order_relaxed);
|
||||
@@ -665,6 +674,19 @@ namespace hid
|
||||
unsigned(p.leftTrigger), unsigned(p.rightTrigger), deadzone, triggerDeadzone,
|
||||
s_blocked.load(std::memory_order_relaxed) ? " (a menu is open: the game sees an idle pad)" : "");
|
||||
|
||||
// The last time anything was pressed or moved, the script's taps
|
||||
// included (SecondsSinceInput).
|
||||
{
|
||||
Pad scripted = p;
|
||||
ApplyScript(scripted);
|
||||
if (scripted.buttons || scripted.leftTrigger || scripted.rightTrigger || scripted.lx || scripted.ly || scripted.rx ||
|
||||
scripted.ry || !s_inputSeen)
|
||||
{
|
||||
s_lastInput = std::chrono::steady_clock::now();
|
||||
s_inputSeen = true;
|
||||
}
|
||||
}
|
||||
|
||||
// While a menu has the input the game sees an idle pad; after it
|
||||
// closes, each button still held stays hidden until released, so the
|
||||
// press that closed the menu (B, Start, Esc) doesn't reach the game.
|
||||
|
||||
@@ -48,6 +48,10 @@ namespace hid
|
||||
// While set (a menu is open), the game sees an idle controller, and
|
||||
// after it is cleared until every control has been released.
|
||||
void SetBlocked(bool blocked);
|
||||
// Main thread: seconds since any control was last pressed or moved (a
|
||||
// controller, the keyboard's controls, NFSMW_INPUT_SCRIPT), as of the
|
||||
// last Update; from the first Update on.
|
||||
double SecondsSinceInput();
|
||||
// At exit: stop any rumble (the process may outlive nothing that would).
|
||||
void Shutdown();
|
||||
// Main thread, from the frame loop (not UIKit's callback), once the game
|
||||
|
||||
+46
-2
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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) ---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
{
|
||||
|
||||
@@ -115,6 +115,11 @@ namespace ui
|
||||
// and WhatsNewEntry()) as a modal panel over the game until the player
|
||||
// closes it (A, B, Start, Esc or OK). Nothing without an entry.
|
||||
void ShowWhatsNew(std::string_view version);
|
||||
// Main thread: a one-time notice (`title`, a paragraph of `text`, OK) as a
|
||||
// panel over the game, which pauses under it, until the player closes it
|
||||
// (A, B, Start, Esc or OK); then `closed` runs with how. False (nothing
|
||||
// shown) without a UI, or while another panel or the settings menu is up.
|
||||
bool ShowNotice(std::string title, std::string text, std::function<void(const char*)> closed);
|
||||
|
||||
// Main thread, before the game starts (main.cpp): a page saying why the
|
||||
// game can't start (memory_status.h's StartupFailure), drawn until it is
|
||||
|
||||
+110
-7
@@ -382,15 +382,49 @@ namespace settings
|
||||
bool s_fileFound = false; // the last Load found a settings.toml
|
||||
bool s_fileLoaded = false; // ...and read and parsed it
|
||||
|
||||
std::string AppTable(const std::string& lastVersion)
|
||||
// refresh_notices: the display rates (whole Hz) the refresh-rate
|
||||
// notice was closed for (video/refresh_notice.h), "90,144".
|
||||
constexpr const char* kRefreshNoticesKey = "refresh_notices";
|
||||
std::vector<int>& RefreshNoticesList()
|
||||
{
|
||||
static std::vector<int> list;
|
||||
return list;
|
||||
}
|
||||
|
||||
std::vector<int> ParseRates(std::string_view text)
|
||||
{
|
||||
std::vector<int> out;
|
||||
while (!text.empty())
|
||||
{
|
||||
size_t comma = text.find(',');
|
||||
std::string item(text.substr(0, comma));
|
||||
text = comma == std::string_view::npos ? std::string_view() : text.substr(comma + 1);
|
||||
char* end = nullptr;
|
||||
long hz = std::strtol(item.c_str(), &end, 10);
|
||||
if (end != item.c_str() && hz > 0 && hz < 1000 && std::find(out.begin(), out.end(), int(hz)) == out.end())
|
||||
out.push_back(int(hz));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string AppTable(const std::string& lastVersion, const std::vector<int>& notices)
|
||||
{
|
||||
// Only what a version looks like (a hand edit could hold a quote
|
||||
// or a newline, which would break the file).
|
||||
if (lastVersion.empty() || lastVersion.size() > 64 ||
|
||||
!std::all_of(lastVersion.begin(), lastVersion.end(), [](char c) { return std::isalnum(uint8_t(c)) || c == '.' || c == '-' || c == '+'; }))
|
||||
std::string version;
|
||||
if (!(lastVersion.empty() || lastVersion.size() > 64 ||
|
||||
!std::all_of(lastVersion.begin(), lastVersion.end(), [](char c) { return std::isalnum(uint8_t(c)) || c == '.' || c == '-' || c == '+'; })))
|
||||
version = std::format("# The SpeedBreaker version that ran last (the game keeps this up to date).\n{} = \"{}\"\n",
|
||||
kLastVersionKey, lastVersion);
|
||||
std::string rates;
|
||||
for (int hz : notices)
|
||||
rates += (rates.empty() ? "" : ",") + std::to_string(hz);
|
||||
if (!rates.empty())
|
||||
rates = std::format("# Screen refresh rates (Hz) the refresh-rate notice was closed for: it isn't shown for them again.\n"
|
||||
"{} = \"{}\"\n", kRefreshNoticesKey, rates);
|
||||
if (version.empty() && rates.empty())
|
||||
return "";
|
||||
return std::format("\n[{}]\n# The SpeedBreaker version that ran last (the game keeps this up to date).\n{} = \"{}\"\n",
|
||||
kAppTable, kLastVersionKey, lastVersion);
|
||||
return std::format("\n[{}]\n{}{}", kAppTable, version, rates);
|
||||
}
|
||||
|
||||
// `text` (a settings.toml) without its [app] table: from a line that
|
||||
@@ -868,11 +902,13 @@ namespace settings
|
||||
out += std::format("{} = {}\n", o.key, TomlValue(o, values[i]));
|
||||
}
|
||||
std::string lastVersion;
|
||||
std::vector<int> notices;
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
lastVersion = LastVersionText();
|
||||
notices = RefreshNoticesList();
|
||||
}
|
||||
return out + AppTable(lastVersion);
|
||||
return out + AppTable(lastVersion, notices);
|
||||
}
|
||||
|
||||
// Where a save of `path` goes: through a symlinked settings.toml
|
||||
@@ -1269,6 +1305,7 @@ namespace settings
|
||||
// wait only for the values to be published.
|
||||
std::array<std::optional<double>, kCount> fileValues;
|
||||
std::string text, readError, lastError, lastVersion;
|
||||
std::vector<int> notices;
|
||||
size_t fromFile = 0;
|
||||
ReadStatus status = ReadFile(path, text, readError);
|
||||
if (status == ReadStatus::Missing)
|
||||
@@ -1328,6 +1365,15 @@ namespace settings
|
||||
for (auto&& [key, node] : *table)
|
||||
{
|
||||
std::string name = std::format("{}.{}", tableKey.str(), key.str());
|
||||
if (tableKey.str() == kAppTable && key.str() == kRefreshNoticesKey)
|
||||
{
|
||||
if (auto v = node.as_string())
|
||||
notices = ParseRates(v->get());
|
||||
else
|
||||
fprintf(stderr, "[settings] %s: %s = %s isn't a list of rates; ignored\n", path.filename().c_str(), name.c_str(),
|
||||
NodeText(node).c_str());
|
||||
continue;
|
||||
}
|
||||
if (tableKey.str() == kAppTable && key.str() == kLastVersionKey)
|
||||
{
|
||||
if (auto v = node.as_string())
|
||||
@@ -1371,6 +1417,7 @@ namespace settings
|
||||
LoadedPath() = path;
|
||||
LastErrorText() = lastError;
|
||||
LastVersionText() = lastVersion;
|
||||
RefreshNoticesList() = notices;
|
||||
s_fileFound = status != ReadStatus::Missing;
|
||||
s_fileLoaded = status == ReadStatus::Ok && lastError.empty();
|
||||
detail::g_generation.fetch_add(1, std::memory_order_release);
|
||||
@@ -1471,7 +1518,12 @@ namespace settings
|
||||
text = "# SpeedBreaker settings. The settings menu writes every option here when one\n"
|
||||
"# changes; until then each one keeps its default.\n";
|
||||
std::string recorded(version);
|
||||
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(recorded)); !failed.empty())
|
||||
std::vector<int> notices;
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
notices = RefreshNoticesList();
|
||||
}
|
||||
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(recorded, notices)); !failed.empty())
|
||||
{
|
||||
fprintf(stderr, "[settings] the version isn't recorded: %s\n", failed.c_str());
|
||||
return false;
|
||||
@@ -1483,4 +1535,55 @@ namespace settings
|
||||
LoadedPath() = path;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RefreshNoticeShown(int hz)
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
const std::vector<int>& list = RefreshNoticesList();
|
||||
return std::find(list.begin(), list.end(), hz) != list.end();
|
||||
}
|
||||
|
||||
bool RecordRefreshNotice(int hz)
|
||||
{
|
||||
std::lock_guard saveLock(s_saveMutex);
|
||||
std::filesystem::path path;
|
||||
std::string lastVersion;
|
||||
std::vector<int> notices;
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
notices = RefreshNoticesList();
|
||||
if (std::find(notices.begin(), notices.end(), hz) != notices.end())
|
||||
return true;
|
||||
notices.push_back(hz);
|
||||
RefreshNoticesList() = notices; // this run doesn't show it again, saved or not
|
||||
if (s_fileFound && !s_fileLoaded)
|
||||
{
|
||||
fprintf(stderr, "[settings] the refresh notice's %d Hz isn't recorded: settings.toml didn't load\n", hz);
|
||||
return false;
|
||||
}
|
||||
path = LoadedPath().empty() ? DefaultPath() : LoadedPath();
|
||||
lastVersion = LastVersionText();
|
||||
}
|
||||
// Only the [app] table changes, as for RecordVersion.
|
||||
std::string text, error;
|
||||
ReadStatus status = ReadFile(path, text, error);
|
||||
if (status == ReadStatus::Failed)
|
||||
{
|
||||
fprintf(stderr, "[settings] the refresh notice's %d Hz isn't recorded: can't read %s: %s\n", hz, path.c_str(), error.c_str());
|
||||
return false;
|
||||
}
|
||||
if (status == ReadStatus::Missing)
|
||||
text = "# SpeedBreaker settings. The settings menu writes every option here when one\n"
|
||||
"# changes; until then each one keeps its default.\n";
|
||||
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(lastVersion, notices)); !failed.empty())
|
||||
{
|
||||
fprintf(stderr, "[settings] the refresh notice's %d Hz isn't recorded: %s\n", hz, failed.c_str());
|
||||
return false;
|
||||
}
|
||||
std::lock_guard lock(s_mutex);
|
||||
s_fileFound = s_fileLoaded = true;
|
||||
if (LoadedPath().empty())
|
||||
LoadedPath() = path;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -297,4 +297,11 @@ namespace settings
|
||||
// is left alone (false, logged), as is one already recording `version`.
|
||||
// Every Save() writes it too.
|
||||
bool RecordVersion(std::string_view version);
|
||||
|
||||
// The display rates (whole Hz) the refresh-rate notice was closed for
|
||||
// (video/refresh_notice.h): [app] refresh_notices in settings.toml.
|
||||
// Record adds one, in memory and in the file, as RecordVersion does (only
|
||||
// the [app] table is rewritten); any thread.
|
||||
bool RefreshNoticeShown(int hz);
|
||||
bool RecordRefreshNotice(int hz);
|
||||
}
|
||||
+311
-41
@@ -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
|
||||
|
||||
@@ -48,9 +48,10 @@ namespace video
|
||||
void NoteGuestVblank(int64_t t);
|
||||
// Any thread: guest vblanks so far, and when the last one fired.
|
||||
uint64_t GuestVblanks();
|
||||
// Display refreshes per guest vblank: 1, or 2 at Frame Rate 30 (the
|
||||
// game then shows a frame every other refresh, and each guest vblank
|
||||
// is two world steps: game/frame_time.cpp).
|
||||
// Lock periods (1/60 s, VblankLock::PeriodNs: one refresh at 60 Hz, two
|
||||
// at 120 Hz) per guest vblank: 1, or 2 at Frame Rate 30 (the game then
|
||||
// shows a frame every other period, and each guest vblank is two world
|
||||
// steps: game/frame_time.cpp).
|
||||
int GuestVblankDivider();
|
||||
int64_t LastGuestVblank();
|
||||
|
||||
|
||||
@@ -6,17 +6,60 @@
|
||||
|
||||
namespace video
|
||||
{
|
||||
int RefreshFit::MultipleOf(double hz)
|
||||
{
|
||||
for (int m = 1; m <= kMaxMultiple; m++)
|
||||
if (hz >= kMinHz * m && hz <= kMaxHz * m)
|
||||
return m;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RefreshFit::SetMultiple(int multiple)
|
||||
{
|
||||
if (multiple < 1 || multiple > kMaxMultiple)
|
||||
multiple = 1;
|
||||
if (multiple == m_multiple && m_rate == double(multiple))
|
||||
return;
|
||||
m_multiple = multiple;
|
||||
m_rate = double(multiple);
|
||||
m_lineFits = false;
|
||||
m_samples.clear();
|
||||
m_estimate = RefreshEstimate{};
|
||||
m_estimate.multiple = multiple;
|
||||
}
|
||||
|
||||
void RefreshFit::SetGridHz(double hz)
|
||||
{
|
||||
const double rate = hz / 60.0;
|
||||
if (!(rate >= kMinHz / 60.0 && rate <= 8.0))
|
||||
return SetMultiple(1);
|
||||
if (m_multiple == 0 && m_rate == rate)
|
||||
return;
|
||||
m_multiple = 0;
|
||||
m_rate = rate;
|
||||
m_lineFits = false;
|
||||
m_samples.clear();
|
||||
m_estimate = RefreshEstimate{};
|
||||
m_estimate.multiple = 0;
|
||||
}
|
||||
|
||||
void RefreshFit::Add(int64_t t)
|
||||
{
|
||||
Sample s{ t, 0, m_segment };
|
||||
if (!m_samples.empty())
|
||||
{
|
||||
const Sample& last = m_samples.back();
|
||||
double period = m_estimate.valid ? m_estimate.periodNs : kNominalNs;
|
||||
int64_t n = std::llround(double(t - last.t) / period);
|
||||
// At a multiple, once the window fits a line (accepted or still
|
||||
// short of 10 s), measured from the last completion's place on
|
||||
// it: one seen more than half a refresh late (2.1 ms at 240 Hz)
|
||||
// is put on the wrong refresh, and set aside, alone; from its own
|
||||
// time every completion after it would be too.
|
||||
const bool fromLine = m_rate > 1.0 && m_lineFits;
|
||||
double period = m_estimate.valid || fromLine ? m_estimate.periodNs : kNominalNs / m_rate;
|
||||
int64_t n = fromLine ? std::llround((double(t) - double(m_estimate.gridNs)) / period) : std::llround(double(t - last.t) / period);
|
||||
if (n < 1)
|
||||
return; // within half a refresh of the last: a late wake-up, not a refresh
|
||||
if (n > kMaxGap)
|
||||
if (double(n) > double(kMaxGap) * m_rate)
|
||||
s.segment = ++m_segment;
|
||||
else
|
||||
s.k = last.k + n;
|
||||
@@ -44,6 +87,8 @@ namespace video
|
||||
}
|
||||
}
|
||||
m_estimate.valid = Acceptable(line, true);
|
||||
m_lineFits = Acceptable(line, false);
|
||||
m_estimate.multiple = m_multiple;
|
||||
m_estimate.samples = uint32_t(m_samples.size());
|
||||
m_estimate.rejected = line.rejected;
|
||||
m_estimate.rmsNs = line.rmsNs;
|
||||
@@ -60,7 +105,7 @@ namespace video
|
||||
if (!line.ok || line.rmsNs > kMaxRmsNs || line.rejected > kMaxRejected * double(line.used + line.rejected))
|
||||
return false;
|
||||
double hz = 1e9 / line.periodNs;
|
||||
return hz >= kMinHz && hz <= kMaxHz && (!needSpan || line.spanNs >= kMinSpanNs);
|
||||
return hz >= kMinHz * m_rate && hz <= kMaxHz * m_rate && (!needSpan || line.spanNs >= kMinSpanNs);
|
||||
}
|
||||
|
||||
RefreshFit::Line RefreshFit::Fit(size_t first) const
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
// the line (a late wake-up) are set aside and the line refitted without them.
|
||||
// A new display rate shows as a window that fits badly while its last 3 s fit
|
||||
// well: the older completions are dropped and the window refills.
|
||||
//
|
||||
// A display at a whole multiple of 60 Hz (120, 180, 240: SetMultiple, from
|
||||
// the display mode's rate) shows a 60 fps game a frame every `multiple`
|
||||
// refreshes, so its completions alone can't tell it from a 60 Hz one (all
|
||||
// gaps even). Told the multiple, the fit indexes completions in the display's
|
||||
// own refreshes (gaps of 2, and 1 or 3 where a frame came early or late, at
|
||||
// 120 Hz) and accepts `multiple` times 58.5-61.5 Hz: the guest vblank then
|
||||
// locks to every `multiple`th refresh (video/vblank_lock.h). At 1, the default,
|
||||
// everything is as it was. A rate that isn't such a multiple (90 Hz, 144 Hz)
|
||||
// stays at 1 and is never accepted (MultipleOf: 0).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
@@ -25,13 +35,14 @@ namespace video
|
||||
{
|
||||
struct RefreshEstimate
|
||||
{
|
||||
bool valid = false; // accepted: 58.5-61.5 Hz, at least 10 s of refreshes, small residuals
|
||||
double periodNs = 0; // one refresh
|
||||
bool valid = false; // accepted: 58.5-61.5 Hz (times `multiple`), at least 10 s of refreshes, small residuals
|
||||
double periodNs = 0; // one refresh (of the display: 8.33 ms at 120 Hz)
|
||||
int64_t gridNs = 0; // a refresh (the fitted time of the newest completion)
|
||||
double rmsNs = 0; // of the completions kept
|
||||
uint32_t samples = 0; // completions in the window
|
||||
uint32_t rejected = 0; // of which set aside as off the line
|
||||
double spanNs = 0; // refresh time the window covers (its segments together)
|
||||
int multiple = 1; // display refreshes per 60 Hz guest vblank: 1, or 2-4 (SetMultiple); 0: a grid check (SetGridHz)
|
||||
};
|
||||
|
||||
class RefreshFit
|
||||
@@ -40,10 +51,31 @@ namespace video
|
||||
// `t`, ns on the steady clock: a present was seen complete. In order.
|
||||
void Add(int64_t t);
|
||||
const RefreshEstimate& Estimate() const { return m_estimate; }
|
||||
// The display refreshes `multiple` (1-4) times per 60 Hz guest
|
||||
// vblank: its mode's rate is that multiple of 60 Hz (MultipleOf). A
|
||||
// change starts the fit over (the old completions are on another
|
||||
// grid). Out of range: 1.
|
||||
void SetMultiple(int multiple);
|
||||
int Multiple() const { return m_multiple; }
|
||||
// The whole multiple of 60 Hz (1-4) that `hz` lies within the
|
||||
// acceptance limits of (58.5-61.5 Hz times it): 1 for 59.94, 2 for
|
||||
// 119.88, 3 for 180, 4 for 239.76; 0 for 90, 143.9, 165 or an unknown
|
||||
// rate (0 or below).
|
||||
static int MultipleOf(double hz);
|
||||
// A grid check, not a lock's measure: completions indexed at `hz`
|
||||
// (the display mode's rate, any: 90, 144), accepted within 2.5% of
|
||||
// it, so a valid estimate says the display shows frames on a fixed
|
||||
// refresh grid at that rate (a variable-refresh one shows them as
|
||||
// they come, 16.7 ms apart, which fits no grid of 1/144 s). The
|
||||
// estimate's multiple is 0: never for the guest vblank. A change
|
||||
// starts the fit over.
|
||||
void SetGridHz(double hz);
|
||||
|
||||
// Acceptance limits: a 60 Hz display, give or take 2.5% (the guest
|
||||
// runs at the display's rate, and its game time with it).
|
||||
// runs at the display's rate, and its game time with it), or
|
||||
// `multiple` times that.
|
||||
static constexpr double kMinHz = 58.5, kMaxHz = 61.5;
|
||||
static constexpr int kMaxMultiple = 4; // 240 Hz
|
||||
static constexpr double kMaxRmsNs = 500'000; // 0.5 ms
|
||||
static constexpr double kMinSpanNs = 10e9; // 10 s
|
||||
static constexpr double kMaxRejected = 0.1; // of the window
|
||||
@@ -69,11 +101,14 @@ namespace video
|
||||
static constexpr int64_t kWindowNs = 20'000'000'000;
|
||||
static constexpr size_t kKeepSamples = 720;
|
||||
static constexpr int64_t kRecentNs = 3'000'000'000;
|
||||
static constexpr int64_t kMaxGap = 8; // refreshes; longer gaps start a segment
|
||||
static constexpr int64_t kMaxGap = 8; // refreshes (times the multiple: 8 vblanks); longer gaps start a segment
|
||||
static constexpr double kNominalNs = 1e9 / 60.0;
|
||||
|
||||
std::deque<Sample> m_samples;
|
||||
uint32_t m_segment = 0;
|
||||
int m_multiple = 1;
|
||||
double m_rate = 1.0; // refreshes per 1/60 s: the multiple, or a grid check's hz / 60
|
||||
bool m_lineFits = false; // the last fit was a good line (Acceptable without the span)
|
||||
RefreshEstimate m_estimate;
|
||||
mutable std::vector<double> m_scratch;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Which displays the guest vblank locks to every 2nd, 3rd or 4th refresh of
|
||||
// (video/refresh_fit.h's multiple, video/vblank_lock.h), from the display
|
||||
// mode's refresh rate (SDL's), and the log line that says so. Header only:
|
||||
// tests/refresh_fit_test.cpp checks it.
|
||||
//
|
||||
// Only the multiple comes from the mode: the period and phase are measured
|
||||
// from present completions, as at 60 Hz (the mode's rate is gamescope's CVT
|
||||
// one under gamescope, 59.94 for a 59.97 Hz monitor). At 60 Hz nothing
|
||||
// changes. A rate that is no whole multiple of 60 (90 Hz, the Steam Deck
|
||||
// OLED's; 144 Hz; 165 Hz) gets a line saying why the vblank free-runs there.
|
||||
//
|
||||
// Auto (NFSMW_REFRESH_MULTIPLE unset) is on everywhere but on Apple's
|
||||
// systems: there a MacBook Pro's ProMotion panel reports 120 Hz but has no
|
||||
// fixed refresh grid (frames are shown when their GPU work is done, on a
|
||||
// 240 Hz timing grid), and new frames are shown at a set time after their
|
||||
// vblank instead (video/present_schedule.h), measured to work on it and on a
|
||||
// player's external 120 Hz monitor (GitHub issue #20). NFSMW_REFRESH_MULTIPLE
|
||||
// =0: never (v0.1.6); =1: from the mode, on Apple's systems too; =2/3/4: that
|
||||
// multiple whatever the mode says (a display that misreports its rate).
|
||||
#pragma once
|
||||
#include "refresh_fit.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct RefreshMultiple
|
||||
{
|
||||
enum class Mode
|
||||
{
|
||||
Auto, // unset
|
||||
Off, // "0"
|
||||
On, // "1"
|
||||
Forced, // "2" to "4": `forced`
|
||||
};
|
||||
Mode mode = Mode::Auto;
|
||||
int forced = 0;
|
||||
|
||||
static RefreshMultiple FromEnv(const char* value)
|
||||
{
|
||||
RefreshMultiple r;
|
||||
if (!value || !*value)
|
||||
return r;
|
||||
if (!std::strcmp(value, "0"))
|
||||
r.mode = Mode::Off;
|
||||
else if (!std::strcmp(value, "1"))
|
||||
r.mode = Mode::On;
|
||||
else if (value[1] == 0 && value[0] >= '2' && value[0] <= '0' + RefreshFit::kMaxMultiple)
|
||||
{
|
||||
r.mode = Mode::Forced;
|
||||
r.forced = value[0] - '0';
|
||||
}
|
||||
return r; // anything else: Auto
|
||||
}
|
||||
|
||||
// The multiple RefreshFit should expect on a display whose mode runs
|
||||
// at `hz` (0: unknown): 1 (as v0.1.6), or 2-4.
|
||||
int For(double hz, bool apple) const
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case Mode::Off:
|
||||
return 1;
|
||||
case Mode::Forced:
|
||||
return forced;
|
||||
case Mode::Auto:
|
||||
if (apple)
|
||||
return 1;
|
||||
[[fallthrough]];
|
||||
case Mode::On:
|
||||
break;
|
||||
}
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
return m >= 2 ? m : 1;
|
||||
}
|
||||
|
||||
// The [vblank] line for a display mode of `hz` given multiple `m`
|
||||
// (For's), or "" where there is nothing to say: 60 Hz, an unknown
|
||||
// rate, Off, and Auto on Apple's systems (as v0.1.6 there).
|
||||
std::string Describe(double hz, int m, bool apple) const
|
||||
{
|
||||
char line[400];
|
||||
if (m >= 2)
|
||||
{
|
||||
std::snprintf(line, sizeof line, "a %.2f Hz display%s: the guest vblank locks to every %s refresh once presents "
|
||||
"measure it, and new frames are held for their refresh (NFSMW_REFRESH_MULTIPLE=0: as v0.1.6)", hz,
|
||||
mode == Mode::Forced ? " (NFSMW_REFRESH_MULTIPLE)" : "", m == 2 ? "2nd" : m == 3 ? "3rd" : "4th");
|
||||
return line;
|
||||
}
|
||||
if (mode == Mode::Off || (mode == Mode::Auto && apple) || !(hz > 0) || RefreshFit::MultipleOf(hz) >= 1)
|
||||
return {}; // 60 Hz (or a multiple held at 1: Forced never is, For never says 1 for one)
|
||||
const double perFrame = hz / 60.0;
|
||||
if (hz < RefreshFit::kMinHz)
|
||||
std::snprintf(line, sizeof line, "a %.2f Hz display is slower than 60 Hz: the guest vblank free-runs at 60 Hz, and "
|
||||
"some frames are never shown (%.2f refreshes a frame)", hz, perFrame);
|
||||
else if (hz > RefreshFit::kMaxHz * RefreshFit::kMaxMultiple)
|
||||
std::snprintf(line, sizeof line, "a %.2f Hz display is faster than the lock's 240 Hz: the guest vblank free-runs at "
|
||||
"60 Hz, and frames stay on screen for about %.2f refreshes each", hz, perFrame);
|
||||
else
|
||||
std::snprintf(line, sizeof line, "a %.2f Hz display is no whole multiple of 60 Hz: the guest vblank free-runs at 60 Hz, "
|
||||
"and each frame stays on screen for %d or %d refreshes (%.2f a frame); at 60 or 120 Hz, or with variable refresh, "
|
||||
"frames show evenly", hz, int(std::floor(perFrame)), int(std::floor(perFrame)) + 1, perFrame);
|
||||
return line;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The one-time notice on a display whose refresh rate 60 fps can't divide
|
||||
// evenly (the Steam Deck OLED at 90 Hz; 144 and 165 Hz monitors), and no
|
||||
// variable refresh: frames there stay on screen for 1 or 2 (2 or 3)
|
||||
// refreshes in turn, which looks like stutter, and only the player can
|
||||
// change the rate. When it shows and what it says; the presenter feeds it
|
||||
// (video/presenter.cpp), ui::ShowNotice draws it. Header only:
|
||||
// tests/refresh_notice_test.cpp checks it.
|
||||
//
|
||||
// Shown when, all at once:
|
||||
// - the display mode's rate (SDL's) is no whole multiple of 60 Hz
|
||||
// (RefreshFit::MultipleOf 0), from 58.5 Hz up, and it hasn't changed for
|
||||
// kStableSec;
|
||||
// - the display is a fixed-refresh one at that rate, as far as can be told:
|
||||
// on SteamOS, Linux and Windows the present waits' completions fit a grid
|
||||
// at the mode's rate (RefreshFit::SetGridHz) for kStableSec (a display
|
||||
// with variable refresh shows each frame as it comes, which fits no such
|
||||
// grid; the vblank lock's own "the display follows the vblank" is no use
|
||||
// here: at such a rate it isn't at the display's multiple, and the
|
||||
// intro's 30 fps movies, 3 refreshes a frame at 90 Hz, fit its 60 Hz line
|
||||
// until it gives up on a fixed display too); on a Mac,
|
||||
// macOS says the screen's refresh is fixed (its present waits sit on no
|
||||
// grid: a ProMotion panel, or one with adaptive sync, never shows it);
|
||||
// - not on iOS, nor on arm64 Linux (the Steam Frame: a headset's
|
||||
// compositor sets the rate);
|
||||
// - the game has shown frames for kSessionSec (past the logos and the
|
||||
// first loads), they have come without a gap of kGapMs for kFlowingSec
|
||||
// (not a load), no movie has played for kMovieQuietSec (the intro, a
|
||||
// cutscene), and the controls have been idle for kIdleSec (a menu, a
|
||||
// pause, the title screen; not a race: its throttle is held), with no
|
||||
// other panel or the settings menu open;
|
||||
// - not shown for this rate before (settings.toml's [app] refresh_notices;
|
||||
// NFSMW_REFRESH_NOTICE=0 never, =1 again every launch).
|
||||
// Game Mode's words are for the Deck's own screen (Quick Access's Refresh
|
||||
// Rate) unless a monitor or TV is connected (NFSMW_REFRESH_NOTICE_SCREEN=
|
||||
// builtin|external decides instead, for tests).
|
||||
// While it shows the game pauses, as for the settings menu (the 360's
|
||||
// Guide); A, B, Start, Esc or OK closes it, and the rate is recorded then.
|
||||
#pragma once
|
||||
#include "refresh_fit.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct RefreshNotice
|
||||
{
|
||||
enum class Platform
|
||||
{
|
||||
GameMode, // SteamOS Game Mode (gamescope)
|
||||
Desktop, // desktop Linux, Windows
|
||||
Mac,
|
||||
Excluded, // iOS, arm64 Linux (the Steam Frame)
|
||||
};
|
||||
struct Inputs
|
||||
{
|
||||
double modeHz = 0; // SDL's display mode
|
||||
double modeStableSec = 0; // since it last changed
|
||||
Platform platform = Platform::Desktop;
|
||||
bool externalScreen = false; // Game Mode: a monitor or TV, not the Deck's own screen
|
||||
bool gridFixed = false; // the grid check fits (not on a Mac)
|
||||
double gridStableSec = 0; // ...for this long
|
||||
bool macFixed = false; // macOS: the screen's refresh is fixed (min = max interval, not built in)
|
||||
bool shownBefore = false; // this rate is in settings.toml's [app] refresh_notices
|
||||
int forced = -1; // NFSMW_REFRESH_NOTICE: -1 unset, 0 never, 1 always
|
||||
double sessionSec = 0; // since the first game frame
|
||||
double flowingSec = 0; // since the last gap of kGapMs between game frames
|
||||
double movieQuietSec = 1e9; // since the last movie frame (the XMV player's)
|
||||
double idleSec = 0; // since the controls were last used
|
||||
bool uiBusy = false; // a panel or the settings menu is open
|
||||
};
|
||||
enum class Verdict
|
||||
{
|
||||
NotApplicable, // 60 Hz, a whole multiple, an unknown rate: nothing to say
|
||||
Never, // this rate, here: not shown (reason())
|
||||
Wait, // maybe later (reason())
|
||||
Show,
|
||||
};
|
||||
struct Decision
|
||||
{
|
||||
Verdict verdict = Verdict::NotApplicable;
|
||||
const char* reason = "";
|
||||
};
|
||||
|
||||
static constexpr double kStableSec = 5.0;
|
||||
static constexpr double kSessionSec = 30.0;
|
||||
static constexpr double kFlowingSec = 5.0;
|
||||
static constexpr double kGapMs = 250.0;
|
||||
static constexpr double kIdleSec = 3.0;
|
||||
static constexpr double kMovieQuietSec = 2.0;
|
||||
|
||||
// The rate as settings.toml records it: whole hertz.
|
||||
static int Key(double hz) { return int(std::lround(hz)); }
|
||||
|
||||
static Decision Decide(const Inputs& in)
|
||||
{
|
||||
if (!(in.modeHz >= RefreshFit::kMinHz) || RefreshFit::MultipleOf(in.modeHz) != 0)
|
||||
return { Verdict::NotApplicable, "" };
|
||||
if (in.forced == 0)
|
||||
return { Verdict::Never, "NFSMW_REFRESH_NOTICE=0" };
|
||||
if (in.platform == Platform::Excluded)
|
||||
return { Verdict::Never, "not on this device (iOS, or a headset's compositor)" };
|
||||
if (in.shownBefore && in.forced != 1)
|
||||
return { Verdict::Never, "dismissed before for this rate (settings.toml [app] refresh_notices)" };
|
||||
if (in.platform == Platform::Mac)
|
||||
{
|
||||
if (!in.macFixed)
|
||||
return { Verdict::Never, "macOS: a variable refresh rate (ProMotion, adaptive sync) or the built-in screen" };
|
||||
}
|
||||
else if (!in.gridFixed)
|
||||
return { Verdict::Wait, "no fixed refresh grid measured yet (variable refresh shows none)" };
|
||||
if (in.modeStableSec < kStableSec || (in.platform != Platform::Mac && in.gridStableSec < kStableSec))
|
||||
return { Verdict::Wait, "the rate isn't settled yet" };
|
||||
if (in.sessionSec < kSessionSec)
|
||||
return { Verdict::Wait, "starting up" };
|
||||
if (in.flowingSec < kFlowingSec)
|
||||
return { Verdict::Wait, "loading" };
|
||||
if (in.movieQuietSec < kMovieQuietSec)
|
||||
return { Verdict::Wait, "a movie is playing" };
|
||||
if (in.uiBusy)
|
||||
return { Verdict::Wait, "another panel is open" };
|
||||
if (in.idleSec < kIdleSec)
|
||||
return { Verdict::Wait, "the controls are in use (playing)" };
|
||||
return { Verdict::Show, "" };
|
||||
}
|
||||
|
||||
static constexpr const char* kTitle = "Screen refresh rate";
|
||||
|
||||
// What it says.
|
||||
static std::string Text(const Inputs& in)
|
||||
{
|
||||
char head[160];
|
||||
std::snprintf(head, sizeof head, "This screen is running at %d Hz, which doesn't divide 60 fps evenly, so motion can stutter. ",
|
||||
Key(in.modeHz));
|
||||
if (in.platform == Platform::GameMode && !in.externalScreen)
|
||||
return std::string(head) + "For smooth play, set Refresh Rate to 60 Hz in Quick Access > Performance. Turn on "
|
||||
"Use per-game profile there and Steam keeps it for this game.";
|
||||
if (in.platform == Platform::GameMode)
|
||||
return std::string(head) + "For smooth play, set the screen to 60 or 120 Hz in Steam's display settings, or turn "
|
||||
"on variable refresh if it has it.";
|
||||
return std::string(head) + "For smooth play, set it to 60 or 120 Hz in your display settings, or turn on variable "
|
||||
"refresh (FreeSync, G-SYNC, Adaptive-Sync) if the screen has it.";
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace video
|
||||
{
|
||||
@@ -15,24 +16,47 @@ namespace video
|
||||
m_locked = locked;
|
||||
m_steadyVblanks = 0;
|
||||
m_slipLeftNs = 0;
|
||||
// AdaptHold starts over with each lock (another display, maybe),
|
||||
// at the refresh right after the vblank's.
|
||||
m_extra = locked ? 1 - m_display.multiple : 0;
|
||||
m_earlierBackoff = 0;
|
||||
m_lastEarlier = false;
|
||||
m_sinceExtra = 0;
|
||||
m_earlyRun = m_earlyLate = 0;
|
||||
std::fill(m_missed, m_missed + kHistory, false);
|
||||
}
|
||||
if (!locked)
|
||||
return prev + m_fallbackNs;
|
||||
// One refresh, and a guest vblank's worth of them (1 at 60 Hz).
|
||||
const double period = m_display.periodNs;
|
||||
m_leadNs = period - double(kAfterRefreshNs);
|
||||
double candidate = double(prev) + period;
|
||||
const double vblank = period * m_display.multiple;
|
||||
m_leadNs = vblank - double(kAfterRefreshNs);
|
||||
double candidate = double(prev) + vblank;
|
||||
if (m_slipLeftNs > 0)
|
||||
{
|
||||
double step = std::min(double(kMaxStepNs), m_slipLeftNs);
|
||||
m_slipLeftNs -= step;
|
||||
return int64_t(std::llround(candidate + step));
|
||||
}
|
||||
// The wanted vblanks are grid + k * period - lead; the nearest one.
|
||||
// The wanted vblanks are grid + k * period - lead (k any refresh,
|
||||
// also at a multiple); the nearest one.
|
||||
double x = (candidate + m_leadNs - double(m_display.gridNs)) / period;
|
||||
m_errorNs = (std::round(x) - x) * period;
|
||||
double step = std::clamp(m_errorNs, -double(kMaxStepNs), double(kMaxStepNs));
|
||||
m_steadyVblanks = std::abs(m_errorNs) <= double(kMaxStepNs) ? m_steadyVblanks + 1 : 0;
|
||||
m_chasingVblanks = std::abs(m_errorNs) > double(kMaxStepNs) ? m_chasingVblanks + 1 : 0;
|
||||
// At a multiple, also the steps' sum: a display that follows the
|
||||
// vblank (its frames shown as they come, unheld at the first
|
||||
// target) keeps the lock stepping one way, though the fit's lag lets
|
||||
// the error reach a step now and then (which resets the count
|
||||
// above). A fixed display's grid is reached within half a refresh
|
||||
// of steps and then held; 10 s held forgets the walk.
|
||||
if (m_display.multiple >= 2)
|
||||
{
|
||||
m_chaseSumNs = m_steadyVblanks >= kSteadyToForget ? 0.0 : m_chaseSumNs + step;
|
||||
if (std::abs(m_chaseSumNs) > 1.5 * period)
|
||||
m_chasingVblanks = std::numeric_limits<uint32_t>::max() / 2;
|
||||
}
|
||||
if (double(m_chasingVblanks) * double(kMaxStepNs) > period)
|
||||
{
|
||||
m_follows = true;
|
||||
@@ -48,6 +72,30 @@ namespace video
|
||||
m_steadyVblanks = 0;
|
||||
m_chasingVblanks = 0;
|
||||
m_count = 0;
|
||||
m_lastOnScreen = 0;
|
||||
m_chaseSumNs = 0;
|
||||
}
|
||||
|
||||
void VblankLock::SetHold(bool on)
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_hold = on;
|
||||
}
|
||||
|
||||
int64_t VblankLock::PresentAt(int64_t vblank) const
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
if (!m_locked || m_display.multiple < 2 || !m_hold || !vblank)
|
||||
return 0;
|
||||
// The last refresh at or before the vblank (it fires kAfterRefreshNs
|
||||
// after one, a little later when its thread wakes late), and the
|
||||
// target `multiple` + m_extra refreshes on (AdaptHold; 1 at first):
|
||||
// rounding instead would take the next refresh for a vblank woken
|
||||
// more than half a refresh late.
|
||||
const double period = m_display.periodNs;
|
||||
const double k = std::floor(double(vblank - m_display.gridNs) / period);
|
||||
const double target = double(m_display.gridNs) + (k + m_display.multiple + m_extra) * period;
|
||||
return int64_t(std::llround(target - period + double(kHoldAfterRefreshNs)));
|
||||
}
|
||||
|
||||
void VblankLock::SetDisplay(const RefreshEstimate& estimate)
|
||||
@@ -56,20 +104,39 @@ namespace video
|
||||
m_display = estimate;
|
||||
}
|
||||
|
||||
void VblankLock::Observe(int64_t vblank, int64_t presented, int64_t onScreen)
|
||||
void VblankLock::Observe(int64_t vblank, int64_t presented, int64_t onScreen, int64_t ready)
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
if (!ready)
|
||||
ready = presented;
|
||||
uint32_t slot = m_count++ % kHistory;
|
||||
m_ready[slot] = float(presented - vblank);
|
||||
m_ready[slot] = float(ready - vblank);
|
||||
m_latency[slot] = float(onScreen - vblank);
|
||||
m_observed++;
|
||||
if (!m_locked)
|
||||
{
|
||||
m_late[slot] = 0;
|
||||
m_lastOnScreen = 0;
|
||||
return;
|
||||
}
|
||||
const double period = m_display.periodNs;
|
||||
m_late[slot] = int32_t(std::llround((double(onScreen - vblank) - m_leadNs) / period));
|
||||
if (m_display.multiple >= 2)
|
||||
{
|
||||
// Its predecessor's time on screen in refreshes, against the
|
||||
// vblanks between them (a gap of more than 0.5 s is a pause).
|
||||
if (m_lastOnScreen && onScreen - m_lastOnScreen <= 500'000'000)
|
||||
{
|
||||
const int64_t shown = std::llround(double(onScreen - m_lastOnScreen) / period);
|
||||
const int64_t wanted = std::max<int64_t>(1, std::llround(double(vblank - m_lastVblank) / (period * m_display.multiple))) *
|
||||
m_display.multiple;
|
||||
(shown == wanted ? m_even : shown < wanted ? m_shorter : m_longer)++;
|
||||
}
|
||||
m_lastOnScreen = onScreen;
|
||||
m_lastVblank = vblank;
|
||||
AdaptHold(vblank, ready, presented, onScreen);
|
||||
return; // nothing queues here (the display has spare refreshes): no slips
|
||||
}
|
||||
// Every frame of the last 2 s showed at least a refresh after its
|
||||
// target, with the phase settled and no slip for as long: a frame is
|
||||
// queued. Slip a refresh to drain it.
|
||||
@@ -94,6 +161,75 @@ namespace video
|
||||
}
|
||||
}
|
||||
|
||||
// Observe at a multiple, under the lock: frames held for later refreshes
|
||||
// when too many miss theirs, and for earlier ones again when every frame
|
||||
// for a while was ready in time for them; and a display that shows held
|
||||
// frames at once (variable refresh) let go of. As Observe's arguments.
|
||||
void VblankLock::AdaptHold(int64_t vblank, int64_t ready, int64_t presented, int64_t onScreen)
|
||||
{
|
||||
const double period = m_display.periodNs;
|
||||
if (presented > ready) // held (PresentAt)
|
||||
{
|
||||
// Counted across locks, as the chase is: such a display soon
|
||||
// stops fitting a line, and each lock is short.
|
||||
m_quick[m_quickNext] = double(onScreen - presented) < period / 2;
|
||||
m_quickNext = (m_quickNext + 1) % kHistory;
|
||||
m_heldSeen = std::min(m_heldSeen + 1, kHistory);
|
||||
if (m_heldSeen >= kQuickToFollow && uint32_t(std::count(m_quick, m_quick + kHistory, true)) * 3 >= m_heldSeen * 2)
|
||||
{
|
||||
m_follows = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The frame's target with no extra refreshes (the 60 Hz one,
|
||||
// PresentAt's at m_extra 0), and whether it was ready half a
|
||||
// refresh and kHoldMarginNs before the target one refresh earlier
|
||||
// than now.
|
||||
// Missed: seen a refresh or more after its held-for one (three
|
||||
// quarters of one: a present wait that returns late, as 1% do by up
|
||||
// to 2 ms, isn't a frame shown late at 240 Hz).
|
||||
const double k = std::floor(double(vblank - m_display.gridNs) / period);
|
||||
const double target = double(m_display.gridNs) + (k + m_display.multiple) * period;
|
||||
m_missed[m_missedNext] = double(onScreen) - target > (double(m_extra) + 0.75) * period;
|
||||
m_missedNext = (m_missedNext + 1) % kHistory;
|
||||
const double fewerAt = target + double(m_extra - 1) * period - period / 2 - double(kHoldMarginNs);
|
||||
if (m_extra > 1 - m_display.multiple)
|
||||
{
|
||||
m_earlyLate += double(ready) > fewerAt;
|
||||
m_earlyRun++;
|
||||
if (m_earlyLate > kEarlyExceptions)
|
||||
m_earlyRun = m_earlyLate = 0;
|
||||
}
|
||||
m_sinceExtra++;
|
||||
if (m_sinceExtra < kHistory || m_steadyVblanks < kHistory || m_count < kHistory)
|
||||
return;
|
||||
const uint32_t missed = uint32_t(std::count(m_missed, m_missed + kHistory, true));
|
||||
if (missed >= kMissedToHoldLonger && m_extra < kMaxExtraVblanks * m_display.multiple)
|
||||
{
|
||||
// An earlier target that didn't last (missed within 6 s): wait
|
||||
// twice as long before the next try.
|
||||
if (m_lastEarlier && m_sinceExtra < 3 * kHistory)
|
||||
m_earlierBackoff = std::min(m_earlierBackoff + 1, kMaxEarlierBackoff);
|
||||
m_lastEarlier = false;
|
||||
m_extra++;
|
||||
m_extraChanges++;
|
||||
m_extraMissed = missed;
|
||||
m_sinceExtra = 0;
|
||||
m_earlyRun = m_earlyLate = 0;
|
||||
std::fill(m_missed, m_missed + kHistory, false);
|
||||
}
|
||||
else if (m_earlyRun >= kEarlyToHoldShorter << m_earlierBackoff)
|
||||
{
|
||||
m_lastEarlier = true;
|
||||
m_extra--;
|
||||
m_extraChanges++;
|
||||
m_extraMissed = 0;
|
||||
m_sinceExtra = 0;
|
||||
m_earlyRun = m_earlyLate = 0;
|
||||
std::fill(m_missed, m_missed + kHistory, false);
|
||||
}
|
||||
}
|
||||
|
||||
bool VblankLock::Locked() const
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
@@ -103,7 +239,7 @@ namespace video
|
||||
double VblankLock::PeriodNs() const
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
return m_locked ? m_display.periodNs : double(m_fallbackNs);
|
||||
return m_locked ? m_display.periodNs * m_display.multiple : double(m_fallbackNs);
|
||||
}
|
||||
|
||||
VblankLock::Status VblankLock::GetStatus() const
|
||||
@@ -111,13 +247,19 @@ namespace video
|
||||
std::lock_guard lock(m_mutex);
|
||||
Status s;
|
||||
s.locked = m_locked;
|
||||
s.periodNs = m_locked ? m_display.periodNs : double(m_fallbackNs);
|
||||
s.periodNs = m_locked ? m_display.periodNs * m_display.multiple : double(m_fallbackNs);
|
||||
s.leadNs = m_leadNs;
|
||||
s.errorNs = m_errorNs;
|
||||
s.slips = m_slips;
|
||||
s.follows = m_follows;
|
||||
s.observed = m_observed;
|
||||
s.display = m_display;
|
||||
s.even = m_even;
|
||||
s.shorter = m_shorter;
|
||||
s.longer = m_longer;
|
||||
s.extra = m_extra;
|
||||
s.extraChanges = m_extraChanges;
|
||||
s.extraMissed = m_extraMissed;
|
||||
uint32_t n = std::min(m_count, kHistory);
|
||||
if (n)
|
||||
{
|
||||
|
||||
+101
-5
@@ -19,6 +19,40 @@
|
||||
// frames earlier they weren't queued but slow to present, and that lateness
|
||||
// is accepted from then on (one frame was shown twice to learn it).
|
||||
//
|
||||
// On a display at a whole multiple of 60 Hz (120, 180, 240: RefreshEstimate's
|
||||
// `multiple`, from video/refresh_fit.h) a guest vblank is `multiple`
|
||||
// refreshes: the vblank lands 1 ms after a refresh (any of them: the phase is
|
||||
// kept to the refresh grid), and its frame targets the refresh `multiple`
|
||||
// later, the one before the next vblank, as at 60 Hz (the same 15.7 ms from
|
||||
// vblank to screen). FIFO there shows a present on the first refresh after it,
|
||||
// though, with nothing queued (the display has spare refreshes): a frame ready
|
||||
// in under one refresh would show early and the one after it late, a
|
||||
// 1-and-3 pair at 120 Hz. So the presenter holds each new frame until just
|
||||
// after the refresh before its target (PresentAt), and every frame stays on
|
||||
// screen for exactly `multiple` refreshes, or one more when it misses its
|
||||
// refresh (with the next one shown on time, a refresh short). The target
|
||||
// starts at the refresh right after the vblank's (vblank to screen 7.3 ms at
|
||||
// 120 Hz, where it is 15.7 at 60): a frame ready within that refresh needs no
|
||||
// hold. Frames that miss it aren't queued as at 60 Hz (where FIFO's queue
|
||||
// then holds every later frame a refresh back, evenly, and a slip drains it):
|
||||
// when several of the last 2 s missed their refresh, the target moves a
|
||||
// refresh later (AdaptHold), up to two vblanks past the 60 Hz one, and a
|
||||
// refresh earlier again after 10 s of frames all ready half a refresh before
|
||||
// that earlier one (twice as long after each earlier target that missed
|
||||
// within 6 s, up to 160 s). There are no slips; the
|
||||
// "follows" chase counts in refreshes, as at 60 Hz. A display that follows
|
||||
// the vblank there (variable refresh in a 120 Hz mode, say) would show held
|
||||
// frames as soon as they are presented and so fit any grid the hold makes:
|
||||
// held frames seen on screen less than half a refresh after their present
|
||||
// (two thirds of the last 30-120 of them, across locks) are that, and the
|
||||
// lock lets go for the run, as at 60 Hz (on a fixed display a held frame
|
||||
// waits a refresh, so this can only take a fixed display for one where the
|
||||
// present waits return more than half a refresh late: it then free-runs,
|
||||
// as in v0.1.6). At the first target frames aren't held: there the lock's
|
||||
// steps adding up to more than a refresh and a half one way, without 10 s
|
||||
// held at the grid between, are that display (a fixed one's grid is
|
||||
// reached in half a refresh of steps).
|
||||
//
|
||||
// Without a valid measurement (no present wait, a display outside 58.5-61.5 Hz,
|
||||
// noisy completions) the vblank free-runs at `fallbackNs`, as before. So it
|
||||
// does, for the rest of the run, when the refreshes move with the vblank: a
|
||||
@@ -40,15 +74,26 @@ namespace video
|
||||
struct Status
|
||||
{
|
||||
bool locked = false;
|
||||
double periodNs = 0; // the vblank period in use
|
||||
double periodNs = 0; // the vblank period in use (locked: `display.multiple` refreshes)
|
||||
double leadNs = 0; // locked: before the refresh each vblank targets
|
||||
double errorNs = 0; // locked: the last vblank's distance from its wanted time
|
||||
uint64_t slips = 0; // refreshes slipped to drain queued frames
|
||||
bool follows = false; // the display follows the vblank: never locked again
|
||||
uint64_t observed = 0; // game frames timed on screen
|
||||
double latencyNs = 0; // median vblank -> on screen, recent frames
|
||||
double readyNs = 0; // 99th percentile vblank -> presented, recent frames
|
||||
double readyNs = 0; // 99th percentile vblank -> presented, recent frames (ready: before any hold)
|
||||
RefreshEstimate display; // the measurement
|
||||
// Locked at a multiple: new frames by refreshes on screen against
|
||||
// the vblanks' (since the lock): as many, fewer (shown early or
|
||||
// after a late one), more (late, or repeated). 0 at 60 Hz.
|
||||
uint64_t even = 0, shorter = 0, longer = 0;
|
||||
// Locked at a multiple: new frames target the refresh `multiple`
|
||||
// + extra after the last one before their vblank (AdaptHold;
|
||||
// extra starts at 1 - multiple: the next refresh), its changes,
|
||||
// and the misses in the last 2 s that made it later (0: earlier).
|
||||
int32_t extra = 0;
|
||||
uint64_t extraChanges = 0;
|
||||
uint32_t extraMissed = 0;
|
||||
};
|
||||
|
||||
VblankLock(int64_t fallbackNs, bool enabled) : m_fallbackNs(fallbackNs), m_enabled(enabled) {}
|
||||
@@ -65,16 +110,50 @@ namespace video
|
||||
// The display's refresh grid (from the presenter's wait thread).
|
||||
void SetDisplay(const RefreshEstimate& estimate);
|
||||
// A game frame, released by the vblank at `vblank`, was presented at
|
||||
// `presented` and seen on screen at `onScreen` (ns, steady clock).
|
||||
void Observe(int64_t vblank, int64_t presented, int64_t onScreen);
|
||||
// `presented` and seen on screen at `onScreen` (ns, steady clock);
|
||||
// `ready`: when it was ready to present, if it was held for its
|
||||
// refresh before that (PresentAt; 0: = presented).
|
||||
void Observe(int64_t vblank, int64_t presented, int64_t onScreen, int64_t ready = 0);
|
||||
Status GetStatus() const;
|
||||
bool Locked() const;
|
||||
double PeriodNs() const; // the vblank period now
|
||||
double PeriodNs() const; // the vblank period now (locked at a multiple: that many refreshes)
|
||||
// Main thread, before presenting a new game frame released by the
|
||||
// vblank that fired at `vblank`: not before the time returned (ns,
|
||||
// steady clock; 0: at once). Locked at a multiple only: just after the
|
||||
// refresh before the one the frame targets (the refresh `multiple`
|
||||
// after the last refresh before `vblank`, plus any lateness the lock
|
||||
// has accepted), so FIFO shows it on its target and no sooner.
|
||||
int64_t PresentAt(int64_t vblank) const;
|
||||
// NFSMW_PRESENT_HOLD=0: frames are presented when ready at a multiple
|
||||
// too (the lock alone, for comparison).
|
||||
void SetHold(bool on);
|
||||
|
||||
static constexpr int64_t kMaxStepNs = 50'000; // phase moved per vblank
|
||||
static constexpr int64_t kAfterRefreshNs = 1'000'000; // vblank this long after the refresh
|
||||
// A held frame is presented this long after the refresh before its
|
||||
// target (the fitted grid: a present seen on screen, a little after
|
||||
// the refresh itself), so the compositor latches it for the target.
|
||||
static constexpr int64_t kHoldAfterRefreshNs = 250'000;
|
||||
// AdaptHold: the target a refresh later when this many of the last
|
||||
// kHistory missed their refresh (one late frame, a stall's, doesn't),
|
||||
// at most kMaxExtraVblanks vblanks past the 60 Hz one; a refresh
|
||||
// earlier after kEarlyToHoldShorter frames in a row (times 2 to the
|
||||
// backoff: an earlier target that missed within 6 s doubles it, up
|
||||
// to kMaxEarlierBackoff) ready half a refresh and kHoldMarginNs
|
||||
// before that earlier target (the compositor's latch and the GPU's
|
||||
// last work fit in that).
|
||||
static constexpr uint32_t kMissedToHoldLonger = 2;
|
||||
static constexpr int32_t kMaxExtraVblanks = 2;
|
||||
static constexpr uint32_t kEarlyToHoldShorter = 600; // 10 s
|
||||
static constexpr uint32_t kEarlyExceptions = 0;
|
||||
static constexpr uint32_t kMaxEarlierBackoff = 4; // 160 s
|
||||
static constexpr int64_t kHoldMarginNs = 500'000;
|
||||
static constexpr uint32_t kQuickToFollow = 30; // held frames seen before judging
|
||||
static constexpr uint32_t kSteadyToForget = 600; // vblanks at the grid that forget the steps' sum
|
||||
|
||||
private:
|
||||
void AdaptHold(int64_t vblank, int64_t ready, int64_t presented, int64_t onScreen);
|
||||
|
||||
mutable std::mutex m_mutex;
|
||||
const int64_t m_fallbackNs;
|
||||
const bool m_enabled;
|
||||
@@ -104,5 +183,22 @@ namespace video
|
||||
// (a long present path), so lateness up to it is accepted.
|
||||
int32_t m_acceptedLate = 0, m_lateAtSlip = 0;
|
||||
bool m_slipJudged = true;
|
||||
bool m_hold = true;
|
||||
// Locked at a multiple: the last observed frame (cadence counts), and
|
||||
// AdaptHold's state: refreshes held past the target, which of the
|
||||
// last kHistory frames missed theirs, frames since a change, and
|
||||
// frames in a row ready in time for one refresh less.
|
||||
int64_t m_lastOnScreen = 0, m_lastVblank = 0;
|
||||
uint64_t m_even = 0, m_shorter = 0, m_longer = 0;
|
||||
int32_t m_extra = 0;
|
||||
bool m_missed[kHistory] = {};
|
||||
uint32_t m_missedNext = 0, m_sinceExtra = 0, m_earlyRun = 0, m_earlyLate = 0, m_extraMissed = 0, m_earlierBackoff = 0;
|
||||
bool m_lastEarlier = false;
|
||||
double m_chaseSumNs = 0; // at a multiple: the lock's steps since 10 s held (variable refresh)
|
||||
// Held frames seen on screen within half a refresh of their present,
|
||||
// of the last kHistory held ones (variable refresh).
|
||||
bool m_quick[kHistory] = {};
|
||||
uint32_t m_quickNext = 0, m_heldSeen = 0;
|
||||
uint64_t m_extraChanges = 0;
|
||||
};
|
||||
}
|
||||
+49
-2
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -1,13 +1,19 @@
|
||||
// Synthetic-timestamp tests for video::RefreshFit (runtime/video/refresh_fit.cpp).
|
||||
// From the repo root:
|
||||
// Synthetic-timestamp tests for video::RefreshFit (runtime/video/refresh_fit.cpp)
|
||||
// and which displays it is told are a whole multiple of 60 Hz
|
||||
// (runtime/video/refresh_multiple.h). Sections 1-8 are the 60 Hz fit's (as in
|
||||
// v0.1.6); 9-15 the multiples (120, 180, 240 Hz), the rates that aren't
|
||||
// (143.9, 90 Hz), jitter, the policy, and planted wrong configurations that
|
||||
// must each fail a check. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/refresh_fit_test.cpp runtime/video/refresh_fit.cpp -o build/refresh_fit_test
|
||||
// build/refresh_fit_test
|
||||
#include <video/refresh_fit.h>
|
||||
#include <video/refresh_multiple.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
using video::RefreshFit;
|
||||
using video::RefreshEstimate;
|
||||
@@ -175,6 +181,227 @@ int main()
|
||||
CHECK(std::abs(got - hz) < 5e-4 && std::abs(got - 59.972616) > 0.02, "%.5f", got);
|
||||
}
|
||||
|
||||
// ---- Whole multiples of 60 Hz (refresh_multiple.h, vblank_lock.h) ----
|
||||
// A 60 fps game on a display of `multiple` x 60 Hz: a new frame every
|
||||
// `multiple` refreshes, `odd` of them a refresh early or late (gaps of
|
||||
// multiple -+ 1: the 1-and-3 pairs at 120 Hz), plus wake-up jitter.
|
||||
struct MultipleRun
|
||||
{
|
||||
bool valid = false;
|
||||
double hz = 0, rmsUs = 0, phaseUs = 0, validAfterS = -1;
|
||||
int multiple = 0;
|
||||
};
|
||||
auto runMultiple = [&](double hz, int multiple, double seconds, double odd, double jitterNs, bool absJitter = false) {
|
||||
Display d{ hz };
|
||||
Feed f;
|
||||
f.fit.SetMultiple(multiple);
|
||||
const int every = std::max(1, int(std::lround(hz / 60.0)));
|
||||
int64_t next = 0, firstValid = -1;
|
||||
f.Run(d, 0, int64_t(hz * seconds),
|
||||
[&](int64_t k) {
|
||||
if (k < next)
|
||||
return false;
|
||||
// The next frame `every` refreshes on, or one refresh either side.
|
||||
next = k + every + (uniform() < odd ? (uniform() < 0.5 ? -1 : 1) : 0);
|
||||
return true;
|
||||
},
|
||||
[&](int64_t) { return kWake + (absJitter ? std::abs(gauss(jitterNs)) : gauss(jitterNs)); },
|
||||
[&](int64_t k, const RefreshEstimate& e) { if (e.valid && firstValid < 0) firstValid = k; });
|
||||
const RefreshEstimate& e = f.fit.Estimate();
|
||||
MultipleRun r;
|
||||
r.valid = e.valid;
|
||||
r.hz = e.periodNs > 0 ? 1e9 / e.periodNs : 0;
|
||||
r.rmsUs = e.rmsNs / 1e3;
|
||||
r.phaseUs = (PhaseError(d, e) - kWake) / 1e3;
|
||||
r.validAfterS = firstValid >= 0 ? firstValid / hz : -1;
|
||||
r.multiple = e.multiple;
|
||||
return r;
|
||||
};
|
||||
|
||||
printf("9. 60, 120, 180 and 240 Hz (and 119.88, 239.76) told their multiple: a frame every 1-4 refreshes, 5%% a refresh early or late, "
|
||||
"0.2 ms jitter, 30 s\n");
|
||||
for (double hz : { 60.0, 59.94, 120.0, 119.88, 180.0, 240.0, 239.76 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
MultipleRun r = runMultiple(hz, m, 30, 0.05, 200'000);
|
||||
printf(" %7.2f Hz, multiple %d: valid %s after %.1f s, %.5f Hz (error %.1e), rms %.0f us, phase error %.0f us\n", hz, m,
|
||||
r.valid ? "yes" : "no", r.validAfterS, r.hz, r.hz - hz, r.rmsUs, r.phaseUs);
|
||||
CHECK(r.valid && r.multiple == m, "%.2f Hz not accepted as %d", hz, m);
|
||||
CHECK(std::abs(r.hz - hz) < 2e-4 * m, "%.2f Hz measured %.5f", hz, r.hz);
|
||||
CHECK(std::abs(r.phaseUs) < 100, "%.2f Hz phase %.0f us", hz, r.phaseUs);
|
||||
CHECK(r.validAfterS > 0 && r.validAfterS < 10.5, "%.2f Hz valid after %.1f s", hz, r.validAfterS);
|
||||
}
|
||||
|
||||
printf("9b. a free-running 59.9988 Hz guest on a 119.88 Hz display (before the lock): frames shown on the first refresh after "
|
||||
"vblank + 2-14 ms, so gaps of 1, 2 and 3 refreshes, all on the grid\n");
|
||||
{
|
||||
Display d{ 119.88 };
|
||||
RefreshFit fit;
|
||||
fit.SetMultiple(2);
|
||||
const double p = 1e9 / d.hz;
|
||||
int64_t lastShown = INT64_MIN;
|
||||
for (int64_t i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
const double ready = double(i) * 16'667'000.0 + 2e6 + 12e6 * uniform();
|
||||
int64_t k = int64_t(std::ceil((ready - d.phaseNs) / p));
|
||||
k = std::max(k, lastShown + 1); // one frame a refresh at most (FIFO)
|
||||
lastShown = k;
|
||||
fit.Add(int64_t(d.Refresh(k) + kWake + gauss(200'000)));
|
||||
}
|
||||
const RefreshEstimate& e = fit.Estimate();
|
||||
printf(" valid %s, %.5f Hz, rms %.0f us\n", e.valid ? "yes" : "no", 1e9 / e.periodNs, e.rmsNs / 1e3);
|
||||
CHECK(e.valid && std::abs(1e9 / e.periodNs - d.hz) < 4e-4, "%.5f Hz", 1e9 / e.periodNs);
|
||||
}
|
||||
|
||||
printf("10. no whole multiple: 143.9, 144 and 165 Hz (a 60 fps frame every 2 or 3 refreshes) and 90 Hz (1 or 2): MultipleOf 0, "
|
||||
"the fit stays at 1 and doesn't accept them\n");
|
||||
for (double hz : { 143.9, 144.0, 165.0, 90.0 })
|
||||
{
|
||||
CHECK(RefreshFit::MultipleOf(hz) == 0, "%.1f Hz has multiple %d", hz, RefreshFit::MultipleOf(hz));
|
||||
const video::RefreshMultiple policy;
|
||||
CHECK(policy.For(hz, false) == 1, "%.1f Hz: For %d", hz, policy.For(hz, false));
|
||||
Display d{ hz };
|
||||
Feed f;
|
||||
f.fit.SetMultiple(policy.For(hz, false));
|
||||
const double p = 1e9 / hz;
|
||||
int64_t lastShown = INT64_MIN;
|
||||
bool everValid = false;
|
||||
for (int64_t i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
const double ready = d.phaseNs + double(i) * 1e9 / 60.0 + 3e6 + gauss(500'000);
|
||||
int64_t k = std::max(int64_t(std::ceil((ready - d.phaseNs) / p)), lastShown + 1);
|
||||
lastShown = k;
|
||||
f.fit.Add(int64_t(d.Refresh(k) + kWake + gauss(200'000)));
|
||||
everValid |= f.fit.Estimate().valid;
|
||||
}
|
||||
printf(" %6.1f Hz: valid %s (ever: %s), fitted %.3f Hz, rms %.0f us\n", hz, f.fit.Estimate().valid ? "yes" : "no",
|
||||
everValid ? "yes" : "no", 1e9 / f.fit.Estimate().periodNs, f.fit.Estimate().rmsNs / 1e3);
|
||||
CHECK(!everValid, "%.1f Hz accepted", hz);
|
||||
CHECK(policy.Describe(hz, 1, false).find("no whole multiple") != std::string::npos, "%.1f Hz: no line saying why", hz);
|
||||
}
|
||||
|
||||
printf("11. jitter at 120 and 240 Hz: 0.3 ms accepted, 1.5 ms wake-ups not\n");
|
||||
for (double hz : { 120.0, 240.0 })
|
||||
{
|
||||
MultipleRun ok = runMultiple(hz, RefreshFit::MultipleOf(hz), 30, 0.02, 300'000);
|
||||
MultipleRun bad = runMultiple(hz, RefreshFit::MultipleOf(hz), 30, 0.02, 1'500'000, true);
|
||||
printf(" %.0f Hz: 0.3 ms valid %s (rms %.0f us), 1.5 ms valid %s (rms %.0f us)\n", hz, ok.valid ? "yes" : "no", ok.rmsUs,
|
||||
bad.valid ? "yes" : "no", bad.rmsUs);
|
||||
CHECK(ok.valid && std::abs(ok.hz - hz) < 5e-4 * RefreshFit::MultipleOf(hz), "%.0f Hz at 0.3 ms: %.5f", hz, ok.hz);
|
||||
CHECK(!bad.valid, "%.0f Hz accepted a 1.5 ms jitter stream", hz);
|
||||
}
|
||||
|
||||
printf("12. MultipleOf at and around the limits\n");
|
||||
{
|
||||
const struct { double hz; int m; } rows[] = { { 0, 0 }, { -1, 0 }, { 30, 0 }, { 50, 0 }, { 58.49, 0 }, { 58.5, 1 }, { 59.94, 1 },
|
||||
{ 61.5, 1 }, { 61.51, 0 }, { 75, 0 }, { 90, 0 }, { 100, 0 }, { 116.9, 0 }, { 117, 2 }, { 119.88, 2 }, { 120, 2 }, { 123, 2 },
|
||||
{ 123.1, 0 }, { 143.9, 0 }, { 144, 0 }, { 165, 0 }, { 175.4, 0 }, { 175.5, 3 }, { 180, 3 }, { 184.5, 3 }, { 200, 0 },
|
||||
{ 233.9, 0 }, { 234, 4 }, { 239.76, 4 }, { 240, 4 }, { 246, 4 }, { 246.1, 0 }, { 300, 0 }, { 360, 0 } };
|
||||
int bad = 0;
|
||||
for (const auto& r : rows)
|
||||
if (RefreshFit::MultipleOf(r.hz) != r.m)
|
||||
{
|
||||
bad++;
|
||||
CHECK(false, "MultipleOf(%.2f) = %d, want %d", r.hz, RefreshFit::MultipleOf(r.hz), r.m);
|
||||
}
|
||||
printf(" %zu rates, %d wrong\n", std::size(rows), bad);
|
||||
}
|
||||
|
||||
printf("13. a new multiple starts the fit over: valid at 60 Hz, told 2 (the window moved to a 120 Hz display), valid again at 120\n");
|
||||
{
|
||||
Display a{ 59.972616 };
|
||||
Feed f;
|
||||
f.Run(a, 0, 900, [](int64_t) { return true; }, [&](int64_t) { return kWake + gauss(200'000); });
|
||||
CHECK(f.fit.Estimate().valid && f.fit.Estimate().multiple == 1, "not valid at 60 Hz");
|
||||
f.fit.SetMultiple(2);
|
||||
CHECK(!f.fit.Estimate().valid && f.fit.Estimate().multiple == 2 && f.fit.Estimate().samples == 0, "the old estimate kept");
|
||||
f.fit.SetMultiple(2); // the same again: nothing to start over
|
||||
Display b{ 120.0, 2'000'000 };
|
||||
int64_t start = int64_t(std::ceil((a.Refresh(900) - b.phaseNs) * b.hz / 1e9)), validAt = -1;
|
||||
f.Run(b, start, start + 120 * 15, [](int64_t k) { return k % 2 == 0; }, [&](int64_t) { return kWake + gauss(200'000); },
|
||||
[&](int64_t k, const RefreshEstimate& e) { if (e.valid && validAt < 0) validAt = k - start; });
|
||||
const RefreshEstimate& e = f.fit.Estimate();
|
||||
printf(" valid again after %.1f s at %.5f Hz (multiple %d)\n", validAt / b.hz, 1e9 / e.periodNs, e.multiple);
|
||||
// 15 s of completions at 0.2 ms jitter: within 1e-3 Hz (8 ppm; libc++ and libstdc++ draw
|
||||
// other noise: 120.00033 and 120.00042 Hz).
|
||||
CHECK(e.valid && std::abs(1e9 / e.periodNs - 120.0) < 1e-3 && validAt / b.hz < 10.5, "%.5f Hz", 1e9 / e.periodNs);
|
||||
f.fit.SetMultiple(9); // out of range: 1
|
||||
CHECK(f.fit.Multiple() == 1 && !f.fit.Estimate().valid, "multiple %d", f.fit.Multiple());
|
||||
}
|
||||
|
||||
printf("14. the policy (NFSMW_REFRESH_MULTIPLE, the platform, the mode's rate) and its log line\n");
|
||||
{
|
||||
using video::RefreshMultiple;
|
||||
const RefreshMultiple autoMode = RefreshMultiple::FromEnv(nullptr), off = RefreshMultiple::FromEnv("0"),
|
||||
on = RefreshMultiple::FromEnv("1"), three = RefreshMultiple::FromEnv("3"),
|
||||
junk = RefreshMultiple::FromEnv("7"), empty = RefreshMultiple::FromEnv("");
|
||||
CHECK(autoMode.mode == RefreshMultiple::Mode::Auto && empty.mode == RefreshMultiple::Mode::Auto &&
|
||||
junk.mode == RefreshMultiple::Mode::Auto, "parsing");
|
||||
CHECK(off.mode == RefreshMultiple::Mode::Off && on.mode == RefreshMultiple::Mode::On &&
|
||||
three.mode == RefreshMultiple::Mode::Forced && three.forced == 3, "parsing");
|
||||
struct Row { const RefreshMultiple* p; const char* name; double hz; bool apple; int want; };
|
||||
const Row rows[] = {
|
||||
{ &autoMode, "auto", 59.94, false, 1 }, { &autoMode, "auto", 119.88, false, 2 }, { &autoMode, "auto", 180, false, 3 },
|
||||
{ &autoMode, "auto", 240, false, 4 }, { &autoMode, "auto", 144, false, 1 }, { &autoMode, "auto", 90, false, 1 },
|
||||
{ &autoMode, "auto", 0, false, 1 }, { &autoMode, "auto", 120, true, 1 }, { &autoMode, "auto", 60, true, 1 },
|
||||
{ &on, "on", 120, true, 2 }, { &on, "on", 144, true, 1 }, { &off, "off", 120, false, 1 }, { &off, "off", 240, false, 1 },
|
||||
{ &three, "3", 60, false, 3 }, { &three, "3", 144, true, 3 },
|
||||
};
|
||||
for (const Row& r : rows)
|
||||
CHECK(r.p->For(r.hz, r.apple) == r.want, "%s at %.2f Hz%s: %d, want %d", r.name, r.hz, r.apple ? " (Apple)" : "",
|
||||
r.p->For(r.hz, r.apple), r.want);
|
||||
// Lines: none at 60 Hz, at an unknown rate, off, or Auto on Apple's systems (v0.1.6 there).
|
||||
CHECK(autoMode.Describe(59.94, 1, false).empty() && autoMode.Describe(0, 1, false).empty(), "a line at 60 Hz or unknown");
|
||||
CHECK(autoMode.Describe(120, 1, true).empty() && autoMode.Describe(144, 1, true).empty(), "a line on Apple's systems");
|
||||
CHECK(off.Describe(144, 1, false).empty() && off.Describe(120, 1, false).empty(), "a line with NFSMW_REFRESH_MULTIPLE=0");
|
||||
const std::string l120 = autoMode.Describe(119.88, 2, false), l240 = autoMode.Describe(240, 4, false),
|
||||
l90 = autoMode.Describe(90, 1, false), l50 = autoMode.Describe(50, 1, false), l300 = autoMode.Describe(300, 1, false);
|
||||
printf(" 119.88: %s\n 90: %s\n 50: %s\n 300: %s\n", l120.c_str(), l90.c_str(), l50.c_str(), l300.c_str());
|
||||
CHECK(l120.find("every 2nd refresh") != std::string::npos && l240.find("every 4th refresh") != std::string::npos, "multiple lines");
|
||||
CHECK(l90.find("1 or 2 refreshes") != std::string::npos && l90.find("1.50") != std::string::npos, "90 Hz: %s", l90.c_str());
|
||||
CHECK(autoMode.Describe(144, 1, false).find("2 or 3 refreshes (2.40") != std::string::npos, "144 Hz");
|
||||
CHECK(l50.find("slower than 60 Hz") != std::string::npos && l300.find("faster than") != std::string::npos, "50 / 300 Hz");
|
||||
CHECK(three.Describe(60, 3, false).find("(NFSMW_REFRESH_MULTIPLE)") != std::string::npos, "forced");
|
||||
}
|
||||
|
||||
printf("15. planted wrong configurations, each must fail a check above\n");
|
||||
{
|
||||
int caught = 0, plants = 0;
|
||||
auto plant = [&](const char* name, bool failed) {
|
||||
plants++;
|
||||
caught += failed;
|
||||
printf(" plant: %-62s %s\n", name, failed ? "caught" : "NOT caught");
|
||||
CHECK(failed, "planted \"%s\" passed", name);
|
||||
};
|
||||
// 9's check (accepted, at the display's own rate) against:
|
||||
auto passes9 = [&](double hz, int multiple) {
|
||||
MultipleRun r = runMultiple(hz, multiple, 30, 0.05, 200'000);
|
||||
return r.valid && std::abs(r.hz - hz) < 2e-4 * std::max(multiple, 1);
|
||||
};
|
||||
plant("v0.1.6's fit (multiple 1) on a 119.88 Hz display", !passes9(119.88, 1));
|
||||
plant("multiple 2 on a 240 Hz display (fits every 2nd refresh)", !passes9(240.0, 2));
|
||||
plant("multiple 3 on a 240 Hz display", !passes9(240.0, 3));
|
||||
plant("multiple 4 on a 180 Hz display", !passes9(180.0, 4));
|
||||
// 10's check (never accepted) against a 143.9 Hz display told 2 or 3.
|
||||
for (int m : { 2, 3 })
|
||||
{
|
||||
Display d{ 143.9 };
|
||||
RefreshFit fit;
|
||||
fit.SetMultiple(m);
|
||||
bool ever = false;
|
||||
int64_t lastShown = INT64_MIN;
|
||||
for (int64_t i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
int64_t k = std::max(int64_t(std::ceil((double(i) * 1e9 / 60.0 + 3e6 + gauss(500'000)) * d.hz / 1e9)), lastShown + 1);
|
||||
lastShown = k;
|
||||
fit.Add(int64_t(d.Refresh(k) + kWake + gauss(200'000)));
|
||||
ever |= fit.Estimate().valid;
|
||||
}
|
||||
plant(m == 2 ? "143.9 Hz told multiple 2" : "143.9 Hz told multiple 3", !ever);
|
||||
}
|
||||
printf(" %d of %d caught\n", caught, plants);
|
||||
}
|
||||
|
||||
printf(g_failures ? "\n%d FAILED\n" : "\nall passed\n", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The one-time refresh-rate notice (video/refresh_notice.h): when it shows (the
|
||||
// display rates, variable refresh, Game Mode, a Mac's ProMotion or fixed
|
||||
// external screen, a headset, already dismissed, NFSMW_REFRESH_NOTICE, and the
|
||||
// moment: settled rate, past start-up, not loading, the controls idle, no other
|
||||
// panel, no movie); what it says on each platform; the grid check it rests on
|
||||
// (RefreshFit::SetGridHz: a fixed 90/143.9/144/165 Hz display taken, variable
|
||||
// refresh never); and the dismissal in settings.toml ([app] refresh_notices,
|
||||
// user/settings.cpp as the game links it), in a scratch folder (the argument).
|
||||
//
|
||||
// Build by hand (from the repo root):
|
||||
// c++ -std=c++20 -O2 -UNDEBUG -I runtime tests/refresh_notice_test.cpp runtime/video/refresh_fit.cpp \
|
||||
// runtime/user/settings.cpp -o build/refresh_notice_test
|
||||
// build/refresh_notice_test build/refresh_notice_scratch
|
||||
#include <user/settings.h>
|
||||
#include <video/refresh_fit.h>
|
||||
#include <video/refresh_notice.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failures = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
s_failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fprintf(stderr, "\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using video::RefreshNotice;
|
||||
using Platform = RefreshNotice::Platform;
|
||||
using Verdict = RefreshNotice::Verdict;
|
||||
|
||||
const char* Name(Verdict v)
|
||||
{
|
||||
switch (v)
|
||||
{
|
||||
case Verdict::NotApplicable: return "n/a";
|
||||
case Verdict::Never: return "never";
|
||||
case Verdict::Wait: return "wait";
|
||||
case Verdict::Show: return "show";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
// Everything settled and fixed: a display it should show on.
|
||||
RefreshNotice::Inputs Ready(double hz, Platform platform)
|
||||
{
|
||||
RefreshNotice::Inputs in;
|
||||
in.modeHz = hz;
|
||||
in.modeStableSec = 60;
|
||||
in.platform = platform;
|
||||
in.gridFixed = platform != Platform::Mac;
|
||||
in.gridStableSec = in.gridFixed ? 40 : 0;
|
||||
in.macFixed = platform == Platform::Mac;
|
||||
in.sessionSec = 90;
|
||||
in.flowingSec = 20;
|
||||
in.idleSec = 10;
|
||||
return in;
|
||||
}
|
||||
|
||||
std::string Read(const std::filesystem::path& path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::stringstream text;
|
||||
text << in.rdbuf();
|
||||
return text.str();
|
||||
}
|
||||
|
||||
void WhenItShows()
|
||||
{
|
||||
printf("when it shows:\n");
|
||||
struct Row
|
||||
{
|
||||
const char* name;
|
||||
RefreshNotice::Inputs in;
|
||||
Verdict want;
|
||||
};
|
||||
auto with = [](RefreshNotice::Inputs in, auto change) { change(in); return in; };
|
||||
const Row rows[] = {
|
||||
// The rates.
|
||||
{ "Steam Deck OLED, 90 Hz, Game Mode", Ready(90, Platform::GameMode), Verdict::Show },
|
||||
{ "144 Hz desktop", Ready(144, Platform::Desktop), Verdict::Show },
|
||||
{ "143.9 Hz desktop", Ready(143.9, Platform::Desktop), Verdict::Show },
|
||||
{ "165 Hz desktop", Ready(165, Platform::Desktop), Verdict::Show },
|
||||
{ "100 Hz desktop", Ready(100, Platform::Desktop), Verdict::Show },
|
||||
{ "75 Hz desktop", Ready(75, Platform::Desktop), Verdict::Show },
|
||||
{ "59.94 Hz: even", Ready(59.94, Platform::GameMode), Verdict::NotApplicable },
|
||||
{ "60 Hz: even", Ready(60, Platform::Desktop), Verdict::NotApplicable },
|
||||
{ "119.88 Hz: a multiple (locked)", Ready(119.88, Platform::Desktop), Verdict::NotApplicable },
|
||||
{ "180 Hz: a multiple", Ready(180, Platform::Desktop), Verdict::NotApplicable },
|
||||
{ "240 Hz: a multiple", Ready(240, Platform::GameMode), Verdict::NotApplicable },
|
||||
{ "50 Hz: below 58.5", Ready(50, Platform::Desktop), Verdict::NotApplicable },
|
||||
{ "unknown rate (0)", Ready(0, Platform::Desktop), Verdict::NotApplicable },
|
||||
// Variable refresh: no grid measured, or the lock let go.
|
||||
{ "144 Hz, no fixed grid (variable refresh)", with(Ready(144, Platform::Desktop), [](auto& i) { i.gridFixed = false; i.gridStableSec = 0; }), Verdict::Wait },
|
||||
{ "90 Hz Game Mode, no grid", with(Ready(90, Platform::GameMode), [](auto& i) { i.gridFixed = false; i.gridStableSec = 0; }), Verdict::Wait },
|
||||
// Macs: ProMotion (or adaptive sync) never; a fixed external screen yes.
|
||||
{ "Mac ProMotion, 120 Hz: a multiple anyway", Ready(120, Platform::Mac), Verdict::NotApplicable },
|
||||
{ "Mac ProMotion at 90 Hz (variable)", with(Ready(90, Platform::Mac), [](auto& i) { i.macFixed = false; }), Verdict::Never },
|
||||
{ "Mac external 144 Hz, adaptive sync on", with(Ready(144, Platform::Mac), [](auto& i) { i.macFixed = false; }), Verdict::Never },
|
||||
{ "Mac external 144 Hz, fixed", Ready(144, Platform::Mac), Verdict::Show },
|
||||
{ "Mac external 144 Hz, fixed, no grid (Metal's waits don't sit on one)", with(Ready(144, Platform::Mac), [](auto& i) { i.gridFixed = false; }), Verdict::Show },
|
||||
// Not here at all.
|
||||
{ "iOS or a headset (excluded)", Ready(90, Platform::Excluded), Verdict::Never },
|
||||
// Dismissed, and the override.
|
||||
{ "90 Hz, dismissed before", with(Ready(90, Platform::GameMode), [](auto& i) { i.shownBefore = true; }), Verdict::Never },
|
||||
{ "90 Hz, NFSMW_REFRESH_NOTICE=0", with(Ready(90, Platform::GameMode), [](auto& i) { i.forced = 0; }), Verdict::Never },
|
||||
{ "90 Hz, dismissed, NFSMW_REFRESH_NOTICE=1", with(Ready(90, Platform::GameMode), [](auto& i) { i.shownBefore = true; i.forced = 1; }), Verdict::Show },
|
||||
// The moment.
|
||||
{ "the mode just changed", with(Ready(144, Platform::Desktop), [](auto& i) { i.modeStableSec = 1; }), Verdict::Wait },
|
||||
{ "the grid just fitted", with(Ready(144, Platform::Desktop), [](auto& i) { i.gridStableSec = 2; }), Verdict::Wait },
|
||||
{ "starting up (20 s in)", with(Ready(90, Platform::GameMode), [](auto& i) { i.sessionSec = 20; }), Verdict::Wait },
|
||||
{ "loading (frames back 1 s ago)", with(Ready(90, Platform::GameMode), [](auto& i) { i.flowingSec = 1; }), Verdict::Wait },
|
||||
{ "racing (the throttle held)", with(Ready(90, Platform::GameMode), [](auto& i) { i.idleSec = 0; }), Verdict::Wait },
|
||||
{ "the controls used 2 s ago", with(Ready(90, Platform::GameMode), [](auto& i) { i.idleSec = 2; }), Verdict::Wait },
|
||||
{ "a movie playing (the intro, a cutscene)", with(Ready(90, Platform::GameMode), [](auto& i) { i.movieQuietSec = 0.1; }), Verdict::Wait },
|
||||
{ "a movie ended 3 s ago", with(Ready(90, Platform::GameMode), [](auto& i) { i.movieQuietSec = 3; }), Verdict::Show },
|
||||
{ "the settings menu or another panel is open", with(Ready(90, Platform::GameMode), [](auto& i) { i.uiBusy = true; }), Verdict::Wait },
|
||||
{ "Mac: the mode just changed", with(Ready(144, Platform::Mac), [](auto& i) { i.modeStableSec = 1; }), Verdict::Wait },
|
||||
};
|
||||
int wrong = 0;
|
||||
for (const Row& r : rows)
|
||||
{
|
||||
const RefreshNotice::Decision d = RefreshNotice::Decide(r.in);
|
||||
const bool ok = d.verdict == r.want;
|
||||
wrong += !ok;
|
||||
printf(" %-62s %-6s %s%s\n", r.name, Name(d.verdict), d.reason, ok ? "" : " <- WRONG");
|
||||
CHECK(ok, "%s: %s, want %s", r.name, Name(d.verdict), Name(r.want));
|
||||
}
|
||||
printf(" %zu cases, %d wrong\n", std::size(rows), wrong);
|
||||
}
|
||||
|
||||
void WhatItSays()
|
||||
{
|
||||
printf("what it says:\n");
|
||||
const std::string deck = RefreshNotice::Text(Ready(90, Platform::GameMode));
|
||||
RefreshNotice::Inputs docked = Ready(144, Platform::GameMode);
|
||||
docked.externalScreen = true;
|
||||
const std::string tv = RefreshNotice::Text(docked);
|
||||
const std::string desktop = RefreshNotice::Text(Ready(143.9, Platform::Desktop));
|
||||
const std::string mac = RefreshNotice::Text(Ready(165, Platform::Mac));
|
||||
printf(" Game Mode, the Deck's screen: %s\n Game Mode, a monitor: %s\n desktop: %s\n Mac: %s\n", deck.c_str(), tv.c_str(),
|
||||
desktop.c_str(), mac.c_str());
|
||||
CHECK(deck.find("90 Hz") != std::string::npos && deck.find("Quick Access > Performance") != std::string::npos &&
|
||||
deck.find("60 Hz") != std::string::npos && deck.find("per-game profile") != std::string::npos, "%s", deck.c_str());
|
||||
CHECK(tv.find("144 Hz") != std::string::npos && tv.find("Quick Access") == std::string::npos &&
|
||||
tv.find("60 or 120 Hz") != std::string::npos, "%s", tv.c_str());
|
||||
CHECK(desktop.find("144 Hz") != std::string::npos && desktop.find("display settings") != std::string::npos &&
|
||||
desktop.find("variable refresh") != std::string::npos, "%s", desktop.c_str());
|
||||
CHECK(mac == RefreshNotice::Text(Ready(165, Platform::Desktop)), "Mac: %s", mac.c_str());
|
||||
CHECK(RefreshNotice::Key(143.9) == 144 && RefreshNotice::Key(89.6) == 90 && RefreshNotice::Key(165.0) == 165, "Key");
|
||||
}
|
||||
|
||||
// The grid check under it: completions of a 60 fps game on a fixed
|
||||
// display (each frame on the first refresh after it is ready, 1-2 ms
|
||||
// jitter in readiness, 0.2 ms in the waits), against a variable-refresh
|
||||
// display that shows each frame as it comes.
|
||||
void GridCheck()
|
||||
{
|
||||
printf("the grid check (RefreshFit::SetGridHz):\n");
|
||||
std::mt19937_64 rng(77);
|
||||
auto gauss = [&](double sd) { return std::normal_distribution<double>(0.0, sd)(rng); };
|
||||
for (double hz : { 90.0, 143.9, 144.0, 165.0, 100.0 })
|
||||
for (bool variable : { false, true })
|
||||
{
|
||||
video::RefreshFit grid;
|
||||
grid.SetGridHz(hz);
|
||||
const double p = 1e9 / hz;
|
||||
int64_t last = INT64_MIN;
|
||||
double validAt = -1;
|
||||
for (int i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
const double ready = double(i) * 1e9 / 60.0 + 3e6 + gauss(variable ? 0.3e6 : 1.0e6);
|
||||
double on;
|
||||
if (variable)
|
||||
on = ready + 0.5e6;
|
||||
else
|
||||
{
|
||||
int64_t k = std::max(int64_t(std::ceil(ready / p)), last + 1);
|
||||
last = k;
|
||||
on = double(k) * p;
|
||||
}
|
||||
grid.Add(int64_t(1e12 + on + 150'000 + gauss(200'000)));
|
||||
if (grid.Estimate().valid && validAt < 0)
|
||||
validAt = double(i) / 60.0;
|
||||
}
|
||||
const auto& e = grid.Estimate();
|
||||
printf(" %6.1f Hz %-9s: valid %s (after %.1f s), %.3f Hz, rms %.0f us, multiple %d\n", hz, variable ? "variable" : "fixed",
|
||||
e.valid ? "yes" : "no", validAt, e.periodNs > 0 ? 1e9 / e.periodNs : 0.0, e.rmsNs / 1e3, e.multiple);
|
||||
if (variable)
|
||||
CHECK(!e.valid, "%.1f Hz variable refresh taken for a grid", hz);
|
||||
else
|
||||
CHECK(e.valid && std::abs(1e9 / e.periodNs - hz) < 2e-3 && validAt > 0 && validAt < 10.5 && e.multiple == 0,
|
||||
"%.1f Hz fixed: valid %d %.4f Hz after %.1f s", hz, e.valid, 1e9 / e.periodNs, validAt);
|
||||
}
|
||||
// Planted: a grid check told the wrong rate never takes the display.
|
||||
for (auto [told, real] : { std::pair{ 144.0, 165.0 }, std::pair{ 90.0, 144.0 }, std::pair{ 165.0, 144.0 } })
|
||||
{
|
||||
video::RefreshFit grid;
|
||||
grid.SetGridHz(told);
|
||||
int64_t last = INT64_MIN;
|
||||
bool ever = false;
|
||||
for (int i = 0; i < 60 * 30; i++)
|
||||
{
|
||||
const double p = 1e9 / real;
|
||||
int64_t k = std::max(int64_t(std::ceil((double(i) * 1e9 / 60.0 + 3e6 + gauss(1e6)) / p)), last + 1);
|
||||
last = k;
|
||||
grid.Add(int64_t(1e12 + double(k) * p + 150'000 + gauss(200'000)));
|
||||
ever |= grid.Estimate().valid;
|
||||
}
|
||||
printf(" planted: told %.0f Hz on a %.0f Hz display: %s\n", told, real, ever ? "TAKEN" : "never taken (caught)");
|
||||
CHECK(!ever, "told %.0f on %.0f", told, real);
|
||||
}
|
||||
}
|
||||
|
||||
void Dismissal(const std::filesystem::path& scratch)
|
||||
{
|
||||
printf("the dismissal in settings.toml:\n");
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(scratch, ec);
|
||||
std::filesystem::create_directories(scratch, ec);
|
||||
const std::filesystem::path file = scratch / "settings.toml";
|
||||
std::ofstream(file) << "[app]\nlast_version = \"0.1.6\"\n";
|
||||
CHECK(settings::Load(file), "load");
|
||||
CHECK(!settings::RefreshNoticeShown(90), "90 shown before anything");
|
||||
CHECK(settings::RecordRefreshNotice(90), "record 90");
|
||||
CHECK(settings::RefreshNoticeShown(90) && !settings::RefreshNoticeShown(144), "after 90");
|
||||
std::string text = Read(file);
|
||||
CHECK(text.find("refresh_notices = \"90\"") != std::string::npos && text.find("last_version = \"0.1.6\"") != std::string::npos,
|
||||
"file after 90:\n%s", text.c_str());
|
||||
CHECK(settings::RecordRefreshNotice(144) && settings::RecordRefreshNotice(90), "record 144, 90 again");
|
||||
text = Read(file);
|
||||
CHECK(text.find("refresh_notices = \"90,144\"") != std::string::npos, "file after 144:\n%s", text.c_str());
|
||||
// Read back in a new load; a version record and a full save keep it.
|
||||
CHECK(settings::Load(file) && settings::RefreshNoticeShown(90) && settings::RefreshNoticeShown(144) && !settings::RefreshNoticeShown(165),
|
||||
"reload");
|
||||
CHECK(settings::RecordVersion("0.1.7") && settings::Save(), "version + save");
|
||||
text = Read(file);
|
||||
CHECK(text.find("refresh_notices = \"90,144\"") != std::string::npos && text.find("last_version = \"0.1.7\"") != std::string::npos,
|
||||
"after a save:\n%s", text.c_str());
|
||||
// A hand-edited list: junk and repeats dropped.
|
||||
std::ofstream(file) << "[app]\nrefresh_notices = \"90, x,144,90,-3\"\n";
|
||||
CHECK(settings::Load(file) && settings::RefreshNoticeShown(90) && settings::RefreshNoticeShown(144), "hand edit");
|
||||
// No list: the [app] table as before (only the version).
|
||||
std::ofstream(file) << "[app]\nlast_version = \"0.1.6\"\n";
|
||||
CHECK(settings::Load(file) && settings::RecordVersion("0.1.7"), "plain");
|
||||
text = Read(file);
|
||||
CHECK(text.find("refresh_notices") == std::string::npos, "a list appeared:\n%s", text.c_str());
|
||||
printf(" recorded, read back, kept by a save and a version record, junk ignored: %s\n", s_failures ? "see failures" : "ok");
|
||||
std::filesystem::remove_all(scratch, ec);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
const std::filesystem::path scratch = argc > 1 ? std::filesystem::path(argv[1]) : std::filesystem::temp_directory_path() / "refresh_notice_test";
|
||||
WhenItShows();
|
||||
WhatItSays();
|
||||
GridCheck();
|
||||
Dismissal(scratch);
|
||||
printf(s_failures ? "\n%d FAILED\n" : "\nall passed\n", s_failures);
|
||||
return s_failures ? 1 : 0;
|
||||
}
|
||||
@@ -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
@@ -3,7 +3,9 @@
|
||||
// From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/vblank_lock_sim.cpp runtime/video/refresh_fit.cpp \
|
||||
// runtime/video/vblank_lock.cpp -o build/vblank_lock_sim
|
||||
// build/vblank_lock_sim
|
||||
// build/vblank_lock_sim [--multiples]
|
||||
// (--multiples: only the displays at a whole multiple of 60 Hz, and the
|
||||
// planted faults; that part is the ctest. The 60 Hz tables print as in v0.1.6.)
|
||||
//
|
||||
// Model (times in ns):
|
||||
// display refreshes every P (true rate), phase random; a present made
|
||||
@@ -27,6 +29,19 @@
|
||||
// the display shows what was queued, and the next vblank comes
|
||||
// exactly the time suspended later, then VblankLock::Resumed
|
||||
// (gpu/command_processor.cpp's Vsync thread).
|
||||
// multiple (--multiples) a display at 2-4 times 60 Hz (120, 180, 240) with
|
||||
// its multiple told to RefreshFit (the display mode's rate: video/
|
||||
// refresh_multiple.h). A present still shows on the first refresh
|
||||
// after it (the display has spare refreshes, so nothing queues);
|
||||
// the presenter holds a new frame until VblankLock::PresentAt.
|
||||
// Counted per new frame: refreshes on screen against the vblanks'
|
||||
// (uneven: not `multiple` a vblank), and frames that couldn't make
|
||||
// their refresh (ready after the latch of the refresh `multiple`
|
||||
// after the one before their vblank, the 60 Hz frame's budget):
|
||||
// each such miss may cost two uneven frames (one long, the next
|
||||
// short), anything more is the cadence's own fault. Optionally the
|
||||
// vblank thread wakes late (Windows: 1.26 +- 0.45 ms), Frame Rate
|
||||
// 30 (two lock periods a vblank), and planted faults.
|
||||
#include <video/refresh_fit.h>
|
||||
#include <video/vblank_lock.h>
|
||||
|
||||
@@ -36,6 +51,7 @@
|
||||
#include <deque>
|
||||
#include <iterator>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -48,6 +64,18 @@ struct Scene
|
||||
double spikeP, spikeMs; // occasional slow frames
|
||||
};
|
||||
|
||||
// What --multiples changes about a run (all off: the v0.1.6 model, unchanged).
|
||||
struct Options
|
||||
{
|
||||
int multiple = 0; // told to RefreshFit (0: not told, as v0.1.6)
|
||||
int trueMultiple = 0; // the display's own, for the cadence count (0: none: 144 Hz, 90 Hz)
|
||||
bool hold = true; // the presenter holds new frames (VblankLock::PresentAt)
|
||||
int64_t holdShiftNs = 0; // planted: the hold moved this much
|
||||
double wakeMeanMs = 0, wakeSdMs = 0; // the vblank thread wakes this late (clamped 0.3-4 ms)
|
||||
int divider = 1; // 2: Frame Rate 30
|
||||
double vrrMaxHz = 144; // the variable-refresh panel's top
|
||||
};
|
||||
|
||||
struct Result
|
||||
{
|
||||
double lockAtS = -1;
|
||||
@@ -62,10 +90,15 @@ struct Result
|
||||
int64_t rephaseVblanks = -1;
|
||||
uint64_t afterResume = 0;
|
||||
bool lockedAtEnd = false;
|
||||
// --multiples: new frames counted, uneven ones, frames that couldn't make
|
||||
// their refresh, refreshes on screen -> frames, lock changes.
|
||||
uint64_t newFrames = 0, uneven = 0, missed = 0, lockChanges = 0;
|
||||
uint64_t shownFor[8] = {};
|
||||
};
|
||||
|
||||
static Result Run(double displayHz, bool lockOn, const Scene& scene, double seconds, uint32_t seed, double countFromS,
|
||||
bool variable = false, const std::vector<std::pair<double, double>>& suspensions = {}, bool callResumed = true)
|
||||
bool variable = false, const std::vector<std::pair<double, double>>& suspensions = {}, bool callResumed = true,
|
||||
const Options& opt = Options{})
|
||||
{
|
||||
std::mt19937_64 rng(seed);
|
||||
std::normal_distribution<double> nd(0, 1);
|
||||
@@ -77,6 +110,10 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
|
||||
RefreshFit fit;
|
||||
VblankLock lock(16'667'000, lockOn);
|
||||
if (opt.multiple)
|
||||
fit.SetMultiple(opt.multiple);
|
||||
lock.SetHold(opt.hold);
|
||||
const int truth = std::max(opt.trueMultiple, 1);
|
||||
|
||||
struct Frame
|
||||
{
|
||||
@@ -87,6 +124,7 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
Frame pending{};
|
||||
bool held = false; // main thread holds a frame, waiting for an image
|
||||
Frame heldFrame{};
|
||||
int64_t wake = INT64_MAX; // it holds the frame until then (VblankLock::PresentAt)
|
||||
struct Queued
|
||||
{
|
||||
Frame f;
|
||||
@@ -96,8 +134,16 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
Result res;
|
||||
std::vector<double> lat;
|
||||
|
||||
int64_t vblank = t0;
|
||||
int64_t vblank = t0; // scheduled (VblankLock::Next)
|
||||
int64_t fired = t0; // when it fires (Options' late wake-ups)
|
||||
auto wakeLate = [&]() -> int64_t {
|
||||
if (opt.wakeMeanMs <= 0)
|
||||
return 0;
|
||||
return int64_t(std::clamp(opt.wakeMeanMs + opt.wakeSdMs * nd(rng), 0.3, 4.0) * 1e6);
|
||||
};
|
||||
int64_t k = 1;
|
||||
int64_t lastShownK = -1, lastShownVblank = 0;
|
||||
bool wasLocked = false;
|
||||
int64_t refresh = t0 + int64_t(phase);
|
||||
int64_t lastRefresh = refresh;
|
||||
const int64_t end = t0 + int64_t(seconds * 1e9);
|
||||
@@ -117,14 +163,24 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
queue.push_back({ f, at });
|
||||
};
|
||||
auto presenterStep = [&](int64_t now) {
|
||||
// Main thread: present what it holds when an image is free, then
|
||||
// take the pending frame.
|
||||
// Main thread: present what it holds when an image is free (and,
|
||||
// at a multiple, its hold is over), then take the pending frame.
|
||||
while (true)
|
||||
{
|
||||
if (held)
|
||||
{
|
||||
if (queue.size() >= 2)
|
||||
return;
|
||||
if (opt.multiple)
|
||||
{
|
||||
const int64_t at = lock.PresentAt(heldFrame.vblank);
|
||||
if (at && at + opt.holdShiftNs > now)
|
||||
{
|
||||
wake = at + opt.holdShiftNs;
|
||||
return;
|
||||
}
|
||||
wake = INT64_MAX;
|
||||
}
|
||||
present(heldFrame, now);
|
||||
held = false;
|
||||
}
|
||||
@@ -139,20 +195,23 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
{
|
||||
if (variable && k > 1)
|
||||
{
|
||||
const int64_t minGap = int64_t(1e9 / 144), maxGap = int64_t(1e9 / 48);
|
||||
const int64_t minGap = int64_t(1e9 / opt.vrrMaxHz), maxGap = int64_t(1e9 / 48);
|
||||
refresh = lastRefresh + maxGap;
|
||||
if (!queue.empty())
|
||||
refresh = std::clamp(queue.front().presented + int64_t(latch), lastRefresh + minGap, lastRefresh + maxGap);
|
||||
}
|
||||
// Next event: vblank, a frame's arrival, or a flip.
|
||||
int64_t nextArrive = inflight.empty() ? INT64_MAX : inflight.front().arrive;
|
||||
const int64_t nextEvent = std::min({ vblank, nextArrive, refresh });
|
||||
const int64_t nextEvent = std::min({ fired, nextArrive, refresh, wake });
|
||||
if (pause < pauses.size() && !suspended && nextEvent >= pauses[pause].first)
|
||||
{
|
||||
// Everything the game does stands still for the suspension; the
|
||||
// display flips on through what was already queued.
|
||||
suspended = true;
|
||||
vblank += pauses[pause].second - pauses[pause].first;
|
||||
fired += pauses[pause].second - pauses[pause].first;
|
||||
if (wake != INT64_MAX)
|
||||
wake += pauses[pause].second - pauses[pause].first;
|
||||
for (Frame& f : inflight)
|
||||
f.arrive += pauses[pause].second - pauses[pause].first;
|
||||
continue;
|
||||
@@ -170,13 +229,20 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
presenterStep(resumedAt);
|
||||
continue;
|
||||
}
|
||||
if (vblank <= refresh && vblank <= nextArrive)
|
||||
if (wake <= refresh && wake <= nextArrive && wake < fired)
|
||||
{
|
||||
const int64_t at = wake;
|
||||
wake = INT64_MAX;
|
||||
presenterStep(at);
|
||||
continue;
|
||||
}
|
||||
if (fired <= refresh && fired <= nextArrive)
|
||||
{
|
||||
double x = scene.meanMs + scene.sdMs * nd(rng);
|
||||
if (ud(rng) < scene.spikeP)
|
||||
x = scene.spikeMs + 1.0 * nd(rng);
|
||||
x = std::max(1.0, x);
|
||||
Frame f{ vblank, vblank + int64_t(x * 1e6 + 500'000) }; // +0.5 ms pickup
|
||||
Frame f{ fired, fired + int64_t(x * 1e6 + 500'000) }; // +0.5 ms pickup
|
||||
auto it = inflight.end();
|
||||
while (it != inflight.begin() && std::prev(it)->arrive > f.arrive)
|
||||
--it;
|
||||
@@ -187,6 +253,9 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
lastCounted = vblank;
|
||||
}
|
||||
vblank = lock.Next(vblank);
|
||||
for (int d = 1; d < opt.divider; d++) // Frame Rate 30: every other lock period
|
||||
vblank = lock.Next(vblank);
|
||||
fired = vblank + wakeLate();
|
||||
if (resumedAt >= 0 && res.rephaseVblanks < 0)
|
||||
{
|
||||
sinceResume++;
|
||||
@@ -223,7 +292,7 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
// The presenter doesn't time a frame released before the resume
|
||||
// (video/presenter.cpp's OnScreen).
|
||||
if (q.f.vblank >= resumedAt)
|
||||
lock.Observe(q.f.vblank, q.presented, onScreen);
|
||||
lock.Observe(q.f.vblank, q.presented, onScreen, std::min(q.f.arrive, q.presented));
|
||||
if (resumedAt >= 0 && shownAfterResume < 0)
|
||||
shownAfterResume = refresh;
|
||||
if (q.f.vblank >= countFrom)
|
||||
@@ -231,6 +300,25 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
res.frames++;
|
||||
lat.push_back((refresh - q.f.vblank) / 1e6);
|
||||
}
|
||||
if (opt.multiple || opt.trueMultiple)
|
||||
{
|
||||
// Its predecessor's refreshes on screen against the vblanks
|
||||
// between them, and whether it could have made its refresh at
|
||||
// all (the display's true phase; `truth` refreshes a vblank).
|
||||
if (lastShownK >= 0 && q.f.vblank >= countFrom && !afterResume(refresh))
|
||||
{
|
||||
const int64_t shown = k - lastShownK;
|
||||
const int64_t wanted = std::max<int64_t>(1, std::llround(double(q.f.vblank - lastShownVblank) / (P * truth))) * truth;
|
||||
res.newFrames++;
|
||||
res.uneven += shown != wanted;
|
||||
res.shownFor[std::min<int64_t>(shown, 7)]++;
|
||||
const double before = std::floor((double(q.f.vblank) - (double(t0) + phase)) / P);
|
||||
const double target = double(t0) + phase + (before + truth) * P;
|
||||
res.missed += double(q.f.arrive) > target - latch;
|
||||
}
|
||||
lastShownK = k;
|
||||
lastShownVblank = q.f.vblank;
|
||||
}
|
||||
flowing = true;
|
||||
if (!suspended)
|
||||
presenterStep(refresh);
|
||||
@@ -244,6 +332,11 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
}
|
||||
if (res.lockAtS < 0 && lock.Locked())
|
||||
res.lockAtS = (refresh - t0) / 1e9;
|
||||
if (lock.Locked() != wasLocked)
|
||||
{
|
||||
wasLocked = !wasLocked;
|
||||
res.lockChanges++;
|
||||
}
|
||||
if (res.followsAtS < 0 && lock.GetStatus().follows)
|
||||
res.followsAtS = (refresh - t0) / 1e9;
|
||||
k++;
|
||||
@@ -264,8 +357,12 @@ static Result Run(double displayHz, bool lockOn, const Scene& scene, double seco
|
||||
return res;
|
||||
}
|
||||
|
||||
int main()
|
||||
static bool Multiples();
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
if (argc > 1 && std::string(argv[1]) == "--multiples")
|
||||
return Multiples() ? 0 : 1;
|
||||
const Scene scenes[] = {
|
||||
{ "light (vblank->presenter 7+-1.5 ms)", 7, 1.5, 0.0, 0 },
|
||||
{ "driving (9+-2 ms, 1% at 20 ms)", 9, 2, 0.01, 20 },
|
||||
@@ -374,3 +471,277 @@ int main()
|
||||
: "FAILED");
|
||||
return ok && resumes ? 0 : 1;
|
||||
}
|
||||
|
||||
// ---- Displays at a whole multiple of 60 Hz (--multiples) ----
|
||||
namespace
|
||||
{
|
||||
const Scene kScenes[] = {
|
||||
{ "fast (1.5+-0.3 ms: Steam Machine)", 1.5, 0.3, 0.0, 0 },
|
||||
{ "light (7+-1.5 ms)", 7, 1.5, 0.0, 0 },
|
||||
{ "driving (9+-2 ms, 1% at 20)", 9, 2, 0.01, 20 },
|
||||
{ "fly-in (14+-1.5 ms, 2% at 20)", 14, 1.5, 0.02, 20 },
|
||||
{ "heavy (16+-1.5 ms)", 16, 1.5, 0.0, 0 },
|
||||
};
|
||||
constexpr double kSeconds = 180, kCountFrom = 60;
|
||||
|
||||
struct Row
|
||||
{
|
||||
Result sum;
|
||||
double lockAt = 0, lat = 0, vblankHz = 0;
|
||||
bool locked = true, follows = false;
|
||||
int seeds = 0;
|
||||
};
|
||||
|
||||
Row RunSeeds(double hz, const Scene& s, const Options& o, int seeds, uint32_t salt, double seconds = kSeconds,
|
||||
bool variable = false)
|
||||
{
|
||||
Row row;
|
||||
for (int seed = 1; seed <= seeds; seed++)
|
||||
{
|
||||
Result r = Run(hz, true, s, seconds, uint32_t(seed) * 7919 + uint32_t(hz * 10) + salt, kCountFrom, variable, {}, true, o);
|
||||
row.sum.newFrames += r.newFrames;
|
||||
row.sum.uneven += r.uneven;
|
||||
row.sum.missed += r.missed;
|
||||
row.sum.slips += r.slips;
|
||||
row.sum.lockChanges += r.lockChanges;
|
||||
for (int i = 0; i < 8; i++)
|
||||
row.sum.shownFor[i] += r.shownFor[i];
|
||||
row.lockAt = std::max(row.lockAt, r.lockAtS);
|
||||
row.lat += r.latMedMs / seeds;
|
||||
row.vblankHz += r.vblankHz / seeds;
|
||||
row.locked &= r.lockAtS > 0 && r.lockedAtEnd;
|
||||
row.follows |= r.followsAtS >= 0;
|
||||
row.seeds++;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
double Pct(uint64_t a, uint64_t b) { return b ? 100.0 * double(a) / double(b) : 0.0; }
|
||||
|
||||
std::string Histogram(const Result& r)
|
||||
{
|
||||
uint64_t all = 0;
|
||||
for (uint64_t c : r.shownFor)
|
||||
all += c;
|
||||
std::string out;
|
||||
for (int i = 1; i < 8; i++)
|
||||
if (r.shownFor[i] * 1000 >= all && r.shownFor[i])
|
||||
{
|
||||
char b[32];
|
||||
snprintf(b, sizeof b, "%s%d:%.1f%%", out.empty() ? "" : " ", i, Pct(r.shownFor[i], all));
|
||||
out += b;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The fix's criteria for a fixed-refresh multiple: locked (and never let
|
||||
// go), the vblank at the display's rate over the multiple, at most 0.3%
|
||||
// of frames uneven, and, for a scene without spikes, the least latency
|
||||
// its frames allow: the first refresh after the vblank's that a frame
|
||||
// ready at the scene's 3 sigma (+0.5 ms pickup, + the wake-up's) makes
|
||||
// with the 2 ms latch (vblank -> screen = that many refreshes less 1 ms).
|
||||
bool Judge(const Row& r, double hz, int m, const Scene& s, const Options& o, std::string* why = nullptr)
|
||||
{
|
||||
const double vblankWant = hz / m;
|
||||
const bool rate = std::abs(r.vblankHz - vblankWant) < 0.01;
|
||||
const bool even = r.sum.uneven * 1000 <= r.sum.newFrames * 3; // 0.3%
|
||||
const double refreshMs = 1e3 / hz;
|
||||
const double need = s.meanMs + 3 * s.sdMs + 0.5 + o.wakeMeanMs + 3 * o.wakeSdMs + 1.0 + 2.0;
|
||||
const double least = std::ceil(need / refreshMs) * refreshMs - 1.0;
|
||||
const bool latency = s.spikeP > 0 || r.lat <= least + 0.5;
|
||||
if (why)
|
||||
*why = std::string(!r.locked ? "not locked; " : "") + (r.follows ? "let go; " : "") + (!rate ? "vblank rate; " : "") +
|
||||
(!even ? "uneven; " : "") + (!latency ? "latency; " : "");
|
||||
return r.locked && !r.follows && rate && even && latency;
|
||||
}
|
||||
|
||||
void PrintRow(const char* display, const Scene& s, const Row& r, const char* verdict)
|
||||
{
|
||||
printf("%-12s %-30s %7.1f %6llu %7.2f %7.2f %6llu %6.1f %10.4f %-24s %s\n", display, s.name, r.lockAt,
|
||||
(unsigned long long)r.sum.lockChanges, Pct(r.sum.uneven, r.sum.newFrames), Pct(r.sum.missed, r.sum.newFrames),
|
||||
(unsigned long long)r.sum.slips, r.lat, r.vblankHz, Histogram(r.sum).c_str(), verdict);
|
||||
}
|
||||
|
||||
void Header()
|
||||
{
|
||||
printf("%-12s %-30s %7s %6s %7s %7s %6s %6s %10s %-24s %s\n", "display", "scene", "lock at", "lockch", "uneven%", "missed%",
|
||||
"slips", "lat ms", "vblank Hz", "refreshes:frames", "");
|
||||
}
|
||||
}
|
||||
|
||||
static bool Multiples()
|
||||
{
|
||||
bool ok = true;
|
||||
printf("displays at a whole multiple of 60 Hz: %.0f s per run, counted from %.0f s; 3 seeds each, summed (lock at: the latest)\n"
|
||||
" uneven%%: new frames not on screen for `multiple` refreshes a vblank; missed%%: frames ready after their refresh's latch\n"
|
||||
" (they're held for later refreshes then: VblankLock::AdaptHold); lat: vblank -> on screen, median\n", kSeconds, kCountFrom);
|
||||
Header();
|
||||
const double rates[] = { 119.88, 120.0, 180.0, 239.76, 240.0 };
|
||||
for (double hz : rates)
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
for (const Scene& s : kScenes)
|
||||
{
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
Row r = RunSeeds(hz, s, o, 3, 0);
|
||||
std::string why;
|
||||
const bool pass = Judge(r, hz, m, s, o, &why);
|
||||
char display[16];
|
||||
snprintf(display, sizeof display, "%.2f/%d", hz, m);
|
||||
PrintRow(display, s, r, pass ? "ok" : ("FAIL: " + why).c_str());
|
||||
ok &= pass;
|
||||
}
|
||||
}
|
||||
|
||||
printf("\nthe vblank thread waking late as on Windows (1.26 +- 0.45 ms, sleep_until after timeBeginPeriod(1)); 3 seeds\n");
|
||||
Header();
|
||||
for (double hz : { 120.0, 240.0 })
|
||||
for (int i : { 0, 1, 2 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
o.wakeMeanMs = 1.26;
|
||||
o.wakeSdMs = 0.45;
|
||||
Row r = RunSeeds(hz, kScenes[i], o, 3, 17);
|
||||
std::string why;
|
||||
const bool pass = Judge(r, hz, m, kScenes[i], o, &why);
|
||||
char display[16];
|
||||
snprintf(display, sizeof display, "%.0f/%d win", hz, m);
|
||||
PrintRow(display, kScenes[i], r, pass ? "ok" : ("FAIL: " + why).c_str());
|
||||
ok &= pass;
|
||||
}
|
||||
|
||||
printf("\nFrame Rate 30 (a guest vblank every 2 lock periods) at 120 and 240 Hz: 4 and 8 refreshes a frame; 3 seeds\n");
|
||||
Header();
|
||||
for (double hz : { 120.0, 240.0 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
o.divider = 2;
|
||||
Row r = RunSeeds(hz, kScenes[2], o, 3, 29);
|
||||
// The vblank comes every 2 guest periods; frames stay 2m refreshes.
|
||||
const bool pass = r.locked && !r.follows && std::abs(r.vblankHz - hz / m / 2) < 0.01 &&
|
||||
r.sum.uneven * 1000 <= r.sum.newFrames * 3 && r.sum.shownFor[std::min(2 * m, 7)] * 100 >= r.sum.newFrames * 99;
|
||||
char display[16];
|
||||
snprintf(display, sizeof display, "%.0f/%d fr30", hz, m);
|
||||
PrintRow(display, kScenes[2], r, pass ? "ok" : "FAIL");
|
||||
ok &= pass;
|
||||
}
|
||||
|
||||
printf("\nno whole multiple (the mode's multiple is 0, so the fit is told nothing, as v0.1.6): never locked, frames 2-3 (or 1-2)\n"
|
||||
"refreshes each as the rate dictates; 3 seeds\n");
|
||||
Header();
|
||||
for (double hz : { 143.9, 144.0, 165.0, 90.0 })
|
||||
for (int i : { 1, 2 })
|
||||
{
|
||||
Options o;
|
||||
o.multiple = std::max(RefreshFit::MultipleOf(hz), 1); // 0 -> 1, what the presenter tells the fit
|
||||
Row r = RunSeeds(hz, kScenes[i], o, 3, 41);
|
||||
const bool pass = r.lockAt <= 0 && std::abs(r.vblankHz - 1e9 / 16'667'000) < 0.01;
|
||||
char display[16];
|
||||
snprintf(display, sizeof display, "%.1f", hz);
|
||||
PrintRow(display, kScenes[i], r, pass ? "ok (free-running)" : "FAIL: locked");
|
||||
ok &= pass;
|
||||
}
|
||||
|
||||
printf("\nvariable refresh up to 120 Hz in a 120 Hz mode (told multiple 2): the display follows the presents; the lock lets go\n"
|
||||
"(or never takes it) in every run and the vblank ends free-running at 60 Hz (counted 120-300 s); 3 seeds\n");
|
||||
{
|
||||
const Scene steady[] = { { "steady (3+-0.05 ms)", 3, 0.05, 0.0, 0 }, kScenes[0], kScenes[1] };
|
||||
Header();
|
||||
for (const Scene& s : steady)
|
||||
{
|
||||
Options o;
|
||||
o.multiple = 2;
|
||||
o.vrrMaxHz = 120;
|
||||
Row row;
|
||||
bool every = true;
|
||||
for (int seed = 1; seed <= 3; seed++)
|
||||
{
|
||||
Result r = Run(60.0, true, s, 300, uint32_t(seed) * 7919 + 600 + 53, 120, true, {}, true, o);
|
||||
every &= r.followsAtS >= 0 || r.lockAtS <= 0;
|
||||
row.sum.newFrames += r.newFrames;
|
||||
row.sum.uneven += r.uneven;
|
||||
row.sum.lockChanges += r.lockChanges;
|
||||
for (int i = 0; i < 8; i++)
|
||||
row.sum.shownFor[i] += r.shownFor[i];
|
||||
row.lockAt = std::max(row.lockAt, std::max(r.followsAtS, r.lockAtS));
|
||||
row.lat += r.latMedMs / 3;
|
||||
row.vblankHz += r.vblankHz / 3;
|
||||
row.follows |= r.followsAtS >= 0;
|
||||
}
|
||||
const bool pass = every && std::abs(row.vblankHz - 1e9 / 16'667'000) < 0.001;
|
||||
PrintRow("VRR 48-120", s, row, pass ? (row.follows ? "ok (let go; lock at: the latest let-go)" : "ok (never locked)")
|
||||
: "FAIL: not free-running at 60 Hz");
|
||||
ok &= pass;
|
||||
}
|
||||
}
|
||||
|
||||
printf("\nsuspended at 90 s for 0.5 / 6.0013 / 12.5 s at 120 and 240 Hz (fast, light): re-phased within half a refresh of\n"
|
||||
"steps and still locked; 3 seeds each, max\n");
|
||||
for (int sc : { 0, 1 })
|
||||
for (double hz : { 120.0, 240.0 })
|
||||
for (double d : { 0.5, 6.0013, 12.5 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
const int64_t limit = int64_t(std::ceil(1e9 / hz / 2 / double(VblankLock::kMaxStepNs))) + 10;
|
||||
int64_t rephase = 0;
|
||||
bool held = true;
|
||||
for (uint32_t seed = 1; seed <= 3; seed++)
|
||||
{
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
Result r = Run(hz, true, kScenes[sc], 90 + d + 10, seed * 6151 + uint32_t(hz * 10), kCountFrom, false, { { 90, d } }, true, o);
|
||||
rephase = r.rephaseVblanks < 0 ? INT64_MAX : std::max(rephase, r.rephaseVblanks);
|
||||
held &= r.lockedAtEnd && r.followsAtS < 0;
|
||||
}
|
||||
const bool pass = rephase <= limit && held;
|
||||
printf(" %-30s %6.1f Hz suspended %7.4f s: rephase %lld vblanks (limit %lld), lock %s: %s\n", kScenes[sc].name, hz, d,
|
||||
rephase == INT64_MAX ? -1LL : (long long)rephase, (long long)limit, held ? "held" : "LOST", pass ? "ok" : "FAIL");
|
||||
ok &= pass;
|
||||
}
|
||||
|
||||
// Planted faults: each must fail the criteria above in at least one of
|
||||
// these rows (the fast, light and driving scenes at 119.88 and 240 Hz).
|
||||
printf("\nplanted faults (each must fail a row):\n");
|
||||
struct Plant
|
||||
{
|
||||
const char* name;
|
||||
void (*apply)(Options&, double hz);
|
||||
};
|
||||
const Plant plants[] = {
|
||||
{ "v0.1.6: the mode's multiple unused (the fit at 1)", [](Options& o, double) { o.multiple = 1; } },
|
||||
{ "the lock without the hold (frames when ready)", [](Options& o, double) { o.hold = false; } },
|
||||
{ "the hold a refresh early", [](Options& o, double hz) { o.holdShiftNs = -int64_t(1e9 / hz); } },
|
||||
{ "the hold a refresh late", [](Options& o, double hz) { o.holdShiftNs = int64_t(1e9 / hz); } },
|
||||
{ "the fit told half the multiple", [](Options& o, double) { o.multiple = std::max(o.multiple / 2, 1); } },
|
||||
};
|
||||
bool plantsOk = true;
|
||||
for (const Plant& p : plants)
|
||||
{
|
||||
int failed = 0, rows = 0;
|
||||
for (double hz : { 119.88, 240.0 })
|
||||
for (int i : { 0, 1, 2 })
|
||||
{
|
||||
const int m = RefreshFit::MultipleOf(hz);
|
||||
Options o;
|
||||
o.multiple = o.trueMultiple = m;
|
||||
p.apply(o, hz);
|
||||
Row r = RunSeeds(hz, kScenes[i], o, 1, 67);
|
||||
std::string why;
|
||||
failed += !Judge(r, hz, m, kScenes[i], o, &why);
|
||||
rows++;
|
||||
printf(" %-52s %7.2f %-30s uneven %6.2f%% missed %5.2f%% lat %5.1f: %s\n", p.name, hz, kScenes[i].name,
|
||||
Pct(r.sum.uneven, r.sum.newFrames), Pct(r.sum.missed, r.sum.newFrames), r.lat, why.empty() ? "passes" : why.c_str());
|
||||
}
|
||||
printf(" plant: %-52s %s (%d of %d rows fail)\n", p.name, failed ? "caught" : "NOT caught", failed, rows);
|
||||
plantsOk &= failed > 0;
|
||||
}
|
||||
printf("%s\n", ok && plantsOk ? "multiples: locked to every 2nd-4th refresh, even, the least latency the frames allow; "
|
||||
"non-multiples and variable refresh free-run; every plant caught: ok"
|
||||
: "FAILED");
|
||||
return ok && plantsOk;
|
||||
}
|
||||
Reference in new issue
Block a user