mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 23:01:20 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2cb93f2d |
No files matched your search
@@ -4,6 +4,49 @@ 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.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.6 LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
@@ -301,8 +301,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
|
||||
|
||||
@@ -558,7 +559,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.6-<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.
|
||||
|
||||
|
||||
+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;
|
||||
};
|
||||
}
|
||||
+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)
|
||||
|
||||
+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,126 @@
|
||||
// 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 <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;
|
||||
}
|
||||
|
||||
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,50 @@
|
||||
// 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);
|
||||
|
||||
// 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)
|
||||
|
||||
+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)
|
||||
{
|
||||
|
||||
+62
-33
@@ -11,6 +11,7 @@
|
||||
#include <game/ultrawide.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"
|
||||
@@ -25,9 +26,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,15 +278,17 @@ 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
|
||||
};
|
||||
@@ -291,8 +298,10 @@ 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
|
||||
|
||||
// macOS: a new frame may be shown a set time after the guest vblank
|
||||
@@ -317,8 +326,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 +365,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;
|
||||
{
|
||||
@@ -469,12 +484,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);
|
||||
@@ -544,10 +557,10 @@ namespace video
|
||||
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 +684,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 +2262,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
|
||||
@@ -3091,6 +3106,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))
|
||||
{
|
||||
@@ -3132,6 +3151,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,
|
||||
@@ -3729,7 +3756,7 @@ void main()
|
||||
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 });
|
||||
s_timingCv.notify_one();
|
||||
}
|
||||
s_lastPresent = std::chrono::steady_clock::now();
|
||||
@@ -3945,10 +3972,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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
+34
-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)
|
||||
@@ -88,6 +94,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 +180,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 +205,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user