mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 17:01:29 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2cb93f2d | ||
|
|
ad14799d14 |
No files matched your search
@@ -4,6 +4,81 @@ 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
|
||||
|
||||
- Settings > Controls: every keyboard control can be reassigned, and each control has two keys.
|
||||
Choose a row, Left or Right for its first or second key, press A or Enter, then the new key; Y
|
||||
clears a key, X puts a row's defaults back, Reset page the whole table. The default keys are
|
||||
unchanged (the arrows steer as well as A and D), and the left stick click gets a key of its own,
|
||||
Z. Thanks to love-gamer, who wrote it (GitHub pull request #12).
|
||||
- Settings > Graphics > Upscaling has a third choice, FSR 1 (AMD FidelityFX Super Resolution 1),
|
||||
for a window much larger than the Internal Resolution (1x on a 1440p, MacBook or 4K screen):
|
||||
curves, wires and diagonals stay smooth where bicubic shows steps, such as lamp posts against
|
||||
the sky or the speedometer's needle. It is used only when the picture is enlarged. Bicubic stays
|
||||
the default. Not on iPhone and iPad.
|
||||
- The race results screen no longer shows a thin strip of the race above and below it. An Xbox 360
|
||||
draws that strip too, and a TV of the time hid it past the edge of its screen; today's screens
|
||||
show the whole picture, so it is now black, on every screen shape (Steam Deck, 16:9 and others).
|
||||
|
||||
### More about it
|
||||
|
||||
- The Controls page: the keys are saved by name in settings.toml's `[keys]` table (`w`,
|
||||
`left_shift`, `none`). F1, Esc (it cancels a wait) and the Cmd and Windows keys can't be
|
||||
assigned. If you go back to v0.1.4 and change a setting there, it forgets the Controls page's
|
||||
keys.
|
||||
- FSR 1: Sharpening sets its sharpening pass too (0.3 of its full strength by default). Bicubic
|
||||
stays the default because FSR 1 makes foliage look a little painted, costs more GPU time (about
|
||||
1 ms a frame on an M1 Pro at 1080p, 0.1 ms on a Steam Machine), and with Internal Resolution on
|
||||
Auto most screens aren't enlarged at all.
|
||||
- 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.4
|
||||
|
||||
### 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.4 LANGUAGES C CXX)
|
||||
project(SpeedBreaker VERSION 0.1.6 LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
AMD FidelityFX Super Resolution 1 (FSR 1): ffx_a.h and ffx_fsr1.h
|
||||
https://github.com/GPUOpen-Effects/FidelityFX-FSR (commit a21ffb8f6c13233ba336352bdff293894c706575, files ffx-fsr/ffx_a.h and ffx-fsr/ffx_fsr1.h)
|
||||
Vendored unmodified in runtime/thirdparty/fsr1; their shader code is compiled into SpeedBreaker.
|
||||
|
||||
Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -71,12 +71,12 @@ and a settings menu you can open mid-race.
|
||||
on the console.
|
||||
- **The original look:** sunlit roads and shadows as on the console, the picture a real Xbox 360
|
||||
sends to a TV (the game's own display gamma), correct colours on Apple devices, proper car paint
|
||||
and mirror reflections, and distant textures that don't shimmer. Anti-aliasing, sharpening, a
|
||||
Brightness setting and up to 16x anisotropic filtering are also available.
|
||||
and mirror reflections, and distant textures that don't shimmer. Anti-aliasing, sharpening,
|
||||
FSR 1 upscaling, a Brightness setting and up to 16x anisotropic filtering are also available.
|
||||
- **A guided installer.** It finds your disc image, checks that it is the right game, and
|
||||
verifies all 49 files as it copies them. An interrupted install never leaves a broken game.
|
||||
- **Your controller, or the keyboard.** Controllers work as an Xbox 360 pad, with hot-plugging
|
||||
and rumble support.
|
||||
and rumble support. Every keyboard control can be reassigned in the settings.
|
||||
- **A settings menu that pauses the game.** Open it with F1, Back + Start, or a three-finger
|
||||
tap. Every change applies at once, and *Quit Game* leaves the game from a controller too.
|
||||
- **Suspend and resume on iPhone and iPad.** Switch away and the game freezes; come back and it
|
||||
@@ -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
|
||||
|
||||
@@ -346,7 +347,7 @@ is B, Square is X and Triangle is Y.
|
||||
|
||||
| Xbox 360 control | Controller | Keyboard |
|
||||
|---|---|---|
|
||||
| Left stick | Left stick | A / D steer (the arrow keys also move it) |
|
||||
| Left stick | Left stick | A / D or Left / Right steer; Up / Down push it up and down |
|
||||
| Right trigger | Right trigger | W |
|
||||
| Left trigger | Left trigger | S |
|
||||
| A / B / X / Y | Bottom / right / left / top face button | Space / Backspace / Left Shift / Left Ctrl |
|
||||
@@ -355,7 +356,7 @@ is B, Square is X and Triangle is Y.
|
||||
| Start | Start / Menu / Options | Enter |
|
||||
| Back | Back / View / Create | Esc |
|
||||
| Right stick | Right stick | O / L up and down, K / ; left and right |
|
||||
| Stick clicks | Press a stick in | C (right) |
|
||||
| Stick clicks | Press a stick in | Z (left), C (right) |
|
||||
|
||||
- One player, one controller: the first one connected is used, and you can plug one in at any
|
||||
time. The keyboard always works as well.
|
||||
@@ -363,7 +364,11 @@ is B, Square is X and Triangle is Y.
|
||||
you let go, a hold after a quarter of a second.
|
||||
- The game shifts gears with the right stick, so from the keyboard you shift with O and L.
|
||||
Holding both keys of a pair centres the stick.
|
||||
- There is no button remapping and no mouse control in game.
|
||||
- Every keyboard control can be reassigned: **Settings > Controls** (F1). Each control has two
|
||||
keys: choose a row, Left or Right for its first or second key, press A or Enter, then the key
|
||||
you want (Esc or B cancels). Y or Backspace clears a key, X or Delete puts the row's defaults
|
||||
back, and *Reset page* restores this whole table. A key may drive several controls, as the
|
||||
arrows do. There is no mouse control in game.
|
||||
|
||||
**To open the settings:**
|
||||
|
||||
@@ -408,8 +413,8 @@ then Yes, puts it back.
|
||||
| Option | Choices | Default |
|
||||
|---|---|---|
|
||||
| Anti-Aliasing | On / Off | On |
|
||||
| Upscaling | Bicubic / Bilinear | Bicubic |
|
||||
| Sharpening | 0.00 to 1.00 | 0.30 (0 turns it off) |
|
||||
| Upscaling | Bicubic / Bilinear / FSR 1 | Bicubic. FSR 1 (AMD FidelityFX Super Resolution 1) keeps curves, wires and diagonals smooth when the picture is enlarged a lot, such as 1x on a 1440p or 4K screen; it is used only when the picture is enlarged, costs a little more GPU time, and makes foliage look slightly painted. Not on iPhone and iPad. |
|
||||
| Sharpening | 0.00 to 1.00 | 0.30 (0 turns it off). With FSR 1, the share of its sharpening pass's full strength. |
|
||||
| Anisotropic Filtering | Auto / Off / 2x / 4x / 8x / 16x / Original | Auto: 16x on dedicated graphics cards and M-series iPads, 4x elsewhere. Original uses each texture's own setting, as on the 360. |
|
||||
| Mipmaps | On / Off | On, so distant textures don't shimmer |
|
||||
| Internal Resolution | Auto / 1x (720p) / 1.5x (1080p) / 2x (1440p) / 3x (2160p) | Auto: enough to cover your screen, within what the device can afford (whole scales only: 1x, 2x, 3x, or 3x2 on an ultrawide). On iPhone and iPad, Auto always picks 1x. |
|
||||
@@ -420,6 +425,10 @@ game while its window isn't in front.
|
||||
**Input:** Stick Deadzone (8%; raise it if the car steers by itself), Trigger Deadzone (2%), and
|
||||
Vibration (On).
|
||||
|
||||
**Controls:** the keyboard keys for each Xbox 360 control, two per control, as in the table under
|
||||
[Controls](#controls). The keys are saved by name in `settings.toml`'s `[keys]` table (`w`,
|
||||
`left_shift`, `keypad_1`, or `none`; `runtime/user/key_names.h` lists them).
|
||||
|
||||
**Advanced:** Asynchronous Shaders (On: an object is skipped for a frame or two while its shader
|
||||
compiles, instead of the game stuttering), Performance Overlay (Off: fps, frame times, and CPU
|
||||
and GPU time per frame), **Save Bug Report**, and **Check for Updates** (the only thing that goes
|
||||
@@ -550,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.4-<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.
|
||||
|
||||
@@ -749,6 +758,7 @@ research.
|
||||
| [SDL3](https://www.libsdl.org/) | Windows, input, audio, app lifecycle | zlib |
|
||||
| [Dear ImGui](https://github.com/ocornut/imgui) (Omar Cornut) | The installer and settings UI | MIT |
|
||||
| [Roboto](https://fonts.google.com/specimen/Roboto) (Google) | The UI font | Apache-2.0 |
|
||||
| [FidelityFX Super Resolution 1](https://github.com/GPUOpen-Effects/FidelityFX-FSR) (AMD) | The FSR 1 Upscaling choice | MIT |
|
||||
| [o1heap](https://github.com/pavel-kirienko/o1heap) (Pavel Kirienko) | The guest heap allocator | MIT |
|
||||
| [glslang](https://github.com/KhronosGroup/glslang) and [SPIRV-Tools](https://github.com/KhronosGroup/SPIRV-Tools) (Khronos) | Shader compilation at runtime | BSD-3-Clause and others; Apache-2.0 |
|
||||
| [MoltenVK](https://github.com/KhronosGroup/MoltenVK) and the [Vulkan Loader](https://github.com/KhronosGroup/Vulkan-Loader) (Khronos) | Vulkan on Mac, iPhone and iPad | Apache-2.0 |
|
||||
|
||||
@@ -28,6 +28,7 @@ collected when the bundle is made), and the iPhone and iPad IPA in `SpeedBreaker
|
||||
| **Roboto** (Medium): Copyright 2011 Google Inc. | The menus' font, embedded in the program. Roboto is a trademark of Google. | Apache-2.0 | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt) |
|
||||
| **o1heap**: Copyright (c) 2020 Pavel Kirienko | The game's heap allocator (`runtime/thirdparty/o1heap`). | MIT | [`LICENSES/o1heap-MIT.txt`](LICENSES/o1heap-MIT.txt) |
|
||||
| **QR Code generator library** (C) 1.8.0: Copyright (c) 2022 Project Nayuki | The QR code of the download page in Settings > Advanced > Check for Updates (`runtime/thirdparty/qrcodegen`). | MIT | [`LICENSES/qrcodegen-MIT.txt`](LICENSES/qrcodegen-MIT.txt) |
|
||||
| **AMD FidelityFX Super Resolution 1** (FSR 1, `ffx_a.h` and `ffx_fsr1.h` at commit a21ffb8): Copyright (c) 2021 Advanced Micro Devices, Inc. | Settings > Graphics > Upscaling's FSR 1: its EASU and RCAS shader code, compiled into the game's shaders, and its constant setup (`runtime/thirdparty/fsr1`, unmodified). | MIT | [`LICENSES/FidelityFX-FSR-MIT.txt`](LICENSES/FidelityFX-FSR-MIT.txt) |
|
||||
| **FFmpeg** 7.1.1: the FFmpeg developers | The XMA1, XMA2 and WMA Pro audio decoders, linked statically. Built without any GPL parts, with one SpeedBreaker change: [`patches/ffmpeg/0001-xma-single-stream-no-holdback.patch`](patches/ffmpeg/0001-xma-single-stream-no-holdback.patch) (no sample hold-back for single-stream XMA, which the game's audio mixer depends on). Source: `https://ffmpeg.org/releases/ffmpeg-7.1.1.tar.xz` plus that patch; [`scripts/build_ffmpeg_xma.sh`](scripts/build_ffmpeg_xma.sh) builds it. | LGPL-2.1-or-later | [`LICENSES/FFmpeg-LGPL-2.1.txt`](LICENSES/FFmpeg-LGPL-2.1.txt) |
|
||||
|
||||
## Libraries in the downloads
|
||||
|
||||
@@ -22,6 +22,9 @@ from you.
|
||||
buttons on either side of the screen).
|
||||
- The D-pad picks a row, left/right changes it, A selects, LB/RB switch
|
||||
pages, B closes. The game pauses while the menu is open.
|
||||
- The **Controls** page: choose a row, Left or Right for its first or second key, press A (or
|
||||
Enter), then the key you want; Esc or B cancels. Y (Backspace) clears a key, X (Delete) restores
|
||||
the row, *Reset page* the whole table.
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
|
||||
@@ -79,6 +79,20 @@ target_link_libraries(imgui PUBLIC SDL3::SDL3 Vulkan::Vulkan)
|
||||
file(READ ${IMGUI}/fonts/Roboto-Medium.ttf UI_FONT_HEX HEX)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," UI_FONT_BYTES "${UI_FONT_HEX}")
|
||||
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/generated/ui_font.inc "${UI_FONT_BYTES}\n")
|
||||
# AMD FidelityFX Super Resolution 1 (thirdparty/fsr1, vendored, unmodified):
|
||||
# the text of its two headers, compiled in (generated/fsr1_headers.inc) for
|
||||
# Upscaling's FSR 1 shaders, which glslang compiles while the game runs
|
||||
# (video/fsr1.cpp). An edit of either reconfigures.
|
||||
set(FSR1 ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/fsr1)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${FSR1}/ffx_a.h ${FSR1}/ffx_fsr1.h)
|
||||
set(fsr1_inc "// Generated from runtime/thirdparty/fsr1 by runtime/CMakeLists.txt. Do not edit.\n")
|
||||
foreach(name ffx_a ffx_fsr1)
|
||||
file(READ ${FSR1}/${name}.h fsr1_hex HEX)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," fsr1_bytes "${fsr1_hex}")
|
||||
string(APPEND fsr1_inc "constexpr unsigned char k_${name}[] = { ${fsr1_bytes}0 };\n")
|
||||
endforeach()
|
||||
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/generated/fsr1_headers.inc.new "${fsr1_inc}")
|
||||
configure_file(${CMAKE_CURRENT_BINARY_DIR}/generated/fsr1_headers.inc.new ${CMAKE_CURRENT_BINARY_DIR}/generated/fsr1_headers.inc COPYONLY)
|
||||
target_include_directories(SpeedBreaker PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||
# The "What's new" note (ui/whats_new.cpp): this version's entry in
|
||||
# CHANGELOG.md, from its "## v<version>" heading to the next "## " one,
|
||||
|
||||
+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;
|
||||
};
|
||||
}
|
||||
+256
-52
@@ -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;
|
||||
}
|
||||
|
||||
@@ -316,14 +331,20 @@ namespace gpu::bars
|
||||
// "WINNER" polaroid over asphalt and leaves; the title's and Controls'
|
||||
// key art have the same shape) is one opaque textured quad of the front
|
||||
// end, centred, x +-1.104 and y +-0.981 of the 16:9 band. On a 16:9
|
||||
// screen it hides the world (a thin border of it shows at the top and
|
||||
// bottom, as on the console, where a TV's overscan hid it); past the band
|
||||
// (Hor+ beside it, Vert+ above and below it) the world rendered behind
|
||||
// it showed: the race's road and cars at the sides of the results on an
|
||||
// iPhone (2868x1320). What a 16:9 screen shows of it is kept, the art's
|
||||
// own pixels too; everything else past the band (what no 16:9 screen
|
||||
// shows) is filled black, as a 16:9 picture's letterbox. Thresholds in
|
||||
// band units. The nearest draws it must not take: the fades and dims
|
||||
// screen it hides the world but for a thin border at the top and bottom
|
||||
// (y 0.981 to 1: ~7 of 720 rows each), where the race behind it shows;
|
||||
// a real 360's own output has that border too (HDMI captures of the
|
||||
// results screen), and a TV's overscan hid it. Past the band (Hor+
|
||||
// beside it, Vert+ above and below it) the world rendered behind it
|
||||
// showed as well: the race's road and cars at the sides of the results
|
||||
// on an iPhone (2868x1320). The art's own pixels are all kept, past the
|
||||
// band too; everything else is filled black on every screen shape, 16:9
|
||||
// too: past the band (what no 16:9 screen shows, as a 16:9 picture's
|
||||
// letterbox) and, inside it, the rows between the picture's top or
|
||||
// bottom edge and the band's (what a period TV's overscan hid). The
|
||||
// title's and Controls' key art have black around them already, so their
|
||||
// fill changes nothing on screen. Thresholds in band units. The nearest
|
||||
// draws it must not take: the fades and dims
|
||||
// (+-1.29: flat, and the widen, stretch and cover rules' own), the pause
|
||||
// dim (C8 alpha) and its gradients, the vid cam's noise and dark overlays
|
||||
// (3C alpha, x +-0.70), the overlay at the intro's start (x +-1.104, B8
|
||||
@@ -366,13 +387,14 @@ namespace gpu::bars
|
||||
return true;
|
||||
}
|
||||
|
||||
// What a backdrop fills, inside the draw's scissor: what lies past the
|
||||
// band (the columns beside it under Hor+, the rows above and below it
|
||||
// under Vert+; at most one of the view scales is below 1) and outside
|
||||
// the backdrop's own box, up to kFillEdge: per side, the part beyond the
|
||||
// box and, where the box reaches into that side, the parts above and
|
||||
// below it (Hor+; left and right of it under Vert+). At most 6.
|
||||
inline uint32_t BackdropRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
// What a backdrop fills past the band, inside the draw's scissor: what
|
||||
// lies past it (the columns beside it under Hor+, the rows above and
|
||||
// below it under Vert+; at most one of the view scales is below 1) and
|
||||
// outside the backdrop's own box, up to kFillEdge: per side, the part
|
||||
// beyond the box and, where the box reaches into that side, the parts
|
||||
// above and below it (Hor+; left and right of it under Vert+). At most
|
||||
// 6; none at 16:9.
|
||||
inline uint32_t PastBandRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[6])
|
||||
{
|
||||
uint32_t n = 0;
|
||||
@@ -410,6 +432,45 @@ namespace gpu::bars
|
||||
return n;
|
||||
}
|
||||
|
||||
// The overscan rows, inside the draw's scissor: inside the band, the
|
||||
// rows between the backdrop's top edge and the band's and between its
|
||||
// bottom edge and the band's (y +-0.981 to +-1 of the band for the
|
||||
// results picture), across the band's width. Where the band's edge is
|
||||
// the screen's (y at 16:9 and under Hor+, x at 16:9 and under Vert+)
|
||||
// they reach kFillEdge, as the fills past the band do (at 2x with
|
||||
// half-pixel centres the outermost row's centre lies past clip 1); where
|
||||
// it isn't, they stop at it and PastBandRects' rectangles go on from
|
||||
// there, meeting them on the same pixel edge (gpu::bars::Edge). None
|
||||
// where the box reaches the band's edge. At most 2.
|
||||
inline uint32_t OverscanRows(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[2])
|
||||
{
|
||||
uint32_t n = 0;
|
||||
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 < 2)
|
||||
out[n++] = c;
|
||||
};
|
||||
const float e = kFillEdge;
|
||||
const float bandX = scaleX < 1.0f ? scaleX : e, bandY = scaleY < 1.0f ? scaleY : e;
|
||||
add(-bandX, bandX, b.y1, bandY);
|
||||
add(-bandX, bandX, -bandY, b.y0);
|
||||
return n;
|
||||
}
|
||||
|
||||
// What a backdrop fills, inside the draw's scissor: everything outside
|
||||
// its box but the columns inside the band beside it (none for a picture
|
||||
// as wide as the band): PastBandRects' and then OverscanRows'
|
||||
// rectangles, which never overlap. At most 8.
|
||||
inline uint32_t BackdropRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[8])
|
||||
{
|
||||
const uint32_t n = PastBandRects(b, ps, hostW, hostH, scissor, scaleX, scaleY, out);
|
||||
return n + OverscanRows(b, ps, hostW, hostH, scissor, scaleX, scaleY, out + n);
|
||||
}
|
||||
|
||||
// The posScale that stretches the quad's box over [-edge, edge] in clip
|
||||
// x and y (x' = ax x + bx, likewise y), folded in: NDC = x' ps0 + ps2 =
|
||||
// x (ax ps0) + (ps2 + bx ps0). Orientation is kept (ax, ay > 0) whatever
|
||||
@@ -442,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[6]{};
|
||||
Rect rects[8]{}; // a bar's 2, a black frame's 4, a backdrop's 8
|
||||
float posScale[4]{};
|
||||
};
|
||||
|
||||
@@ -482,13 +546,14 @@ namespace gpu::bars
|
||||
}
|
||||
|
||||
// The register checks for a backdrop, before any vertex is read:
|
||||
// Candidate's, but over a large t0 (a picture, not the white texture).
|
||||
// Candidate's, but over a large t0 (a picture, not the white texture),
|
||||
// at every screen shape (16:9 too: its overscan rows).
|
||||
inline bool BackdropCandidate(const Draw& d, const BackdropRule& 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.scaleX < 1.0f || d.scaleY < 1.0f) && !d.occlusion && d.prim == 13 && d.count == 4 && d.frontEndShader && d.leftAlone &&
|
||||
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.blend0 == 0x00010001u) && d.t0Sampled && d.t0Width >= rule.minTexels && d.t0Height >= rule.minTexels;
|
||||
}
|
||||
@@ -526,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
|
||||
@@ -561,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;
|
||||
|
||||
+184
-97
@@ -7832,8 +7832,131 @@ 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.
|
||||
// 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): 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) {
|
||||
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),
|
||||
.count = count,
|
||||
.c = c,
|
||||
.frontEndY = feY,
|
||||
.scaleX = aspectX,
|
||||
.scaleY = aspectY,
|
||||
.frontEndShader = feShader,
|
||||
.leftAlone = leftAlone,
|
||||
.occlusion = zpdCount,
|
||||
.vte = vte.value,
|
||||
.blend0 = s_regs[XE_GPU_REG_RB_BLENDCONTROL0] & 0x1FFF1FFF,
|
||||
.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),
|
||||
.scissor = { scissor.offset.x, scissor.offset.y, scissor.offset.x + int32_t(scissor.extent.width),
|
||||
scissor.offset.y + int32_t(scissor.extent.height) } };
|
||||
const uint32_t* vf = &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0 + 95 * 2];
|
||||
const uint32_t base = vf[0] & 0x1FFFFFFC, endian = vf[1] & 3;
|
||||
const uint32_t words = (vf[1] >> 2) & 0xFFFFFF, first = s_regs[XE_GPU_REG_VGT_INDX_OFFSET];
|
||||
auto read = [&](uint32_t k, uint32_t j, uint32_t& word) {
|
||||
const uint32_t v = src.Get(k) + first; // an ePoly: 8 words
|
||||
if ((v + 1) * 8 > words)
|
||||
return false;
|
||||
word = GpuSwap(LoadPhysical(base + (v * 8 + j) * 4), endian);
|
||||
return true;
|
||||
};
|
||||
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
|
||||
// it, is cleared to black after it, where the world drawn behind
|
||||
// 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) {
|
||||
if (barPlan.fill == gpu::bars::Fill::None)
|
||||
return;
|
||||
static const bool logFrontEnd = std::getenv("NFSMW_LOG_FRONTEND") != nullptr;
|
||||
// A match each frame skips building the line once its key is
|
||||
// logged.
|
||||
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[6] = { ~0ull, ~0ull, ~0ull, ~0ull, ~0ull, ~0ull };
|
||||
if (loggedKey[kind] != key)
|
||||
{
|
||||
loggedKey[kind] = key;
|
||||
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)"
|
||||
: 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", 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,
|
||||
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[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, 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);
|
||||
}
|
||||
last = s_frame;
|
||||
};
|
||||
gpu::bars::Plan barPlan;
|
||||
if (const float s = game::AspectScaleX(), sv = game::AspectScaleY(); s < 1.0f || sv < 1.0f)
|
||||
{
|
||||
@@ -8127,102 +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.
|
||||
// NFSMW_BAR_FILL=0 turns it off (an A/B switch).
|
||||
static const bool barFill = [] { const char* v = std::getenv("NFSMW_BAR_FILL"); return !(v && v[0] == '0'); }();
|
||||
// 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)
|
||||
{
|
||||
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 = prim,
|
||||
.count = count,
|
||||
.c = c,
|
||||
.frontEndY = feY,
|
||||
.scaleX = s,
|
||||
.scaleY = sv,
|
||||
.frontEndShader = feShader,
|
||||
.leftAlone = kX == 1.0f && kY == 1.0f && kCover == 1.0f && !clipped,
|
||||
.occlusion = zpdCount,
|
||||
.vte = vte.value,
|
||||
.blend0 = blend0,
|
||||
.t0Sampled = (s_ps->info.texture2DMask & 1) != 0,
|
||||
.t0Width = uint32_t(t0.size_2d.width) + 1,
|
||||
.t0Height = uint32_t(t0.size_2d.height) + 1,
|
||||
.posScale = { pc.posScale[0], pc.posScale[1], pc.posScale[2], pc.posScale[3] },
|
||||
.hostW = w * float(scaleX),
|
||||
.hostH = h * float(scaleY),
|
||||
.scissor = { scissor.offset.x, scissor.offset.y, scissor.offset.x + int32_t(scissor.extent.width),
|
||||
scissor.offset.y + int32_t(scissor.extent.height) } };
|
||||
const uint32_t base = vf[0] & 0x1FFFFFFC, endian = vf[1] & 3;
|
||||
const uint32_t words = (vf[1] >> 2) & 0xFFFFFF, first = s_regs[XE_GPU_REG_VGT_INDX_OFFSET];
|
||||
auto read = [&](uint32_t k, uint32_t j, uint32_t& word) {
|
||||
const uint32_t v = src.Get(k) + first; // an ePoly: 8 words
|
||||
if ((v + 1) * 8 > words)
|
||||
return false;
|
||||
word = GpuSwap(LoadPhysical(base + (v * 8 + j) * 4), endian);
|
||||
return true;
|
||||
};
|
||||
barPlan = gpu::bars::PlanFill(draw, read);
|
||||
// The results screen's backdrop (and any opaque
|
||||
// picture of that shape: the title's key art): what
|
||||
// lies past the band and outside the picture is
|
||||
// cleared to black after it, where the world drawn
|
||||
// behind it showed (gpu/edge_bars.h).
|
||||
if (barPlan.fill == gpu::bars::Fill::None)
|
||||
barPlan = gpu::bars::PlanBackdrop(draw, read);
|
||||
}
|
||||
if (barPlan.fill != gpu::bars::Fill::None)
|
||||
{
|
||||
// Once per kind, screen shape and outcome, in every
|
||||
// log (a tester's too); a match each frame skips
|
||||
// building the line once its key is logged.
|
||||
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 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(s * 4096.0f)) << 18) | uint64_t(std::lround(sv * 4096.0f));
|
||||
static uint64_t loggedKey[5] = { ~0ull, ~0ull, ~0ull, ~0ull, ~0ull };
|
||||
if (loggedKey[kind] != key)
|
||||
{
|
||||
loggedKey[kind] = key;
|
||||
char fills[96];
|
||||
if (barPlan.rectCount)
|
||||
snprintf(fills, sizeof(fills), "fills %u rectangle(s) past the 16:9 band, first [%d,%d)x[%d,%d) (host px)",
|
||||
barPlan.rectCount, 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");
|
||||
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 past the band)" : 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, s, sv, 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];
|
||||
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",
|
||||
box.x0, box.x1, box.y0, box.y1, barPlan.argb, blend0, (uint32_t(t0.base_address) << 12) & 0x1FFFFFFFu,
|
||||
uint32_t(t0.size_2d.width) + 1, uint32_t(t0.size_2d.height) + 1, barPlan.rectCount);
|
||||
}
|
||||
last = s_frame;
|
||||
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
|
||||
@@ -8273,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))
|
||||
@@ -8361,6 +8410,42 @@ 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 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 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];
|
||||
memcpy(c, &s_regs[XE_GPU_REG_SHADER_CONSTANT_000_X], sizeof(c));
|
||||
const uint32_t* vf = &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0 + 95 * 2];
|
||||
if (std::fabs(c[0] - feX) <= feX * 1e-5f && c[1] == 0.0f && c[2] == 0.0f &&
|
||||
c[12] == 0.0f && c[13] == 0.0f && c[14] == 0.0f && c[15] == 1.0f && ((vf[0] & 3) == 3))
|
||||
{
|
||||
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);
|
||||
logEdgeFill(barPlan, s, sv);
|
||||
if (barPlan.fill == gpu::bars::Fill::Vignette)
|
||||
memcpy(pc.posScale, barPlan.posScale, sizeof(pc.posScale));
|
||||
}
|
||||
}
|
||||
}
|
||||
// NFSMW_LOG_DRAWS=<seconds>: every draw of one frame (target, viewport,
|
||||
// scissor, textures, shaders), consecutive duplicates folded.
|
||||
if (s_logDrawsNow)
|
||||
@@ -8373,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)
|
||||
@@ -8600,7 +8687,7 @@ void main()
|
||||
ca[attachments].clearValue.color = { { 0.0f, 0.0f, 0.0f, 1.0f } };
|
||||
attachments++;
|
||||
}
|
||||
VkClearRect cr[6]{};
|
||||
VkClearRect cr[std::extent_v<decltype(gpu::bars::Plan::rects)>]{};
|
||||
for (uint32_t i = 0; i < barPlan.rectCount; i++)
|
||||
{
|
||||
const gpu::bars::Rect& r = barPlan.rects[i];
|
||||
|
||||
+37
-29
@@ -1,13 +1,30 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See input.h.
|
||||
#include <stdafx.h>
|
||||
#include "input.h"
|
||||
#include "key_map.h"
|
||||
#include <cpu/guest_time.h>
|
||||
#include <kernel/function.h>
|
||||
#include <ui/ui.h>
|
||||
#include <user/settings.h>
|
||||
#include <user/key_names.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
// key_map.h names XInput's bits as literals (it has no kernel header): keep
|
||||
// them the bits the game reads.
|
||||
static_assert(hid::keymap::button::DpadUp == XAMINPUT_GAMEPAD_DPAD_UP && hid::keymap::button::DpadDown == XAMINPUT_GAMEPAD_DPAD_DOWN &&
|
||||
hid::keymap::button::DpadLeft == XAMINPUT_GAMEPAD_DPAD_LEFT && hid::keymap::button::DpadRight == XAMINPUT_GAMEPAD_DPAD_RIGHT &&
|
||||
hid::keymap::button::Start == XAMINPUT_GAMEPAD_START && hid::keymap::button::Back == XAMINPUT_GAMEPAD_BACK &&
|
||||
hid::keymap::button::LeftThumb == XAMINPUT_GAMEPAD_LEFT_THUMB && hid::keymap::button::RightThumb == XAMINPUT_GAMEPAD_RIGHT_THUMB &&
|
||||
hid::keymap::button::LeftShoulder == XAMINPUT_GAMEPAD_LEFT_SHOULDER && hid::keymap::button::RightShoulder == XAMINPUT_GAMEPAD_RIGHT_SHOULDER &&
|
||||
hid::keymap::button::A == XAMINPUT_GAMEPAD_A && hid::keymap::button::B == XAMINPUT_GAMEPAD_B &&
|
||||
hid::keymap::button::X == XAMINPUT_GAMEPAD_X && hid::keymap::button::Y == XAMINPUT_GAMEPAD_Y,
|
||||
"key_map.h's button bits are XInput's");
|
||||
// The key codes of user/key_names.h are SDL's scancodes (USB HID usages);
|
||||
// tests/key_names_test.cpp checks every row, this pins a few.
|
||||
static_assert(settings::keynames::Code("a") == SDL_SCANCODE_A && settings::keynames::Code("keypad_1") == SDL_SCANCODE_KP_1 &&
|
||||
settings::keynames::Code("right_alt") == SDL_SCANCODE_RALT && settings::keynames::Code("escape") == SDL_SCANCODE_ESCAPE);
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uint32_t ERROR_SUCCESS = 0;
|
||||
@@ -608,39 +625,30 @@ namespace hid
|
||||
}
|
||||
const float rawLt = lt, rawRt = rt;
|
||||
|
||||
// Keyboard (digital; overrides the analog values it touches).
|
||||
const bool* k = SDL_GetKeyboardState(nullptr);
|
||||
if (k)
|
||||
// Keyboard (digital; replaces the controller's value for what it
|
||||
// touches): each control's key is Settings > Controls (the Key
|
||||
// options), resolved by key_map.h. A key is a scancode SDL's state
|
||||
// array is indexed by; an out-of-range value (a hand edit beat
|
||||
// Load's checks) counts as not held.
|
||||
if (const bool* k = SDL_GetKeyboardState(nullptr))
|
||||
{
|
||||
static const std::pair<SDL_Scancode, uint16_t> keys[] = {
|
||||
{ SDL_SCANCODE_UP, XAMINPUT_GAMEPAD_DPAD_UP }, { SDL_SCANCODE_DOWN, XAMINPUT_GAMEPAD_DPAD_DOWN },
|
||||
{ SDL_SCANCODE_LEFT, XAMINPUT_GAMEPAD_DPAD_LEFT }, { SDL_SCANCODE_RIGHT, XAMINPUT_GAMEPAD_DPAD_RIGHT },
|
||||
{ SDL_SCANCODE_SPACE, XAMINPUT_GAMEPAD_A }, { SDL_SCANCODE_BACKSPACE, XAMINPUT_GAMEPAD_B },
|
||||
{ SDL_SCANCODE_LSHIFT, XAMINPUT_GAMEPAD_X }, { SDL_SCANCODE_LCTRL, XAMINPUT_GAMEPAD_Y },
|
||||
{ SDL_SCANCODE_Q, XAMINPUT_GAMEPAD_LEFT_SHOULDER }, { SDL_SCANCODE_E, XAMINPUT_GAMEPAD_RIGHT_SHOULDER },
|
||||
{ SDL_SCANCODE_RETURN, XAMINPUT_GAMEPAD_START }, { SDL_SCANCODE_ESCAPE, XAMINPUT_GAMEPAD_BACK },
|
||||
{ SDL_SCANCODE_C, XAMINPUT_GAMEPAD_RIGHT_THUMB },
|
||||
bool pressed[keymap::kControls] = {};
|
||||
auto down = [&](settings::Id id) {
|
||||
int code = settings::GetInt(id); // 0: no key
|
||||
return code > 0 && code < SDL_SCANCODE_COUNT && k[code];
|
||||
};
|
||||
for (auto& [key, bit] : keys)
|
||||
if (k[key])
|
||||
p.buttons |= bit;
|
||||
float kx = float(k[SDL_SCANCODE_RIGHT] || k[SDL_SCANCODE_D]) - float(k[SDL_SCANCODE_LEFT] || k[SDL_SCANCODE_A]);
|
||||
float ky = float(k[SDL_SCANCODE_UP]) - float(k[SDL_SCANCODE_DOWN]);
|
||||
if (kx != 0.0f)
|
||||
lx = kx;
|
||||
if (ky != 0.0f)
|
||||
ly = ky;
|
||||
if (k[SDL_SCANCODE_W])
|
||||
for (size_t i = 0; i < keymap::kControls; i++)
|
||||
pressed[i] = down(keymap::kEntries[i].id) || down(keymap::kEntries[i].id2);
|
||||
keymap::Resolved r = keymap::Resolve(pressed);
|
||||
p.buttons |= r.buttons;
|
||||
if (r.rightTrigger)
|
||||
rt = 1.0f;
|
||||
if (k[SDL_SCANCODE_S])
|
||||
if (r.leftTrigger)
|
||||
lt = 1.0f;
|
||||
// The right stick (the game shifts gears with it: drag races
|
||||
// need it). While either key of an axis is held the keyboard
|
||||
// has that axis; both keys centre it.
|
||||
if (k[SDL_SCANCODE_O] || k[SDL_SCANCODE_L])
|
||||
ry = float(k[SDL_SCANCODE_O]) - float(k[SDL_SCANCODE_L]);
|
||||
if (k[SDL_SCANCODE_K] || k[SDL_SCANCODE_SEMICOLON])
|
||||
rx = float(k[SDL_SCANCODE_SEMICOLON]) - float(k[SDL_SCANCODE_K]);
|
||||
float* axes[size_t(keymap::Axis::Count)] = { &lx, &ly, &rx, &ry }; // Axis order: LX, LY, RX, RY
|
||||
for (size_t a = 0; a < size_t(keymap::Axis::Count); a++)
|
||||
if (r.axisSet[a])
|
||||
*axes[a] = r.axis[a];
|
||||
}
|
||||
p.lx = ToShort(lx);
|
||||
p.ly = ToShort(ly);
|
||||
|
||||
+6
-4
@@ -8,10 +8,12 @@
|
||||
// sticks rest at exactly zero while small movements still ramp up
|
||||
// smoothly from the edge of the deadzone. Triggers get their own
|
||||
// (NFSMW_TRIGGER_DEADZONE, default 0.02).
|
||||
// - Keyboard: arrows = D-pad + left stick, W/S = gas/brake (RT/LT), A/D =
|
||||
// steer, Space = A, Backspace = B, Left Shift = X, Left Ctrl = Y,
|
||||
// Q/E = LB/RB, Enter = START, Esc = BACK, O/L and K/; = right stick
|
||||
// (up/down, left/right), C = right stick click.
|
||||
// - Keyboard: each Xbox 360 control has two keys (Settings > Controls;
|
||||
// the [keys] table of settings.toml; hid/key_map.h lists them).
|
||||
// Defaults: arrows = D-pad, A/D and Left/Right = steer, Up/Down = left
|
||||
// stick too, W/S = gas/brake (RT/LT), Space = A, Backspace = B,
|
||||
// Left Shift = X, Left Ctrl = Y, Q/E = LB/RB, Enter = START, Esc = BACK,
|
||||
// O/L/K/; = right stick, Z/C = the stick clicks. A key may drive several.
|
||||
// - NFSMW_INPUT_SCRIPT="12:START,15.5:A,30-90/4:DOWN,100~160:RT": taps
|
||||
// (150 ms) at seconds since launch, taps every `step` seconds over a
|
||||
// range, or inputs held over a range; RT/LT (triggers) and LL/LR/LU/LD
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The keyboard as an Xbox 360 controller (Settings > Controls): the 24
|
||||
// controls a key can drive, in the page's order, each with the two settings
|
||||
// that hold its keys (user/settings.h, the [keys] table of settings.toml),
|
||||
// and how a set of held keys becomes buttons, triggers and stick axes.
|
||||
// Header only and SDL-free: hid/input.cpp samples the keys and feeds the
|
||||
// result to the pad; ui/settings_menu.cpp draws one row per entry;
|
||||
// tests/key_map_test.cpp checks the rules.
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include <user/settings.h>
|
||||
|
||||
namespace hid::keymap
|
||||
{
|
||||
enum class Control : uint8_t
|
||||
{
|
||||
RightTrigger, LeftTrigger,
|
||||
LeftStickLeft, LeftStickRight, LeftStickUp, LeftStickDown,
|
||||
A, B, X, Y, LB, RB,
|
||||
DpadUp, DpadDown, DpadLeft, DpadRight,
|
||||
Start, Back,
|
||||
RightStickUp, RightStickDown, RightStickLeft, RightStickRight,
|
||||
LeftStickClick, RightStickClick,
|
||||
Count
|
||||
};
|
||||
constexpr size_t kControls = size_t(Control::Count);
|
||||
|
||||
enum class Role : uint8_t { Button, LeftTrigger, RightTrigger, AxisNeg, AxisPos };
|
||||
enum class Axis : uint8_t { LX, LY, RX, RY, Count };
|
||||
|
||||
// XInput's button bits, as the game reads them (xbox.h's
|
||||
// XAMINPUT_GAMEPAD_*; input.cpp asserts they agree). Literals here so
|
||||
// this header needs no kernel header.
|
||||
namespace button
|
||||
{
|
||||
constexpr uint16_t DpadUp = 0x0001, DpadDown = 0x0002, DpadLeft = 0x0004, DpadRight = 0x0008;
|
||||
constexpr uint16_t Start = 0x0010, Back = 0x0020, LeftThumb = 0x0040, RightThumb = 0x0080;
|
||||
constexpr uint16_t LeftShoulder = 0x0100, RightShoulder = 0x0200;
|
||||
constexpr uint16_t A = 0x1000, B = 0x2000, X = 0x4000, Y = 0x8000;
|
||||
}
|
||||
|
||||
struct Entry
|
||||
{
|
||||
Control control;
|
||||
settings::Id id; // the option holding its first key
|
||||
settings::Id id2; // ...and its second (always id + 1)
|
||||
Role role;
|
||||
uint16_t button = 0; // Role::Button
|
||||
Axis axis = Axis::LX; // Role::AxisNeg, AxisPos
|
||||
};
|
||||
|
||||
constexpr Entry kEntries[kControls] = {
|
||||
{ Control::RightTrigger, settings::Id::KeyRightTrigger, settings::Id::KeyRightTrigger2, Role::RightTrigger },
|
||||
{ Control::LeftTrigger, settings::Id::KeyLeftTrigger, settings::Id::KeyLeftTrigger2, Role::LeftTrigger },
|
||||
{ Control::LeftStickLeft, settings::Id::KeyLeftStickLeft, settings::Id::KeyLeftStickLeft2, Role::AxisNeg, 0, Axis::LX },
|
||||
{ Control::LeftStickRight, settings::Id::KeyLeftStickRight, settings::Id::KeyLeftStickRight2, Role::AxisPos, 0, Axis::LX },
|
||||
{ Control::LeftStickUp, settings::Id::KeyLeftStickUp, settings::Id::KeyLeftStickUp2, Role::AxisPos, 0, Axis::LY },
|
||||
{ Control::LeftStickDown, settings::Id::KeyLeftStickDown, settings::Id::KeyLeftStickDown2, Role::AxisNeg, 0, Axis::LY },
|
||||
{ Control::A, settings::Id::KeyA, settings::Id::KeyA2, Role::Button, button::A },
|
||||
{ Control::B, settings::Id::KeyB, settings::Id::KeyB2, Role::Button, button::B },
|
||||
{ Control::X, settings::Id::KeyX, settings::Id::KeyX2, Role::Button, button::X },
|
||||
{ Control::Y, settings::Id::KeyY, settings::Id::KeyY2, Role::Button, button::Y },
|
||||
{ Control::LB, settings::Id::KeyLB, settings::Id::KeyLB2, Role::Button, button::LeftShoulder },
|
||||
{ Control::RB, settings::Id::KeyRB, settings::Id::KeyRB2, Role::Button, button::RightShoulder },
|
||||
{ Control::DpadUp, settings::Id::KeyDpadUp, settings::Id::KeyDpadUp2, Role::Button, button::DpadUp },
|
||||
{ Control::DpadDown, settings::Id::KeyDpadDown, settings::Id::KeyDpadDown2, Role::Button, button::DpadDown },
|
||||
{ Control::DpadLeft, settings::Id::KeyDpadLeft, settings::Id::KeyDpadLeft2, Role::Button, button::DpadLeft },
|
||||
{ Control::DpadRight, settings::Id::KeyDpadRight, settings::Id::KeyDpadRight2, Role::Button, button::DpadRight },
|
||||
{ Control::Start, settings::Id::KeyStart, settings::Id::KeyStart2, Role::Button, button::Start },
|
||||
{ Control::Back, settings::Id::KeyBack, settings::Id::KeyBack2, Role::Button, button::Back },
|
||||
{ Control::RightStickUp, settings::Id::KeyRightStickUp, settings::Id::KeyRightStickUp2, Role::AxisPos, 0, Axis::RY },
|
||||
{ Control::RightStickDown, settings::Id::KeyRightStickDown, settings::Id::KeyRightStickDown2, Role::AxisNeg, 0, Axis::RY },
|
||||
{ Control::RightStickLeft, settings::Id::KeyRightStickLeft, settings::Id::KeyRightStickLeft2, Role::AxisNeg, 0, Axis::RX },
|
||||
{ Control::RightStickRight, settings::Id::KeyRightStickRight, settings::Id::KeyRightStickRight2, Role::AxisPos, 0, Axis::RX },
|
||||
{ Control::LeftStickClick, settings::Id::KeyLeftStickClick, settings::Id::KeyLeftStickClick2, Role::Button, button::LeftThumb },
|
||||
{ Control::RightStickClick, settings::Id::KeyRightStickClick, settings::Id::KeyRightStickClick2, Role::Button, button::RightThumb },
|
||||
};
|
||||
|
||||
consteval bool EntriesInOrder()
|
||||
{
|
||||
for (size_t i = 0; i < kControls; i++)
|
||||
if (size_t(kEntries[i].control) != i || size_t(kEntries[i].id) != size_t(settings::Id::KeyRightTrigger) + 2 * i ||
|
||||
size_t(kEntries[i].id2) != size_t(kEntries[i].id) + 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
static_assert(EntriesInOrder(), "one Entry per Control, in Control order, over the Key options in pairs");
|
||||
|
||||
// The control either of a Key option's keys belongs to.
|
||||
constexpr const Entry& EntryOf(settings::Id id)
|
||||
{
|
||||
return kEntries[(size_t(id) - size_t(settings::Id::KeyRightTrigger)) / 2];
|
||||
}
|
||||
|
||||
// What the held keys amount to.
|
||||
struct Resolved
|
||||
{
|
||||
uint16_t buttons = 0;
|
||||
bool leftTrigger = false, rightTrigger = false; // a trigger key is down: that trigger at full
|
||||
bool axisSet[size_t(Axis::Count)] = {}; // either key of the axis is down: the keyboard has the axis
|
||||
float axis[size_t(Axis::Count)] = {}; // +1, 0 (both keys) or -1 while set; up is positive, as XInput has it
|
||||
};
|
||||
|
||||
// pressed[i]: either key bound to Control(i) is down. Buttons OR in; an axis
|
||||
// is its positive key minus its negative one, and belongs to the
|
||||
// keyboard while either is held (both held: centred).
|
||||
constexpr Resolved Resolve(std::span<const bool, kControls> pressed)
|
||||
{
|
||||
Resolved r;
|
||||
for (size_t i = 0; i < kControls; i++)
|
||||
{
|
||||
if (!pressed[i])
|
||||
continue;
|
||||
const Entry& e = kEntries[i];
|
||||
switch (e.role)
|
||||
{
|
||||
case Role::Button:
|
||||
r.buttons |= e.button;
|
||||
break;
|
||||
case Role::LeftTrigger:
|
||||
r.leftTrigger = true;
|
||||
break;
|
||||
case Role::RightTrigger:
|
||||
r.rightTrigger = true;
|
||||
break;
|
||||
case Role::AxisNeg:
|
||||
r.axisSet[size_t(e.axis)] = true;
|
||||
r.axis[size_t(e.axis)] -= 1.0f;
|
||||
break;
|
||||
case Role::AxisPos:
|
||||
r.axisSet[size_t(e.axis)] = true;
|
||||
r.axis[size_t(e.axis)] += 1.0f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
+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)
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
# AMD FidelityFX Super Resolution 1 (vendored)
|
||||
|
||||
`ffx_a.h` and `ffx_fsr1.h` from https://github.com/GPUOpen-Effects/FidelityFX-FSR at commit
|
||||
a21ffb8f6c13233ba336352bdff293894c706575 (2021-11-15; its `ffx-fsr/` folder), unmodified, MIT
|
||||
license (`LICENSE.txt`, the repository's `license.txt` as it is; also
|
||||
`LICENSES/FidelityFX-FSR-MIT.txt`).
|
||||
|
||||
SHA-256:
|
||||
|
||||
f60e2722fcd13989523b9164d776ab382b3692791767f3bf8bb19967f763f3fb ffx_a.h
|
||||
93c3922362ea7fc99cbcc698ca30c98de4f8c246d1fbb0b09e015ddef38ce3a5 ffx_fsr1.h
|
||||
db089274ce766da70f5b7d791029c3486f9f9e27c8c79c652689603d3192e802 LICENSE.txt
|
||||
|
||||
Used by Settings > Graphics > Upscaling's FSR 1 (`runtime/video/fsr1.cpp`): their 32-bit GLSL
|
||||
EASU and RCAS functions are compiled into the presenter's shaders (the build embeds both files'
|
||||
text, `generated/fsr1_headers.inc`), and their CPU code computes the shaders' constants.
|
||||
Vendored
+2656
File diff suppressed because it is too large.
Load diff
Vendored
+1199
File diff suppressed because it is too large.
Load diff
+327
-10
@@ -15,11 +15,14 @@
|
||||
|
||||
#include <report/report.h>
|
||||
#include <user/settings.h>
|
||||
#include <hid/key_map.h> // the Controls page: one row per control, two keys each
|
||||
#include <user/key_names.h> // a key's name in the file, when SDL has none
|
||||
#include <video/frame_rate.h>
|
||||
#include <video/presenter.h>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <imgui_internal.h> // ClosePopupToLevel: a dialog left open when the menu closed
|
||||
#include <SDL3/SDL_keyboard.h> // a key row's label on this layout
|
||||
|
||||
#include <SDL3/SDL_error.h>
|
||||
#include <SDL3/SDL_events.h>
|
||||
@@ -39,6 +42,22 @@ namespace ui
|
||||
bool s_describedBugReport = false; // ...or Save Bug Report's
|
||||
bool s_describedUpdate = false; // ...or Check for Updates'
|
||||
bool s_opened = true; // just opened: take focus
|
||||
// The Controls page (KeyRow). A row's two keys are cells; the chosen
|
||||
// cell is the focused row's, and a wait is for one option of one
|
||||
// row (SettingsMenuTakesKey takes the press before ImGui sees it).
|
||||
Id s_waiting = Id::Count; // the Key option a row waits for; Count: no wait
|
||||
SDL_Scancode s_refused = SDL_SCANCODE_UNKNOWN; // a press the wait wouldn't take; the help line says so
|
||||
SDL_Scancode s_takenKey = SDL_SCANCODE_UNKNOWN; // the key a wait took or refused, until its release
|
||||
Id s_slotRow = Id::Count; // the row whose chosen cell s_slot is
|
||||
int s_slot = 0; // 0: the row's first key, 1: its second
|
||||
constexpr const char* kPressAKey = "Press a key";
|
||||
constexpr const char* kNoKey = "none";
|
||||
|
||||
void EndWait()
|
||||
{
|
||||
s_waiting = Id::Count;
|
||||
s_refused = SDL_SCANCODE_UNKNOWN;
|
||||
}
|
||||
|
||||
constexpr int kCategories = int(Category::Count);
|
||||
|
||||
@@ -62,6 +81,80 @@ namespace ui
|
||||
return delta;
|
||||
}
|
||||
|
||||
// iPhone and iPad: the Controls page only while a keyboard is
|
||||
// connected (nothing to bind without one).
|
||||
bool PageShown(Category c)
|
||||
{
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
if (c == Category::Controls)
|
||||
return SDL_HasKeyboard();
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
// A key's name for a cell: on this keyboard's layout ("Z" on a
|
||||
// French Mac for the position the file calls "w"), else SDL's name
|
||||
// of the position (the platform's: "Left Option" on a Mac), else the
|
||||
// file's. "none" for no key. Main thread.
|
||||
std::string KeyLabel(double stored)
|
||||
{
|
||||
if (stored == double(settings::keynames::kNone))
|
||||
return kNoKey;
|
||||
if (!(stored >= 1 && stored < double(SDL_SCANCODE_COUNT)))
|
||||
return "?";
|
||||
SDL_Scancode sc = SDL_Scancode(int(stored));
|
||||
const char* name = SDL_GetKeyName(SDL_GetKeyFromScancode(sc, SDL_KMOD_NONE, false));
|
||||
if (!name || !name[0])
|
||||
name = SDL_GetScancodeName(sc);
|
||||
if (!name || !name[0])
|
||||
name = settings::keynames::Token(uint16_t(stored));
|
||||
return name && name[0] ? name : "?";
|
||||
}
|
||||
|
||||
// A key the wait refused, for the help line.
|
||||
std::string RefusedName(SDL_Scancode sc)
|
||||
{
|
||||
std::string name = KeyLabel(double(sc));
|
||||
return name == "?" || name == kNoKey ? "That key" : name;
|
||||
}
|
||||
|
||||
// The other controls that have any of this row's keys ("D-pad
|
||||
// Left"), in page order, each once; "" when none.
|
||||
std::string SharedWith(const hid::keymap::Entry& e)
|
||||
{
|
||||
const double mine[2] = { settings::GetStoredValue(e.id), settings::GetStoredValue(e.id2) };
|
||||
std::string text;
|
||||
for (const auto& other : hid::keymap::kEntries)
|
||||
{
|
||||
if (other.control == e.control)
|
||||
continue;
|
||||
const double theirs[2] = { settings::GetStoredValue(other.id), settings::GetStoredValue(other.id2) };
|
||||
bool shared = false;
|
||||
for (double m : mine)
|
||||
for (double th : theirs)
|
||||
shared |= m != double(settings::keynames::kNone) && m == th;
|
||||
if (shared)
|
||||
text += (text.empty() ? "" : ", ") + std::string(settings::Info(other.id).label);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// A key cell is as wide as the widest name among the 48 keys, "Press
|
||||
// a key" or "none"; a row has two and a gap.
|
||||
float KeyCellWidth()
|
||||
{
|
||||
float width = std::max(ImGui::CalcTextSize(kPressAKey).x, ImGui::CalcTextSize(kNoKey).x);
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.type == settings::Type::Key)
|
||||
width = std::max(width, ImGui::CalcTextSize(KeyLabel(settings::GetStoredValue(o.id)).c_str()).x);
|
||||
return width;
|
||||
}
|
||||
|
||||
float KeyCellGap()
|
||||
{
|
||||
return kSpaceM * Scale();
|
||||
}
|
||||
|
||||
// Widest value this option can show, for the value column.
|
||||
float ValueWidth(const settings::Option& o)
|
||||
{
|
||||
@@ -77,6 +170,9 @@ namespace ui
|
||||
for (const auto& e : o.entries)
|
||||
measure(e.value);
|
||||
break;
|
||||
case settings::Type::Key:
|
||||
width = 2 * KeyCellWidth() + KeyCellGap();
|
||||
break;
|
||||
default:
|
||||
measure(o.min);
|
||||
measure(o.max);
|
||||
@@ -250,6 +346,110 @@ namespace ui
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
// A Controls row: the two keys that drive the control, as two cells.
|
||||
// Left and Right choose a cell (the focused row's choice; another
|
||||
// row starts on its first); A or Enter waits for a key for it, which
|
||||
// SettingsMenuTakesKey takes from the event loop before ImGui sees
|
||||
// it; Y or Backspace empties it; X or Delete puts both keys back to
|
||||
// their defaults. A click or tap only selects the row, and the cell
|
||||
// under the pointer, as on the other pages. The wait follows the
|
||||
// focus (the D-pad moving on ends it) and B ends it (below, in
|
||||
// DrawSettings, where B would otherwise close the menu).
|
||||
void KeyRow(const hid::keymap::Entry& e, float valueWidth, bool focus)
|
||||
{
|
||||
const float s = Scale();
|
||||
const float rowHeight = RowHeight();
|
||||
const settings::Option& o = settings::Info(e.id);
|
||||
const Id ids[2] = { e.id, e.id2 };
|
||||
ImGui::PushID(int(e.id));
|
||||
ImGui::TableNextRow(ImGuiTableRowFlags_None, rowHeight);
|
||||
ImGui::TableNextColumn();
|
||||
if (focus)
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + LabelInset());
|
||||
ImVec2 labelPos = ImGui::GetCursorScreenPos();
|
||||
bool activated = ImGui::Selectable("##row", false,
|
||||
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap, ImVec2(0, rowHeight));
|
||||
bool focused = ImGui::IsItemFocused();
|
||||
if (focused || (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenOverlappedByItem) && s_described == Id::Count))
|
||||
s_described = e.id;
|
||||
const float mid = RowLabel(labelPos, o.label);
|
||||
|
||||
if (focused && s_slotRow != e.id)
|
||||
{
|
||||
s_slotRow = e.id;
|
||||
s_slot = 0;
|
||||
}
|
||||
const bool waiting = s_waiting == e.id || s_waiting == e.id2;
|
||||
if (waiting && !focused)
|
||||
EndWait();
|
||||
|
||||
// The cells, where other pages put their value (the arrows'
|
||||
// room kept so the columns line up from page to page).
|
||||
ImGui::TableNextColumn();
|
||||
const float arrow = ImGui::GetFrameHeight();
|
||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (rowHeight - arrow) * 0.5f);
|
||||
const ImVec2 min = ImGui::GetCursorScreenPos();
|
||||
ImGui::Dummy(ImVec2(valueWidth + arrow * 2, arrow));
|
||||
const float gap = KeyCellGap(), cell = (valueWidth - gap) * 0.5f;
|
||||
const float cellX[2] = { min.x + arrow, min.x + arrow + cell + gap };
|
||||
|
||||
const bool byPointer = ImGui::IsMouseReleased(ImGuiMouseButton_Left);
|
||||
if (activated && byPointer)
|
||||
{
|
||||
const float x = ImGui::GetMousePos().x;
|
||||
for (int i = 0; i < 2; i++)
|
||||
if (x >= cellX[i] && x < cellX[i] + cell)
|
||||
s_slot = i;
|
||||
}
|
||||
else if (activated)
|
||||
{
|
||||
s_waiting = ids[s_slot];
|
||||
s_refused = SDL_SCANCODE_UNKNOWN;
|
||||
}
|
||||
else if (focused && s_waiting == Id::Count)
|
||||
{
|
||||
if (Pressed(ImGuiKey_GamepadDpadLeft) || Pressed(ImGuiKey_GamepadLStickLeft) || Pressed(ImGuiKey_LeftArrow))
|
||||
s_slot = 0;
|
||||
if (Pressed(ImGuiKey_GamepadDpadRight) || Pressed(ImGuiKey_GamepadLStickRight) || Pressed(ImGuiKey_RightArrow))
|
||||
s_slot = 1;
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceUp, false) || ImGui::IsKeyPressed(ImGuiKey_Backspace, false))
|
||||
{
|
||||
const settings::Option& slot = settings::Info(ids[s_slot]);
|
||||
LogChange(slot, settings::GetStoredValue(slot.id), settings::Set(slot.id, settings::keynames::kNone));
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceLeft, false) || ImGui::IsKeyPressed(ImGuiKey_Delete, false))
|
||||
for (Id id : ids)
|
||||
LogChange(settings::Info(id), settings::GetStoredValue(id), settings::Reset(id));
|
||||
}
|
||||
|
||||
ImDrawList* draw = ImGui::GetWindowDrawList();
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
const bool waitingHere = s_waiting == ids[i];
|
||||
const bool chosen = focused && s_slot == i;
|
||||
const double stored = settings::GetStoredValue(ids[i]);
|
||||
const std::string text = waitingHere ? kPressAKey : KeyLabel(stored);
|
||||
const ImVec2 size = ImGui::CalcTextSize(text.c_str());
|
||||
ImU32 color;
|
||||
if (waitingHere)
|
||||
{
|
||||
PushAccentText();
|
||||
color = ImGui::GetColorU32(ImGuiCol_Text);
|
||||
PopAccentText();
|
||||
}
|
||||
else if (chosen)
|
||||
color = ImGui::GetColorU32(ImGuiCol_PlotLines);
|
||||
else
|
||||
color = ImGui::GetColorU32(stored == double(settings::keynames::kNone) ? ImGuiCol_TextDisabled : ImGuiCol_Text);
|
||||
const ImVec2 at(cellX[i] + (cell - size.x) * 0.5f, CentredTextTop(mid));
|
||||
draw->AddText(at, color, text.c_str());
|
||||
if (chosen) // underlined too, so an empty cell shows which it is
|
||||
draw->AddLine(ImVec2(at.x, at.y + size.y + 2.0f * s), ImVec2(at.x + size.x, at.y + size.y + 2.0f * s), color, 1.0f * s);
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
const char* BugReportValue()
|
||||
{
|
||||
switch (report::GetBugReportState())
|
||||
@@ -427,7 +627,30 @@ namespace ui
|
||||
return;
|
||||
}
|
||||
const settings::Option& o = settings::Info(s_described);
|
||||
ImGui::TextWrapped("%s", o.description);
|
||||
if (o.type == settings::Type::Key)
|
||||
{
|
||||
const hid::keymap::Entry& e = hid::keymap::EntryOf(o.id);
|
||||
if (s_waiting == e.id || s_waiting == e.id2)
|
||||
{
|
||||
PushAccentText();
|
||||
if (s_refused != SDL_SCANCODE_UNKNOWN)
|
||||
ImGui::Text("%s can't be used here. Press another key; Esc or B cancels.", RefusedName(s_refused).c_str());
|
||||
else
|
||||
ImGui::Text("Press the key for %s. Esc or B cancels.", o.label);
|
||||
PopAccentText();
|
||||
ImGui::TextDisabled(" ");
|
||||
return;
|
||||
}
|
||||
ImGui::TextWrapped("%s", o.description);
|
||||
// The keyboard's keys for what the legend names in
|
||||
// controller terms (Y, X); and a key on several controls is
|
||||
// fine (the defaults share the arrows), but say which, in
|
||||
// case it wasn't meant.
|
||||
std::string also = SharedWith(e);
|
||||
ImGui::TextDisabled("Enter sets the key, Backspace clears it, Delete restores the row.%s%s%s",
|
||||
also.empty() ? "" : " Also: ", also.c_str(), also.empty() ? "" : ".");
|
||||
return;
|
||||
}
|
||||
if (o.id == Id::Fullscreen && video::FullscreenLocked())
|
||||
{
|
||||
ImGui::TextDisabled("Always on in Game Mode: Steam sets the resolution (Properties > Game Resolution).");
|
||||
@@ -500,18 +723,43 @@ namespace ui
|
||||
const bool dialog = ImGui::IsPopupOpen("reset") || UpdateDialogOpen() || QuitPromptOpen();
|
||||
if (int delta = dialog ? 0 : PageDelta())
|
||||
{
|
||||
s_category = Category((int(s_category) + delta + kCategories) % kCategories);
|
||||
Category next = s_category;
|
||||
do
|
||||
next = Category((int(next) + delta + kCategories) % kCategories);
|
||||
while (!PageShown(next));
|
||||
s_category = next;
|
||||
s_tabPending = true;
|
||||
s_focusFirstRow = true;
|
||||
}
|
||||
if (!PageShown(s_category)) // the keyboard went away while on Controls
|
||||
{
|
||||
s_category = Category::Input;
|
||||
s_tabPending = true;
|
||||
s_focusFirstRow = true;
|
||||
}
|
||||
// X on the Controls page is the rows' (KeyRow: the row's default
|
||||
// keys). To ImGui it is also the window-switching key: a press
|
||||
// starts the toggle and the release moves the cursor to the root
|
||||
// window. Owning the key while the page shows keeps that off; the
|
||||
// rows' IsKeyPressed, which accepts any owner, still sees it.
|
||||
if (s_category == Category::Controls)
|
||||
ImGui::SetKeyOwner(ImGuiKey_GamepadFaceLeft, ImHashStr("controls-x"));
|
||||
s_described = Id::Count;
|
||||
s_describedBugReport = false;
|
||||
s_describedUpdate = false;
|
||||
if (s_waiting != Id::Count && settings::Info(s_waiting).category != s_category)
|
||||
EndWait();
|
||||
// For the B check at the end: B's nav cancel (in ImGui's NewFrame,
|
||||
// before this) takes the waiting row's focus, and KeyRow then ends
|
||||
// the wait before that check could see it.
|
||||
const bool wasWaiting = s_waiting != Id::Count;
|
||||
if (ImGui::BeginTabBar("pages", ImGuiTabBarFlags_NoTooltip))
|
||||
{
|
||||
for (int c = 0; c < kCategories; c++)
|
||||
{
|
||||
Category category = Category(c);
|
||||
if (!PageShown(category))
|
||||
continue;
|
||||
ImGuiTabItemFlags tabFlags = (s_tabPending && category == s_category) ? ImGuiTabItemFlags_SetSelected : 0;
|
||||
ImGui::PushItemFlag(ImGuiItemFlags_NoNav, true);
|
||||
bool open = ImGui::BeginTabItem(settings::CategoryName(category), nullptr, tabFlags);
|
||||
@@ -547,9 +795,12 @@ namespace ui
|
||||
if (rows)
|
||||
{
|
||||
float valueWidth = 0.0f;
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.category == category && !o.hidden)
|
||||
valueWidth = std::max(valueWidth, ValueWidth(o));
|
||||
if (category == Category::Controls)
|
||||
valueWidth = 2 * KeyCellWidth() + KeyCellGap();
|
||||
else
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.category == category && !o.hidden)
|
||||
valueWidth = std::max(valueWidth, ValueWidth(o));
|
||||
if (category == Category::Advanced)
|
||||
for (const char* text : { "Saving...", "Checking...", UpdateRowValue() })
|
||||
valueWidth = std::max(valueWidth, ImGui::CalcTextSize(text).x);
|
||||
@@ -560,12 +811,19 @@ namespace ui
|
||||
{
|
||||
ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthStretch);
|
||||
ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthFixed, valueWidth + arrow * 2 + ValueInset());
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.category == category && !o.hidden)
|
||||
if (category == Category::Controls)
|
||||
for (const auto& e : hid::keymap::kEntries)
|
||||
{
|
||||
OptionRow(o, valueWidth, focusFirst);
|
||||
KeyRow(e, valueWidth, focusFirst);
|
||||
focusFirst = false;
|
||||
}
|
||||
else
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.category == category && !o.hidden)
|
||||
{
|
||||
OptionRow(o, valueWidth, focusFirst);
|
||||
focusFirst = false;
|
||||
}
|
||||
if (category == Category::Advanced)
|
||||
{
|
||||
BugReportRow(valueWidth);
|
||||
@@ -627,7 +885,10 @@ namespace ui
|
||||
#endif
|
||||
ImGui::SameLine();
|
||||
{
|
||||
const char* legend = "D-pad: choose, change LB RB: page B: close";
|
||||
// (The Controls page leaves out "D-pad: choose": with Quit Game in the
|
||||
// footer its legend was wider than the window and lost "B: close".)
|
||||
const char* legend = s_category == Category::Controls ? "A: set key Y: clear X: default LB RB: page B: close"
|
||||
: "D-pad: choose, change LB RB: page B: close";
|
||||
float w = ImGui::CalcTextSize(legend).x;
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - w));
|
||||
ImGui::TextDisabled("%s", legend);
|
||||
@@ -661,15 +922,29 @@ namespace ui
|
||||
if (DrawQuitPrompt())
|
||||
popup = true;
|
||||
#endif
|
||||
// B (or Esc) closes the menu; while a Controls row was waiting for
|
||||
// a key at the top of this frame it ends the wait instead (the
|
||||
// cursor goes with it, as after the Reset dialog: the next D-pad
|
||||
// press brings it back). The Esc key itself never gets here then:
|
||||
// SettingsMenuTakesKey took its press.
|
||||
if (!popup && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && !ImGui::IsAnyItemActive() &&
|
||||
(ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)))
|
||||
CloseSettings();
|
||||
{
|
||||
if (wasWaiting)
|
||||
EndWait();
|
||||
else
|
||||
CloseSettings();
|
||||
}
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
void ResetSettingsMenu()
|
||||
{
|
||||
s_opened = true;
|
||||
EndWait(); // a wait the menu was closed under is over
|
||||
s_takenKey = SDL_SCANCODE_UNKNOWN;
|
||||
s_slotRow = Id::Count;
|
||||
s_slot = 0;
|
||||
// A dialog the menu was closed under (Start or Back alone closes it
|
||||
// from anywhere) doesn't come back with it.
|
||||
if (ImGuiContext* g = ImGui::GetCurrentContext(); g && g->OpenPopupStack.Size > 0)
|
||||
@@ -677,6 +952,48 @@ namespace ui
|
||||
ForgetUpdateDialog();
|
||||
}
|
||||
|
||||
bool SettingsMenuTakesKey(const SDL_KeyboardEvent& key)
|
||||
{
|
||||
// The key a wait took or refused stays down a moment longer. ImGui never saw
|
||||
// it go down, so its OS repeats would read as fresh presses (Enter
|
||||
// would start a new wait, an arrow would move the cursor): they and
|
||||
// its release stay out of ImGui too.
|
||||
if (s_takenKey != SDL_SCANCODE_UNKNOWN && key.scancode == s_takenKey)
|
||||
{
|
||||
if (!key.down)
|
||||
s_takenKey = SDL_SCANCODE_UNKNOWN;
|
||||
if (!key.down || key.repeat)
|
||||
return true;
|
||||
s_takenKey = SDL_SCANCODE_UNKNOWN; // pressed anew: its release went by unseen
|
||||
}
|
||||
if (s_waiting == Id::Count || !key.down || key.repeat)
|
||||
return false;
|
||||
const settings::Option& o = settings::Info(s_waiting);
|
||||
s_takenKey = key.scancode;
|
||||
if (key.scancode == SDL_SCANCODE_ESCAPE)
|
||||
{
|
||||
fprintf(stderr, "[settings] menu: %s.%s: no new key (Esc)\n", o.table, o.key);
|
||||
EndWait();
|
||||
return true;
|
||||
}
|
||||
// Not a key of the table (Cmd / Windows, F1, the media keys), or a
|
||||
// key pressed with Cmd / Windows held: the wait goes on and the help
|
||||
// line says so. (Cmd+, still closes the menu on a Mac: ProcessEvent's
|
||||
// toggle sees the comma before this does; the Cmd press before it
|
||||
// lands here and is refused.) Its repeats and release stay out of
|
||||
// ImGui like a taken key's: s_takenKey is already set.
|
||||
if (key.scancode == SDL_SCANCODE_UNKNOWN || !settings::keynames::Known(uint16_t(key.scancode)) || (key.mod & SDL_KMOD_GUI))
|
||||
{
|
||||
s_refused = key.scancode;
|
||||
fprintf(stderr, "[settings] menu: %s.%s: %s can't be used\n", o.table, o.key, RefusedName(key.scancode).c_str());
|
||||
return true;
|
||||
}
|
||||
double from = settings::GetStoredValue(o.id);
|
||||
LogChange(o, from, settings::Set(o.id, double(key.scancode)));
|
||||
EndWait();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ForgetQuitPrompt()
|
||||
{
|
||||
#if SB_QUIT_GAME
|
||||
|
||||
+67
-5
@@ -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;
|
||||
@@ -638,8 +671,14 @@ namespace ui
|
||||
s_settingsOpen ? CloseSettings() : OpenSettings();
|
||||
return;
|
||||
}
|
||||
if (s_settingsOpen && SettingsMenuTakesKey(e.key))
|
||||
return; // a Controls row took it: ImGui doesn't act on it too
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_KEY_UP:
|
||||
if (s_settingsOpen && SettingsMenuTakesKey(e.key))
|
||||
return; // the release of a key a Controls row took
|
||||
break;
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_UP:
|
||||
{
|
||||
@@ -723,6 +762,10 @@ namespace ui
|
||||
|
||||
// NFSMW_UI_KEYS="3:F1,4:Down,4.5:Right": key presses (SDL key names)
|
||||
// at seconds since launch, for testing the menus without a person.
|
||||
// A key is pressed and released at once; "Hold Return" only presses
|
||||
// it, "Repeat Return" is the OS's auto-repeat of a held key (a
|
||||
// key-down marked repeat) and "Release Return" lets it go, for a key
|
||||
// held across other keys.
|
||||
// FocusLost and FocusGained send the window those events instead (as
|
||||
// switching to another window would: Mute in Background). Shot saves
|
||||
// the next presented frame as build/ui_shot_<n>.ppm: on the keys'
|
||||
@@ -735,7 +778,8 @@ namespace ui
|
||||
// motion a mouse moving there makes is posted.
|
||||
constexpr SDL_Keycode kFocusLost = SDLK_EXTENDED_MASK | 0x7F01, kFocusGained = SDLK_EXTENDED_MASK | 0x7F02,
|
||||
kShot = SDLK_EXTENDED_MASK | 0x7F03, kMouseMove = SDLK_EXTENDED_MASK | 0x7F04;
|
||||
struct Key { double time; SDL_Keycode key; };
|
||||
enum class Press : uint8_t { Tap, Hold, Repeat, Release };
|
||||
struct Key { double time; SDL_Keycode key; Press press = Press::Tap; };
|
||||
static const auto start = std::chrono::steady_clock::now();
|
||||
static std::vector<Key> keys = [] {
|
||||
std::vector<Key> parsed;
|
||||
@@ -749,12 +793,20 @@ namespace ui
|
||||
if (colon == std::string::npos)
|
||||
continue;
|
||||
std::string name = item.substr(colon + 1);
|
||||
Press press = Press::Tap;
|
||||
for (auto [prefix, kind] : { std::pair{ "Hold ", Press::Hold }, std::pair{ "Repeat ", Press::Repeat },
|
||||
std::pair{ "Release ", Press::Release } })
|
||||
if (name.starts_with(prefix))
|
||||
{
|
||||
press = kind;
|
||||
name = name.substr(strlen(prefix));
|
||||
}
|
||||
SDL_Keycode key = name == "FocusLost" ? kFocusLost : name == "FocusGained" ? kFocusGained
|
||||
: name == "Shot" ? kShot
|
||||
: name == "MouseMove" ? kMouseMove
|
||||
: SDL_GetKeyFromName(name.c_str());
|
||||
if (key != SDLK_UNKNOWN)
|
||||
parsed.push_back({ std::atof(item.c_str()), key });
|
||||
parsed.push_back({ std::atof(item.c_str()), key, press });
|
||||
else
|
||||
fprintf(stderr, "[ui] NFSMW_UI_KEYS: unknown key %s\n", item.c_str() + colon + 1);
|
||||
}
|
||||
@@ -821,8 +873,11 @@ namespace ui
|
||||
fprintf(stderr, "[ui] NFSMW_UI_KEYS: window focus %s\n", keys[next].key == kFocusLost ? "lost" : "gained");
|
||||
continue;
|
||||
}
|
||||
const Press press = keys[next].press;
|
||||
for (bool down : { true, false })
|
||||
{
|
||||
if ((down && press == Press::Release) || (!down && (press == Press::Hold || press == Press::Repeat)))
|
||||
continue;
|
||||
SDL_Event e{};
|
||||
e.type = down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP;
|
||||
e.key.timestamp = SDL_GetTicksNS();
|
||||
@@ -830,6 +885,7 @@ namespace ui
|
||||
e.key.key = keys[next].key;
|
||||
e.key.scancode = SDL_GetScancodeFromKey(keys[next].key, nullptr);
|
||||
e.key.down = down;
|
||||
e.key.repeat = press == Press::Repeat;
|
||||
SDL_PushEvent(&e);
|
||||
}
|
||||
}
|
||||
@@ -948,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)
|
||||
{
|
||||
|
||||
@@ -130,6 +130,12 @@ namespace ui
|
||||
// Screens (settings_menu.cpp, perf_overlay.cpp, installer_screen.cpp).
|
||||
void DrawSettings();
|
||||
void ResetSettingsMenu(); // on opening: focus the first row, no dialog left open
|
||||
// Main thread, from ProcessEvent on a key-down or key-up while the menu
|
||||
// is open, after the F1 toggle: true when a Controls row was waiting
|
||||
// for a key and took this one, refused it (the wait goes on), or Esc
|
||||
// cancelled it, or this is a repeat or the release of a key it took or
|
||||
// refused, so ImGui must not see it.
|
||||
bool SettingsMenuTakesKey(const SDL_KeyboardEvent& key);
|
||||
void DrawPerfOverlay();
|
||||
|
||||
// Settings > Advanced > Check for Updates (update_dialog.cpp, the check
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The keys a Controls row can hold (Settings > Controls), named for
|
||||
// settings.toml: "w", "left_shift", "keypad_1", or "none" for no key. The
|
||||
// code is the key's USB HID usage, which is what SDL's scancodes are (A is
|
||||
// 4), so a binding is the physical key whatever the layout; SDL itself
|
||||
// stays out of this header and of settings.cpp, and tests/key_names_test.cpp
|
||||
// checks every row against SDL's name for the code (hid/input.cpp pins a
|
||||
// few at compile time). SDL's own names won't do for the file: some change
|
||||
// once video starts (on a Mac Left Alt becomes "Left Option"), while the
|
||||
// settings load before that, and some keys share one. The table is also
|
||||
// the list of what can be bound: F1 (it opens the menu), Cmd / Windows and
|
||||
// the media keys are left out on purpose. Header only.
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <iterator>
|
||||
#include <string_view>
|
||||
|
||||
namespace settings::keynames
|
||||
{
|
||||
constexpr uint16_t kNone = 0; // no key; "none" in the file
|
||||
constexpr uint16_t kUnknown = 0xFFFF; // Code(): not a name in the table
|
||||
|
||||
struct Key
|
||||
{
|
||||
uint16_t code;
|
||||
const char* token;
|
||||
};
|
||||
|
||||
// Ascending by code. The tokens are SDL's scancode constants in lower
|
||||
// case with underscores: SDL_SCANCODE_LEFTBRACKET is "left_bracket",
|
||||
// SDL_SCANCODE_KP_1 "keypad_1", SDL_SCANCODE_NUMLOCKCLEAR "num_lock".
|
||||
constexpr Key kKeys[] = {
|
||||
{ 4, "a" }, { 5, "b" }, { 6, "c" }, { 7, "d" }, { 8, "e" }, { 9, "f" }, { 10, "g" }, { 11, "h" }, { 12, "i" },
|
||||
{ 13, "j" }, { 14, "k" }, { 15, "l" }, { 16, "m" }, { 17, "n" }, { 18, "o" }, { 19, "p" }, { 20, "q" }, { 21, "r" },
|
||||
{ 22, "s" }, { 23, "t" }, { 24, "u" }, { 25, "v" }, { 26, "w" }, { 27, "x" }, { 28, "y" }, { 29, "z" },
|
||||
{ 30, "1" }, { 31, "2" }, { 32, "3" }, { 33, "4" }, { 34, "5" }, { 35, "6" }, { 36, "7" }, { 37, "8" }, { 38, "9" }, { 39, "0" },
|
||||
{ 40, "return" }, { 41, "escape" }, { 42, "backspace" }, { 43, "tab" }, { 44, "space" },
|
||||
{ 45, "minus" }, { 46, "equals" }, { 47, "left_bracket" }, { 48, "right_bracket" }, { 49, "backslash" },
|
||||
{ 50, "non_us_hash" }, { 51, "semicolon" }, { 52, "apostrophe" }, { 53, "grave" }, { 54, "comma" },
|
||||
{ 55, "period" }, { 56, "slash" }, { 57, "caps_lock" },
|
||||
{ 59, "f2" }, { 60, "f3" }, { 61, "f4" }, { 62, "f5" }, { 63, "f6" }, { 64, "f7" }, { 65, "f8" }, { 66, "f9" },
|
||||
{ 67, "f10" }, { 68, "f11" }, { 69, "f12" },
|
||||
{ 70, "print_screen" }, { 71, "scroll_lock" }, { 72, "pause" }, { 73, "insert" }, { 74, "home" },
|
||||
{ 75, "page_up" }, { 76, "delete" }, { 77, "end" }, { 78, "page_down" },
|
||||
{ 79, "right" }, { 80, "left" }, { 81, "down" }, { 82, "up" },
|
||||
{ 83, "num_lock" }, { 84, "keypad_divide" }, { 85, "keypad_multiply" }, { 86, "keypad_minus" },
|
||||
{ 87, "keypad_plus" }, { 88, "keypad_enter" },
|
||||
{ 89, "keypad_1" }, { 90, "keypad_2" }, { 91, "keypad_3" }, { 92, "keypad_4" }, { 93, "keypad_5" },
|
||||
{ 94, "keypad_6" }, { 95, "keypad_7" }, { 96, "keypad_8" }, { 97, "keypad_9" }, { 98, "keypad_0" },
|
||||
{ 99, "keypad_period" }, { 100, "non_us_backslash" }, { 101, "application" }, { 103, "keypad_equals" },
|
||||
{ 104, "f13" }, { 105, "f14" }, { 106, "f15" }, { 107, "f16" }, { 108, "f17" }, { 109, "f18" },
|
||||
{ 110, "f19" }, { 111, "f20" }, { 112, "f21" }, { 113, "f22" }, { 114, "f23" }, { 115, "f24" },
|
||||
{ 133, "keypad_comma" },
|
||||
{ 135, "international_1" }, { 136, "international_2" }, { 137, "international_3" },
|
||||
{ 138, "international_4" }, { 139, "international_5" }, { 140, "international_6" },
|
||||
{ 224, "left_ctrl" }, { 225, "left_shift" }, { 226, "left_alt" },
|
||||
{ 228, "right_ctrl" }, { 229, "right_shift" }, { 230, "right_alt" },
|
||||
};
|
||||
constexpr size_t kKeyCount = std::size(kKeys);
|
||||
|
||||
namespace detail
|
||||
{
|
||||
constexpr char Lower(char c)
|
||||
{
|
||||
return c >= 'A' && c <= 'Z' ? char(c - 'A' + 'a') : c;
|
||||
}
|
||||
|
||||
// `text` in any case against a lower-case token.
|
||||
constexpr bool Same(std::string_view text, const char* token)
|
||||
{
|
||||
size_t i = 0;
|
||||
for (; i < text.size() && token[i]; i++)
|
||||
if (Lower(text[i]) != token[i])
|
||||
return false;
|
||||
return i == text.size() && token[i] == '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// The file's name for a code: "none" for kNone; nullptr for a code that
|
||||
// isn't a key here.
|
||||
constexpr const char* Token(uint16_t code)
|
||||
{
|
||||
if (code == kNone)
|
||||
return "none";
|
||||
for (const Key& k : kKeys)
|
||||
if (k.code == code)
|
||||
return k.token;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The code for a name in the file, in any case; kUnknown when it isn't one.
|
||||
constexpr uint16_t Code(std::string_view token)
|
||||
{
|
||||
if (detail::Same(token, "none"))
|
||||
return kNone;
|
||||
for (const Key& k : kKeys)
|
||||
if (detail::Same(token, k.token))
|
||||
return k.code;
|
||||
return kUnknown;
|
||||
}
|
||||
|
||||
// A value a Key option can hold: no key, or a key of the table.
|
||||
constexpr bool Known(uint16_t code)
|
||||
{
|
||||
return code == kNone || Token(code) != nullptr;
|
||||
}
|
||||
}
|
||||
+136
-7
@@ -27,6 +27,8 @@
|
||||
#include <TargetConditionals.h> // TARGET_OS_IOS: __APPLE__ alone means macOS or iOS
|
||||
#endif
|
||||
|
||||
#include "key_names.h" // the Key options: the keys and their names in the file
|
||||
|
||||
// toml++ (header-only), as vendored by XenonRecomp. The fallback path keeps
|
||||
// this file building before runtime/CMakeLists.txt adds the include
|
||||
// directory (the runtime globs every .cpp under runtime/).
|
||||
@@ -45,9 +47,23 @@ namespace settings
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// FSR 1 isn't offered on iPhone and iPad: its EASU pass costs an
|
||||
// Apple GPU 2-3x the bicubic pass, and a phone's 1x picture is
|
||||
// enlarged about 2x every frame. Without the entry, "fsr1" in
|
||||
// settings.toml or NFSMW_SCALING reads as unknown (bicubic), so the
|
||||
// presenter never makes an FSR 1 shader or pipeline there (it makes
|
||||
// them only for a frame whose pass is FSR 1: video/upscale.h).
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
constexpr bool kFsr1Offered = false;
|
||||
#else
|
||||
constexpr bool kFsr1Offered = true;
|
||||
#endif
|
||||
constexpr EnumEntry kScalingEntries[] = {
|
||||
{ int32_t(ScalingMode::Bicubic), "bicubic", "Bicubic" },
|
||||
{ int32_t(ScalingMode::Linear), "linear", "Bilinear" },
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
{ int32_t(ScalingMode::Fsr1), "fsr1", "FSR 1" },
|
||||
#endif
|
||||
};
|
||||
|
||||
// Frame Rate: the game's frames per second (60, or 30 on a GPU that
|
||||
@@ -104,6 +120,31 @@ namespace settings
|
||||
{ kAnisotropyGame, "game", "Original" },
|
||||
};
|
||||
|
||||
// A Controls row's key: the key (user/key_names.h, by its name
|
||||
// there) that drives one Xbox 360 control. Two per control, the
|
||||
// second named <key>_2 with the same label. No environment
|
||||
// variable: 48 of them would clutter the file's comments for nobody.
|
||||
constexpr Option KeyOption(Id id, const char* key, const char* label, const char* description, const char* token)
|
||||
{
|
||||
return { .id = id, .category = Category::Controls, .type = Type::Key, .apply = Apply::Live,
|
||||
.table = "keys", .key = key, .label = label, .description = description, .env = nullptr,
|
||||
.defaultValue = double(keynames::Code(token)) };
|
||||
}
|
||||
|
||||
constexpr Option KeyOption2(Id id, const char* key, const char* label, const char* token)
|
||||
{
|
||||
return KeyOption(id, key, label, "A second key for the same control, or none.", token);
|
||||
}
|
||||
|
||||
// A stored or offered value as a key code: kUnknown unless it's a
|
||||
// whole number the key table knows (none included).
|
||||
constexpr uint16_t KeyCode(double v)
|
||||
{
|
||||
if (!(v >= 0 && v < double(keynames::kUnknown)) || v != uint16_t(v))
|
||||
return keynames::kUnknown;
|
||||
return keynames::Known(uint16_t(v)) ? uint16_t(v) : keynames::kUnknown;
|
||||
}
|
||||
|
||||
// Defaults are what the runtime did before settings existed, so an
|
||||
// empty settings.toml changes nothing.
|
||||
constexpr Option kOptions[] = {
|
||||
@@ -130,7 +171,9 @@ namespace settings
|
||||
.env = "NFSMW_AA", .defaultValue = 1 },
|
||||
{ .id = Id::Scaling, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
|
||||
.table = "video", .key = "scaling", .label = "Upscaling",
|
||||
.description = "How the 720p image is scaled to the window. Bicubic is sharper, bilinear softer.",
|
||||
.description = kFsr1Offered
|
||||
? "How the game's picture is enlarged to the window. Bicubic is sharp, bilinear softer. FSR 1 (AMD FidelityFX Super Resolution 1) keeps curves, wires and diagonals smooth when the window is much larger than the Internal Resolution (1x on a 1440p or 4K screen), for a little more GPU time."
|
||||
: "How the game's picture is enlarged to the screen. Bicubic is sharp, bilinear softer.",
|
||||
.env = "NFSMW_SCALING", .defaultValue = double(ScalingMode::Bicubic), .entries = kScalingEntries },
|
||||
{ .id = Id::AspectRatio, .category = Category::Display, .type = Type::Enum, .apply = Apply::Live,
|
||||
.table = "video", .key = "aspect_ratio", .label = "Aspect Ratio",
|
||||
@@ -147,7 +190,9 @@ namespace settings
|
||||
.env = "NFSMW_DISPLAY_GAMMA", .defaultValue = 1 },
|
||||
{ .id = Id::Sharpening, .category = Category::Graphics, .type = Type::Float, .apply = Apply::Live,
|
||||
.table = "video", .key = "sharpening", .label = "Sharpening",
|
||||
.description = "Extra sharpness for bicubic upscaling. 0 turns it off.",
|
||||
.description = kFsr1Offered
|
||||
? "Extra sharpness for bicubic and FSR 1 upscaling (for FSR 1, the share of its sharpening pass's full strength). 0 turns it off."
|
||||
: "Extra sharpness for bicubic upscaling. 0 turns it off.",
|
||||
.env = "NFSMW_SHARPEN", .defaultValue = 0.3, .min = 0, .max = 1, .step = 0.05 },
|
||||
{ .id = Id::Anisotropy, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
|
||||
.table = "video", .key = "anisotropic_filtering", .label = "Anisotropic Filtering",
|
||||
@@ -195,6 +240,57 @@ namespace settings
|
||||
.table = "input", .key = "vibration", .label = "Vibration",
|
||||
.description = "Controller rumble.",
|
||||
.env = "NFSMW_VIBRATION", .defaultValue = 1 },
|
||||
// Controls (Settings > Controls; hid/key_map.h reads them). The
|
||||
// defaults are v0.1.4's keys: the arrows steer as well as A
|
||||
// and D, and are the D-pad; O/L/K/; are the right stick.
|
||||
KeyOption(Id::KeyRightTrigger, "right_trigger", "Right Trigger", "Gas in the game's default layout.", "w"),
|
||||
KeyOption2(Id::KeyRightTrigger2, "right_trigger_2", "Right Trigger", "none"),
|
||||
KeyOption(Id::KeyLeftTrigger, "left_trigger", "Left Trigger", "Brake and reverse in the game's default layout.", "s"),
|
||||
KeyOption2(Id::KeyLeftTrigger2, "left_trigger_2", "Left Trigger", "none"),
|
||||
KeyOption(Id::KeyLeftStickLeft, "left_stick_left", "Left Stick Left", "Steer left.", "a"),
|
||||
KeyOption2(Id::KeyLeftStickLeft2, "left_stick_left_2", "Left Stick Left", "left"),
|
||||
KeyOption(Id::KeyLeftStickRight, "left_stick_right", "Left Stick Right", "Steer right.", "d"),
|
||||
KeyOption2(Id::KeyLeftStickRight2, "left_stick_right_2", "Left Stick Right", "right"),
|
||||
KeyOption(Id::KeyLeftStickUp, "left_stick_up", "Left Stick Up", "The left stick pushed up (menus).", "up"),
|
||||
KeyOption2(Id::KeyLeftStickUp2, "left_stick_up_2", "Left Stick Up", "none"),
|
||||
KeyOption(Id::KeyLeftStickDown, "left_stick_down", "Left Stick Down", "The left stick pulled down.", "down"),
|
||||
KeyOption2(Id::KeyLeftStickDown2, "left_stick_down_2", "Left Stick Down", "none"),
|
||||
KeyOption(Id::KeyA, "a", "A", "The A button.", "space"),
|
||||
KeyOption2(Id::KeyA2, "a_2", "A", "none"),
|
||||
KeyOption(Id::KeyB, "b", "B", "The B button.", "backspace"),
|
||||
KeyOption2(Id::KeyB2, "b_2", "B", "none"),
|
||||
KeyOption(Id::KeyX, "x", "X", "The X button.", "left_shift"),
|
||||
KeyOption2(Id::KeyX2, "x_2", "X", "none"),
|
||||
KeyOption(Id::KeyY, "y", "Y", "The Y button.", "left_ctrl"),
|
||||
KeyOption2(Id::KeyY2, "y_2", "Y", "none"),
|
||||
KeyOption(Id::KeyLB, "lb", "LB", "The left shoulder button.", "q"),
|
||||
KeyOption2(Id::KeyLB2, "lb_2", "LB", "none"),
|
||||
KeyOption(Id::KeyRB, "rb", "RB", "The right shoulder button.", "e"),
|
||||
KeyOption2(Id::KeyRB2, "rb_2", "RB", "none"),
|
||||
KeyOption(Id::KeyDpadUp, "dpad_up", "D-pad Up", "The D-pad, up.", "up"),
|
||||
KeyOption2(Id::KeyDpadUp2, "dpad_up_2", "D-pad Up", "none"),
|
||||
KeyOption(Id::KeyDpadDown, "dpad_down", "D-pad Down", "The D-pad, down.", "down"),
|
||||
KeyOption2(Id::KeyDpadDown2, "dpad_down_2", "D-pad Down", "none"),
|
||||
KeyOption(Id::KeyDpadLeft, "dpad_left", "D-pad Left", "The D-pad, left.", "left"),
|
||||
KeyOption2(Id::KeyDpadLeft2, "dpad_left_2", "D-pad Left", "none"),
|
||||
KeyOption(Id::KeyDpadRight, "dpad_right", "D-pad Right", "The D-pad, right.", "right"),
|
||||
KeyOption2(Id::KeyDpadRight2, "dpad_right_2", "D-pad Right", "none"),
|
||||
KeyOption(Id::KeyStart, "start", "Start", "Start: pause.", "return"),
|
||||
KeyOption2(Id::KeyStart2, "start_2", "Start", "none"),
|
||||
KeyOption(Id::KeyBack, "back", "Back", "Back.", "escape"),
|
||||
KeyOption2(Id::KeyBack2, "back_2", "Back", "none"),
|
||||
KeyOption(Id::KeyRightStickUp, "right_stick_up", "Right Stick Up", "The right stick pushed up.", "o"),
|
||||
KeyOption2(Id::KeyRightStickUp2, "right_stick_up_2", "Right Stick Up", "none"),
|
||||
KeyOption(Id::KeyRightStickDown, "right_stick_down", "Right Stick Down", "The right stick pulled down.", "l"),
|
||||
KeyOption2(Id::KeyRightStickDown2, "right_stick_down_2", "Right Stick Down", "none"),
|
||||
KeyOption(Id::KeyRightStickLeft, "right_stick_left", "Right Stick Left", "The right stick pushed left.", "k"),
|
||||
KeyOption2(Id::KeyRightStickLeft2, "right_stick_left_2", "Right Stick Left", "none"),
|
||||
KeyOption(Id::KeyRightStickRight, "right_stick_right", "Right Stick Right", "The right stick pushed right.", "semicolon"),
|
||||
KeyOption2(Id::KeyRightStickRight2, "right_stick_right_2", "Right Stick Right", "none"),
|
||||
KeyOption(Id::KeyLeftStickClick, "left_stick_click", "Left Stick Click", "Pressing the left stick in.", "z"),
|
||||
KeyOption2(Id::KeyLeftStickClick2, "left_stick_click_2", "Left Stick Click", "none"),
|
||||
KeyOption(Id::KeyRightStickClick, "right_stick_click", "Right Stick Click", "Pressing the right stick in.", "c"),
|
||||
KeyOption2(Id::KeyRightStickClick2, "right_stick_click_2", "Right Stick Click", "none"),
|
||||
// Advanced
|
||||
{ .id = Id::AsyncShaders, .category = Category::Advanced, .type = Type::Bool, .apply = Apply::Live,
|
||||
.table = "advanced", .key = "async_shaders", .label = "Asynchronous Shaders",
|
||||
@@ -232,7 +328,10 @@ namespace settings
|
||||
if (!found)
|
||||
return false;
|
||||
}
|
||||
if (o.env == nullptr || o.label == nullptr || o.description == nullptr)
|
||||
if (o.type == Type::Key && !(KeyCode(o.defaultValue) != keynames::kUnknown && o.min == 0 && o.max == 0 &&
|
||||
o.step == 0 && o.entries.empty() && !o.hidden))
|
||||
return false;
|
||||
if ((o.env == nullptr) != (o.type == Type::Key) || o.label == nullptr || o.description == nullptr)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -429,6 +528,11 @@ namespace settings
|
||||
if (const EnumEntry* e = FindEntry(o, v))
|
||||
return double(e->value);
|
||||
return {};
|
||||
case Type::Key:
|
||||
// No key, or a key of the table: the file holds its name.
|
||||
if (KeyCode(v) == keynames::kUnknown)
|
||||
return {};
|
||||
return v;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -453,6 +557,8 @@ namespace settings
|
||||
text += std::format(", or a number from {} to {}", o.min, o.max);
|
||||
return text;
|
||||
}
|
||||
case Type::Key:
|
||||
return "a key name such as \"w\", \"left_shift\" or \"keypad_1\", or \"none\" (user/key_names.h lists them)";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -476,6 +582,10 @@ namespace settings
|
||||
if (auto n = ParseNumber(text))
|
||||
return EnumFromNumber(o, *n);
|
||||
return {};
|
||||
case Type::Key:
|
||||
if (uint16_t code = keynames::Code(text); code != keynames::kUnknown)
|
||||
return double(code);
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -504,6 +614,8 @@ namespace settings
|
||||
return n;
|
||||
case Type::Enum:
|
||||
return EnumFromNumber(o, *n);
|
||||
case Type::Key:
|
||||
return {}; // names only
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -569,6 +681,8 @@ namespace settings
|
||||
{
|
||||
const Option& o = kOptions[i];
|
||||
s_envSet[i].store(false, std::memory_order_relaxed);
|
||||
if (!o.env)
|
||||
return; // a Key option: the file alone
|
||||
const char* v = std::getenv(o.env);
|
||||
if (!v)
|
||||
return;
|
||||
@@ -718,6 +832,13 @@ namespace settings
|
||||
if (const EnumEntry* e = FindEntry(o, Default(size_t(o.id))))
|
||||
return std::format("\"{}\"", e->token);
|
||||
return std::format("\"{}\"", FindEntry(o, o.defaultValue)->token);
|
||||
case Type::Key:
|
||||
{
|
||||
uint16_t code = KeyCode(v);
|
||||
if (code == keynames::kUnknown)
|
||||
code = KeyCode(o.defaultValue);
|
||||
return std::format("\"{}\"", keynames::Token(code)); // [a-z0-9_]+: nothing to escape
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -727,8 +848,8 @@ namespace settings
|
||||
std::string out =
|
||||
"# SpeedBreaker settings, written by the game's settings menu. Hand edits\n"
|
||||
"# are fine while the game is closed; unknown keys are dropped on the next\n"
|
||||
"# save. The environment variable after each option overrides it for one\n"
|
||||
"# run without changing this file.\n";
|
||||
"# save. The environment variable after an option overrides it for one run\n"
|
||||
"# without changing this file.\n";
|
||||
const char* table = "";
|
||||
for (size_t i = 0; i < kCount; i++)
|
||||
{
|
||||
@@ -742,8 +863,8 @@ namespace settings
|
||||
out += "\n";
|
||||
std::string range = RangeText(o);
|
||||
range[0] = char(std::toupper(uint8_t(range[0])));
|
||||
out += std::format("# {}: {}\n# {}. ({}{})\n", o.label, o.description, range, o.env,
|
||||
o.apply == Apply::Restart ? "; applies at the next launch" : "");
|
||||
std::string override = o.env ? std::format(" ({}{})", o.env, o.apply == Apply::Restart ? "; applies at the next launch" : "") : "";
|
||||
out += std::format("# {}: {}\n# {}.{}\n", o.label, o.description, range, override);
|
||||
out += std::format("{} = {}\n", o.key, TomlValue(o, values[i]));
|
||||
}
|
||||
std::string lastVersion;
|
||||
@@ -932,6 +1053,7 @@ namespace settings
|
||||
case Category::Graphics: return "Graphics";
|
||||
case Category::Audio: return "Audio";
|
||||
case Category::Input: return "Input";
|
||||
case Category::Controls: return "Controls";
|
||||
case Category::Advanced: return "Advanced";
|
||||
default: return "";
|
||||
}
|
||||
@@ -1069,6 +1191,8 @@ namespace settings
|
||||
double index = delta > 0 ? std::floor(position) + delta : delta < 0 ? std::ceil(position) + delta : position;
|
||||
return *Normalize(o, o.min + index * o.step); // not NaN: v and step are finite
|
||||
}
|
||||
case Type::Key:
|
||||
return value; // a key has no neighbour: the menu takes a key press instead
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1085,6 +1209,11 @@ namespace settings
|
||||
if (const EnumEntry* e = FindEntry(o, value))
|
||||
return e->label;
|
||||
return "?";
|
||||
case Type::Key:
|
||||
{
|
||||
uint16_t code = KeyCode(value);
|
||||
return code == keynames::kUnknown ? "?" : keynames::Token(code);
|
||||
}
|
||||
case Type::Int:
|
||||
case Type::Float:
|
||||
break;
|
||||
|
||||
+39
-5
@@ -14,7 +14,8 @@
|
||||
// that runs before main() calls Load(), so it would miss the file (it
|
||||
// gets the environment and defaults, with a warning in the log).
|
||||
// - An environment variable (NFSMW_AA=0, ...) wins over the file for that
|
||||
// run and is never written back; EnvOverride() tells the menu.
|
||||
// run and is never written back; EnvOverride() tells the menu. Key
|
||||
// options have none.
|
||||
// - A Restart option keeps its launch value for the whole run. Its
|
||||
// consumers read it once (the window's size), so a change mid-run would
|
||||
// reach only part of them. The menu edits the stored value and shows
|
||||
@@ -62,6 +63,34 @@ namespace settings
|
||||
StickDeadzone,
|
||||
TriggerDeadzone,
|
||||
Vibration,
|
||||
// Controls (the [keys] table): the two keyboard keys that drive
|
||||
// each Xbox 360 control, as key codes (user/key_names.h); the
|
||||
// second of a pair is always the first's id + 1 (hid/key_map.h
|
||||
// pairs them). Page order.
|
||||
KeyRightTrigger, KeyRightTrigger2,
|
||||
KeyLeftTrigger, KeyLeftTrigger2,
|
||||
KeyLeftStickLeft, KeyLeftStickLeft2,
|
||||
KeyLeftStickRight, KeyLeftStickRight2,
|
||||
KeyLeftStickUp, KeyLeftStickUp2,
|
||||
KeyLeftStickDown, KeyLeftStickDown2,
|
||||
KeyA, KeyA2,
|
||||
KeyB, KeyB2,
|
||||
KeyX, KeyX2,
|
||||
KeyY, KeyY2,
|
||||
KeyLB, KeyLB2,
|
||||
KeyRB, KeyRB2,
|
||||
KeyDpadUp, KeyDpadUp2,
|
||||
KeyDpadDown, KeyDpadDown2,
|
||||
KeyDpadLeft, KeyDpadLeft2,
|
||||
KeyDpadRight, KeyDpadRight2,
|
||||
KeyStart, KeyStart2,
|
||||
KeyBack, KeyBack2,
|
||||
KeyRightStickUp, KeyRightStickUp2,
|
||||
KeyRightStickDown, KeyRightStickDown2,
|
||||
KeyRightStickLeft, KeyRightStickLeft2,
|
||||
KeyRightStickRight, KeyRightStickRight2,
|
||||
KeyLeftStickClick, KeyLeftStickClick2,
|
||||
KeyRightStickClick, KeyRightStickClick2,
|
||||
// Advanced
|
||||
AsyncShaders,
|
||||
PerformanceOverlay,
|
||||
@@ -69,12 +98,17 @@ namespace settings
|
||||
};
|
||||
constexpr size_t kCount = size_t(Id::Count);
|
||||
|
||||
enum class Type : uint8_t { Bool, Int, Float, Enum };
|
||||
enum class Category : uint8_t { Display, Graphics, Audio, Input, Advanced, Count };
|
||||
// Key: a key code of user/key_names.h (0: no key), saved by its name
|
||||
// there ("w", "left_shift", "none"); the menu takes it from a key press.
|
||||
enum class Type : uint8_t { Bool, Int, Float, Enum, Key };
|
||||
enum class Category : uint8_t { Display, Graphics, Audio, Input, Controls, Advanced, Count };
|
||||
enum class Apply : uint8_t { Live, Restart };
|
||||
|
||||
// Values of the enum options (GetInt returns them).
|
||||
enum class ScalingMode : int32_t { Bicubic = 0, Linear = 1 };
|
||||
// Upscaling: FSR 1 is used only where the picture is enlarged; frames
|
||||
// shown at their size or smaller take the bicubic pass
|
||||
// (video/upscale.h, whose Mode has the same values).
|
||||
enum class ScalingMode : int32_t { Bicubic = 0, Linear = 1, Fsr1 = 2 };
|
||||
// Frame Rate: 60, 30, or Auto (video/frame_rate.h: 60, or 30 while the
|
||||
// device is hot or can't hold 60).
|
||||
constexpr int32_t kFrameRateAuto = 0;
|
||||
@@ -111,7 +145,7 @@ namespace settings
|
||||
const char* key; // key in that table, e.g. "anti_aliasing"
|
||||
const char* label; // menu label
|
||||
const char* description; // one-line menu help
|
||||
const char* env; // the environment variable that overrides it
|
||||
const char* env; // the environment variable that overrides it; nullptr: none (Key options)
|
||||
int envField = 0; // 0: the whole variable; n: its n-th 'x'-separated field (NFSMW_WINDOW=WxH)
|
||||
double defaultValue; // Bool: 0 or 1; Enum: an entry's value
|
||||
// Int and Float: the range and the menu's step. Enum: the range a
|
||||
|
||||
@@ -56,4 +56,29 @@ namespace video::band
|
||||
{
|
||||
return std::clamp(i, s.a, s.b - 1);
|
||||
}
|
||||
|
||||
// FSR 1 (video/fsr1.cpp's GLSL). Its passes work on output pixels: the
|
||||
// picture's pixel i of outSize along an axis has its centre at this
|
||||
// coordinate of the srcSize-texel frame image, and its span is the one
|
||||
// holding it (SpanOf). EASU keeps each of its taps (texels floor(t -
|
||||
// 0.5) - 1 to + 2) in that span with ClampTexel.
|
||||
inline float SourceOf(int32_t i, int32_t outSize, int32_t srcSize)
|
||||
{
|
||||
return (float(i) + 0.5f) * float(srcSize) / float(outSize);
|
||||
}
|
||||
|
||||
// Which span holds t: 0 [0, lo), 1 [lo, hi), 2 [hi, size).
|
||||
inline int SpanIndex(float t, int32_t lo, int32_t hi)
|
||||
{
|
||||
return t < float(lo) ? 0 : t < float(hi) ? 1 : 2;
|
||||
}
|
||||
|
||||
// RCAS's neighbour j (i +- 1) of output pixel i: j, or i itself where
|
||||
// j's centre lies in another span (its span's edge pixel, as ClampTexel
|
||||
// keeps a tap), so the sharpening never reads across the band's edge.
|
||||
inline int32_t RcasNeighbour(int32_t i, int32_t j, int32_t lo, int32_t hi, int32_t outSize, int32_t srcSize)
|
||||
{
|
||||
j = std::clamp(j, 0, outSize - 1);
|
||||
return SpanIndex(SourceOf(j, outSize, srcSize), lo, hi) == SpanIndex(SourceOf(i, outSize, srcSize), lo, hi) ? j : i;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See fsr1.h.
|
||||
#include "fsr1.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
// AMD's headers (thirdparty/fsr1, MIT), as text for the shaders: k_ffx_a and
|
||||
// k_ffx_fsr1 (runtime/CMakeLists.txt).
|
||||
#include <fsr1_headers.inc>
|
||||
|
||||
// The same headers' CPU code, for the constants (it defines short type
|
||||
// names and many helpers this file doesn't use).
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wunused-function"
|
||||
#pragma GCC diagnostic ignored "-Wunused-parameter"
|
||||
#define A_CPU 1
|
||||
#include <thirdparty/fsr1/ffx_a.h>
|
||||
#include <thirdparty/fsr1/ffx_fsr1.h>
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
namespace video::fsr1
|
||||
{
|
||||
namespace
|
||||
{
|
||||
std::string Text(const unsigned char* bytes)
|
||||
{
|
||||
return std::string(reinterpret_cast<const char*>(bytes));
|
||||
}
|
||||
|
||||
// Both shaders: AMD's 32-bit GLSL path (no extensions needed).
|
||||
constexpr const char* kPrelude = R"(#version 460
|
||||
#define A_GPU 1
|
||||
#define A_GLSL 1
|
||||
)";
|
||||
|
||||
// EASU, one 8x8 group per 64 pixels of the picture. Its 12 taps come
|
||||
// from 4 gathers (FsrEasuRF..BF at the corners between texels);
|
||||
// under Hor+ and Vert+ (bandOn) each of a gather's 2x2 texels is
|
||||
// fetched instead and kept in this pixel's span of the band's three
|
||||
// along each axis (band_filter.h's SpanOf and ClampTexel), so a strip
|
||||
// pixel reads only the strip and a band pixel only the band.
|
||||
// band: the band's columns x..y and rows z..w in texels (video::BandOf).
|
||||
constexpr const char* kEasuHead = R"(
|
||||
layout(local_size_x = 8, local_size_y = 8) in;
|
||||
layout(set = 0, binding = 0) uniform sampler2D src;
|
||||
layout(set = 0, binding = 1, rgba8) uniform writeonly image2D dst;
|
||||
layout(push_constant) uniform P { uvec4 con0, con1, con2, con3; ivec4 band; ivec2 outSize; ivec2 srcSize; uint bandOn; } p;
|
||||
|
||||
// This pixel's span: its first and last texels along each axis.
|
||||
ivec2 spanLo = ivec2(0), spanHi = ivec2(0);
|
||||
|
||||
// band_filter.h's SpanOf: [0, lo), [lo, hi) or [hi, size) holding t, as its first and end texels.
|
||||
ivec2 spanOf(float t, int lo, int hi, int size)
|
||||
{
|
||||
return t < float(lo) ? ivec2(0, lo) : t < float(hi) ? ivec2(lo, hi) : ivec2(hi, size);
|
||||
}
|
||||
|
||||
vec3 texelIn(ivec2 t)
|
||||
{
|
||||
return texelFetch(src, clamp(t, spanLo, spanHi), 0).rgb;
|
||||
}
|
||||
|
||||
// What a gather at q reads, each texel kept in the span, in a gather's
|
||||
// order: (x0, y1), (x1, y1), (x1, y0), (x0, y0).
|
||||
vec4 quadIn(vec2 q, int c)
|
||||
{
|
||||
ivec2 t = ivec2(floor(q * vec2(p.srcSize) - 0.5));
|
||||
return vec4(texelIn(t + ivec2(0, 1))[c], texelIn(t + ivec2(1, 1))[c], texelIn(t + ivec2(1, 0))[c], texelIn(t)[c]);
|
||||
}
|
||||
|
||||
AF4 FsrEasuRF(AF2 q) { return p.bandOn == 0u ? textureGather(src, q, 0) : quadIn(q, 0); }
|
||||
AF4 FsrEasuGF(AF2 q) { return p.bandOn == 0u ? textureGather(src, q, 1) : quadIn(q, 1); }
|
||||
AF4 FsrEasuBF(AF2 q) { return p.bandOn == 0u ? textureGather(src, q, 2) : quadIn(q, 2); }
|
||||
#define FSR_EASU_F 1
|
||||
)";
|
||||
|
||||
constexpr const char* kEasuMain = R"(
|
||||
void main()
|
||||
{
|
||||
uvec2 ip = gl_GlobalInvocationID.xy;
|
||||
if (ip.x >= uint(p.outSize.x) || ip.y >= uint(p.outSize.y))
|
||||
return;
|
||||
if (p.bandOn != 0u)
|
||||
{
|
||||
// Where this pixel's centre falls in the frame image (texels).
|
||||
vec2 pos = (vec2(ip) + 0.5) * vec2(p.srcSize) / vec2(p.outSize);
|
||||
ivec2 sx = spanOf(pos.x, p.band.x, p.band.y, p.srcSize.x), sy = spanOf(pos.y, p.band.z, p.band.w, p.srcSize.y);
|
||||
spanLo = ivec2(sx.x, sy.x);
|
||||
spanHi = ivec2(sx.y, sy.y) - 1;
|
||||
}
|
||||
AF3 c;
|
||||
FsrEasuF(c, ip, p.con0, p.con1, p.con2, p.con3);
|
||||
imageStore(dst, ivec2(ip), vec4(c, 1.0));
|
||||
}
|
||||
)";
|
||||
|
||||
// RCAS, drawn over the picture's place in the swapchain image (the
|
||||
// viewport at origin, outSize): src is EASU's image. Under Hor+ and
|
||||
// Vert+ a neighbour on the other side of the band's edge (its centre
|
||||
// maps into another span than this pixel's, by EASU's rule) reads
|
||||
// this pixel instead: the span's edge, as ClampTexel keeps a tap.
|
||||
// con.x 0 (Sharpening 0): EASU's picture as it is (RCAS's own
|
||||
// arithmetic would still scale it by its approximate reciprocal).
|
||||
constexpr const char* kRcasHead = R"(
|
||||
layout(location = 0) in vec2 uv;
|
||||
layout(location = 0) out vec4 color;
|
||||
layout(set = 0, binding = 0) uniform sampler2D src;
|
||||
layout(push_constant) uniform P { uvec4 con; ivec4 band; ivec2 origin; ivec2 outSize; ivec2 srcSize; uint bandOn; } p;
|
||||
|
||||
ivec2 centre = ivec2(0), centreSpan = ivec2(0);
|
||||
|
||||
// Which of the band's three spans along an axis holds output pixel i's centre: 0, 1 or 2.
|
||||
int spanIndex(int i, int lo, int hi, int outSize, int srcSize)
|
||||
{
|
||||
float t = (float(i) + 0.5) * float(srcSize) / float(outSize);
|
||||
return t < float(lo) ? 0 : t < float(hi) ? 1 : 2;
|
||||
}
|
||||
|
||||
AF4 FsrRcasLoadF(ASU2 q)
|
||||
{
|
||||
ivec2 t = clamp(q, ivec2(0), p.outSize - 1);
|
||||
if (p.bandOn != 0u)
|
||||
{
|
||||
if (spanIndex(t.x, p.band.x, p.band.y, p.outSize.x, p.srcSize.x) != centreSpan.x)
|
||||
t.x = centre.x;
|
||||
if (spanIndex(t.y, p.band.z, p.band.w, p.outSize.y, p.srcSize.y) != centreSpan.y)
|
||||
t.y = centre.y;
|
||||
}
|
||||
return texelFetch(src, t, 0);
|
||||
}
|
||||
void FsrRcasInputF(inout AF1 r, inout AF1 g, inout AF1 b) {}
|
||||
#define FSR_RCAS_F 1
|
||||
)";
|
||||
|
||||
constexpr const char* kRcasMain = R"(
|
||||
void main()
|
||||
{
|
||||
centre = clamp(ivec2(gl_FragCoord.xy) - p.origin, ivec2(0), p.outSize - 1);
|
||||
if (p.bandOn != 0u)
|
||||
centreSpan = ivec2(spanIndex(centre.x, p.band.x, p.band.y, p.outSize.x, p.srcSize.x),
|
||||
spanIndex(centre.y, p.band.z, p.band.w, p.outSize.y, p.srcSize.y));
|
||||
if (p.con.x == 0u)
|
||||
{
|
||||
color = vec4(texelFetch(src, centre, 0).rgb, 1.0);
|
||||
return;
|
||||
}
|
||||
AF1 r, g, b;
|
||||
FsrRcasF(r, g, b, uvec2(centre), p.con);
|
||||
color = vec4(r, g, b, 1.0);
|
||||
}
|
||||
)";
|
||||
}
|
||||
|
||||
std::array<uint32_t, 16> EasuConstants(uint32_t srcW, uint32_t srcH, uint32_t dstW, uint32_t dstH)
|
||||
{
|
||||
std::array<uint32_t, 16> con{};
|
||||
FsrEasuCon(&con[0], &con[4], &con[8], &con[12], AF1(srcW), AF1(srcH), AF1(srcW), AF1(srcH), AF1(dstW), AF1(dstH));
|
||||
return con;
|
||||
}
|
||||
|
||||
std::array<uint32_t, 4> RcasConstants(float stops)
|
||||
{
|
||||
std::array<uint32_t, 4> con{};
|
||||
if (!std::isfinite(stops))
|
||||
return con; // off: con.x 0 (the shader then copies EASU's picture)
|
||||
FsrRcasCon(con.data(), AF1(stops));
|
||||
return con;
|
||||
}
|
||||
|
||||
std::string EasuGlsl()
|
||||
{
|
||||
return kPrelude + Text(k_ffx_a) + kEasuHead + Text(k_ffx_fsr1) + kEasuMain;
|
||||
}
|
||||
|
||||
std::string RcasGlsl()
|
||||
{
|
||||
return kPrelude + Text(k_ffx_a) + kRcasHead + Text(k_ffx_fsr1) + kRcasMain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Graphics > Upscaling's FSR 1: AMD FidelityFX Super Resolution 1
|
||||
// (runtime/thirdparty/fsr1, MIT, unmodified), as two passes after the
|
||||
// anti-aliasing (video/presenter.cpp):
|
||||
// - EASU (compute), the frame image enlarged into an image the size of
|
||||
// the picture on the screen: AMD's FsrEasuF (32-bit), whose 12 taps
|
||||
// come from 4 gathers, or under Hor+ and Vert+ from fetches kept in the
|
||||
// pixel's span of the 16:9 band's three (video/band_filter.h);
|
||||
// - RCAS (fragment, into the swapchain image, where bicubic's pass draws),
|
||||
// AMD's FsrRcasF: sharpening of that image, its 4 neighbours each kept
|
||||
// in the pixel's span too; then Brightness and the 360's display gamma
|
||||
// (the tone variant, as for the other scale shaders), then the UI.
|
||||
// Their constants come from AMD's own CPU code (FsrEasuCon, FsrRcasCon).
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace video::fsr1
|
||||
{
|
||||
// EASU's push constants: FsrEasuCon's con0..con3 for a srcW x srcH
|
||||
// image enlarged to dstW x dstH.
|
||||
std::array<uint32_t, 16> EasuConstants(uint32_t srcW, uint32_t srcH, uint32_t dstW, uint32_t dstH);
|
||||
// RCAS's: FsrRcasCon's con for a strength in stops below its maximum
|
||||
// (video::upscale::RcasStops; infinity gives 0: no sharpening).
|
||||
std::array<uint32_t, 4> RcasConstants(float stops);
|
||||
|
||||
// The shaders' GLSL: AMD's two headers between ours (the RCAS one has a
|
||||
// single main(), so the tone variant can wrap it).
|
||||
std::string EasuGlsl();
|
||||
std::string RcasGlsl();
|
||||
}
|
||||
+595
-65
@@ -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"
|
||||
@@ -19,13 +20,19 @@
|
||||
#include <cpu/guest_time.h>
|
||||
#include "vblank_lock.h"
|
||||
#include "display_tone.h"
|
||||
#include "fsr1.h"
|
||||
#include "upscale.h"
|
||||
#include <ui/ui.h>
|
||||
#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>
|
||||
@@ -102,12 +109,20 @@ namespace video
|
||||
VkDeviceSize stagingSize = 0;
|
||||
VkImageView sourceView = VK_NULL_HANDLE; // this frame's view of the presented image
|
||||
bool timed = false; // wrote its post-processing timestamps
|
||||
upscale::Pass pass = upscale::Pass::Bicubic; // the pass it drew with (NFSMW_UPSCALE_AB)
|
||||
} s_frames[FRAMES_IN_FLIGHT];
|
||||
// NFSMW_GPU_TIMING=1: 3 timestamps per frame slot around the
|
||||
// post-processing (before, after AA, after the scale and UI pass).
|
||||
VkQueryPool s_timestamps = VK_NULL_HANDLE;
|
||||
double s_timestampPeriod = 1.0;
|
||||
std::atomic<uint64_t> s_postFrames{ 0 }, s_postAaNs{ 0 }, s_postNs{ 0 };
|
||||
// NFSMW_UPSCALE_AB=1 (a diagnostic, with NFSMW_GPU_TIMING=1): frames
|
||||
// the FSR 1 setting enlarges alternate between FSR 1 and bicubic, one
|
||||
// frame each, and an [upscale-ab] line every 600 timed frames gives
|
||||
// each pass's post-processing GPU time per frame (anti-aliasing and
|
||||
// the UI included, as in [perf]): the same scenes, the same clocks.
|
||||
const bool s_upscaleAb = [] { const char* v = std::getenv("NFSMW_UPSCALE_AB"); return v && v[0] == '1'; }();
|
||||
uint64_t s_abFrames[3]{}, s_abNs[3]{}, s_abAaNs[3]{}; // by upscale::Pass; main thread
|
||||
std::vector<VkSemaphore> s_renderDone; // one per swapchain image
|
||||
std::mutex s_queueMutex;
|
||||
VulkanContext s_context{};
|
||||
@@ -263,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
|
||||
};
|
||||
@@ -281,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
|
||||
@@ -307,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
|
||||
{
|
||||
@@ -345,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;
|
||||
{
|
||||
@@ -459,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);
|
||||
@@ -534,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);
|
||||
@@ -661,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 });
|
||||
@@ -1362,8 +1382,10 @@ void main()
|
||||
if (!EnsureScalePipeline())
|
||||
return false;
|
||||
s_post.ready = true;
|
||||
const int32_t mode = settings::GetInt(settings::Id::Scaling);
|
||||
fprintf(stderr, "[video] post-processing: %s, %s scaling%s%s\n", AaEnabled() ? "anti-aliasing" : "no anti-aliasing",
|
||||
Bicubic() ? "bicubic" : "linear", Bicubic() ? std::format(", sharpen {:.2f}", Sharpen()).c_str() : "",
|
||||
mode == int32_t(settings::ScalingMode::Fsr1) ? "FSR 1 (enlarging; else bicubic)" : Bicubic() ? "bicubic" : "linear",
|
||||
mode != int32_t(settings::ScalingMode::Linear) ? std::format(", sharpen {:.2f}", Sharpen()).c_str() : "",
|
||||
s_fastScale ? " (NFSMW_FAST_SCALE: 7 fetches)" : "");
|
||||
return true;
|
||||
}
|
||||
@@ -1546,6 +1568,487 @@ void main()
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Settings > Graphics > Upscaling's FSR 1 (video/fsr1.h; when it is
|
||||
// used: video/upscale.h): EASU (compute) from the anti-aliased frame
|
||||
// into s_fsr.image, the picture's size on the screen, then RCAS
|
||||
// (fragment) where the bicubic pass draws, with the tone table and
|
||||
// then the UI after it as before. Made the first time a frame is
|
||||
// enlarged with it; if that fails, those frames take the bicubic
|
||||
// pass (logged once).
|
||||
struct FsrState
|
||||
{
|
||||
bool tried = false, ready = false, toneTried = false;
|
||||
VkDescriptorSetLayout easuSet = VK_NULL_HANDLE, rcasSet = VK_NULL_HANDLE;
|
||||
VkPipelineLayout easuLayout = VK_NULL_HANDLE, rcasLayout = VK_NULL_HANDLE;
|
||||
VkPipeline easu = VK_NULL_HANDLE;
|
||||
VkShaderModule rcasFs = VK_NULL_HANDLE, rcasToneFs = VK_NULL_HANDLE;
|
||||
VkPipeline rcas = VK_NULL_HANDLE, rcasTone = VK_NULL_HANDLE;
|
||||
VkFormat rcasFormat = VK_FORMAT_UNDEFINED, rcasToneFormat = VK_FORMAT_UNDEFINED; // the swapchain's they were made for
|
||||
// EASU's picture, the size of the picture on the screen.
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
VkDeviceMemory memory = VK_NULL_HANDLE;
|
||||
VkImageView view = VK_NULL_HANDLE;
|
||||
uint32_t width = 0, height = 0;
|
||||
} s_fsr;
|
||||
|
||||
// The shaders' push constants (their P in video/fsr1.cpp).
|
||||
struct EasuPush
|
||||
{
|
||||
uint32_t con[16]; // FsrEasuCon's con0..con3
|
||||
int32_t band[4]; // the 16:9 band's columns x0..x1 and rows y0..y1 (video::BandOf)
|
||||
int32_t outW, outH, srcW, srcH;
|
||||
uint32_t bandOn;
|
||||
};
|
||||
static_assert(sizeof(EasuPush) == 100);
|
||||
struct RcasPush
|
||||
{
|
||||
uint32_t con[4]; // FsrRcasCon's (con[0] 0: no sharpening)
|
||||
int32_t band[4];
|
||||
int32_t originX, originY, outW, outH, srcW, srcH;
|
||||
uint32_t bandOn;
|
||||
};
|
||||
static_assert(sizeof(RcasPush) == 60);
|
||||
|
||||
bool EnsureFsr()
|
||||
{
|
||||
if (s_fsr.tried)
|
||||
return s_fsr.ready;
|
||||
s_fsr.tried = true;
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
VkDescriptorSetLayoutBinding easuBindings[2] = {
|
||||
{ 0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr },
|
||||
{ 1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr },
|
||||
};
|
||||
// RCAS: EASU's picture, and the tone table (its tone variant).
|
||||
VkDescriptorSetLayoutBinding rcasBindings[2] = {
|
||||
{ 0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr },
|
||||
{ 1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr },
|
||||
};
|
||||
VkDescriptorSetLayoutCreateInfo dslci{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO };
|
||||
dslci.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_PUSH_DESCRIPTOR_BIT_KHR;
|
||||
dslci.bindingCount = 2;
|
||||
dslci.pBindings = easuBindings;
|
||||
VkDescriptorSetLayoutCreateInfo rcasDslci = dslci;
|
||||
rcasDslci.pBindings = rcasBindings;
|
||||
VkPushConstantRange easuRange{ VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(EasuPush) };
|
||||
VkPushConstantRange rcasRange{ VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(RcasPush) };
|
||||
VkPipelineLayoutCreateInfo plci{ VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO };
|
||||
plci.setLayoutCount = 1;
|
||||
plci.pSetLayouts = &s_fsr.easuSet;
|
||||
plci.pushConstantRangeCount = 1;
|
||||
plci.pPushConstantRanges = &easuRange;
|
||||
VkPipelineLayoutCreateInfo rcasPlci = plci;
|
||||
rcasPlci.pSetLayouts = &s_fsr.rcasSet;
|
||||
rcasPlci.pPushConstantRanges = &rcasRange;
|
||||
if (!Check(vkCreateDescriptorSetLayout(s_device, &dslci, nullptr, &s_fsr.easuSet), "FSR 1 EASU set layout") ||
|
||||
!Check(vkCreateDescriptorSetLayout(s_device, &rcasDslci, nullptr, &s_fsr.rcasSet), "FSR 1 RCAS set layout") ||
|
||||
!Check(vkCreatePipelineLayout(s_device, &plci, nullptr, &s_fsr.easuLayout), "FSR 1 EASU pipeline layout") ||
|
||||
!Check(vkCreatePipelineLayout(s_device, &rcasPlci, nullptr, &s_fsr.rcasLayout), "FSR 1 RCAS pipeline layout"))
|
||||
{
|
||||
fprintf(stderr, "[video] FSR 1 is off: its pipeline layouts couldn't be made; bicubic upscaling instead\n");
|
||||
return false;
|
||||
}
|
||||
VkShaderModule cs = PostModule(fsr1::EasuGlsl().c_str(), 2);
|
||||
s_fsr.rcasFs = PostModule(fsr1::RcasGlsl().c_str(), 1);
|
||||
if (!cs || !s_fsr.rcasFs)
|
||||
{
|
||||
fprintf(stderr, "[video] FSR 1 is off: its shaders didn't compile; bicubic upscaling instead\n");
|
||||
return false;
|
||||
}
|
||||
VkComputePipelineCreateInfo cpci{ VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO };
|
||||
cpci.stage = { VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_COMPUTE_BIT, cs, "main", nullptr };
|
||||
cpci.layout = s_fsr.easuLayout;
|
||||
const bool made = Check(vkCreateComputePipelines(s_device, VK_NULL_HANDLE, 1, &cpci, nullptr, &s_fsr.easu), "FSR 1 EASU pipeline");
|
||||
vkDestroyShaderModule(s_device, cs, nullptr);
|
||||
if (!made)
|
||||
{
|
||||
fprintf(stderr, "[video] FSR 1 is off: its EASU pipeline couldn't be made; bicubic upscaling instead\n");
|
||||
return false;
|
||||
}
|
||||
s_fsr.ready = true;
|
||||
fprintf(stderr, "[video] FSR 1: EASU and RCAS compiled in %.0f ms\n",
|
||||
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count());
|
||||
return true;
|
||||
}
|
||||
|
||||
// RCAS's pipeline for the swapchain's format, plain or with the tone
|
||||
// table (Brightness, the 360's display gamma); VK_NULL_HANDLE if it
|
||||
// can't be made (the frame then takes the bicubic pass).
|
||||
VkPipeline RcasPipeline(bool tone)
|
||||
{
|
||||
if (tone && !s_fsr.toneTried)
|
||||
{
|
||||
s_fsr.toneTried = true;
|
||||
const std::string glsl = ToneVariant(fsr1::RcasGlsl().c_str());
|
||||
s_fsr.rcasToneFs = glsl.empty() ? VK_NULL_HANDLE : PostModule(glsl.c_str(), 1);
|
||||
if (!s_fsr.rcasToneFs)
|
||||
fprintf(stderr, "[video] FSR 1: the RCAS shader with Brightness couldn't be made; bicubic upscaling while the tone is in use\n");
|
||||
}
|
||||
VkShaderModule fs = tone ? s_fsr.rcasToneFs : s_fsr.rcasFs;
|
||||
VkPipeline& pipeline = tone ? s_fsr.rcasTone : s_fsr.rcas;
|
||||
VkFormat& format = tone ? s_fsr.rcasToneFormat : s_fsr.rcasFormat;
|
||||
if (!fs)
|
||||
return VK_NULL_HANDLE;
|
||||
if (pipeline && format == s_swapFormat)
|
||||
return pipeline;
|
||||
if (pipeline)
|
||||
vkDestroyPipeline(s_device, pipeline, nullptr);
|
||||
pipeline = MakeScalePipeline(fs, s_fsr.rcasLayout);
|
||||
format = pipeline ? s_swapFormat : VK_FORMAT_UNDEFINED;
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
void EnsureFsrImage(uint32_t width, uint32_t height)
|
||||
{
|
||||
if (s_fsr.image && s_fsr.width == width && s_fsr.height == height)
|
||||
return;
|
||||
WaitIdle();
|
||||
if (s_fsr.image)
|
||||
{
|
||||
vkDestroyImageView(s_device, s_fsr.view, nullptr);
|
||||
vkDestroyImage(s_device, s_fsr.image, nullptr);
|
||||
vkFreeMemory(s_device, s_fsr.memory, nullptr);
|
||||
}
|
||||
VkImageCreateInfo ii{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
|
||||
ii.imageType = VK_IMAGE_TYPE_2D;
|
||||
ii.format = VK_FORMAT_R8G8B8A8_UNORM;
|
||||
ii.extent = { width, height, 1 };
|
||||
ii.mipLevels = 1;
|
||||
ii.arrayLayers = 1;
|
||||
ii.samples = VK_SAMPLE_COUNT_1_BIT;
|
||||
ii.tiling = VK_IMAGE_TILING_OPTIMAL;
|
||||
ii.usage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
|
||||
vkCreateImage(s_device, &ii, nullptr, &s_fsr.image);
|
||||
VkMemoryRequirements req;
|
||||
vkGetImageMemoryRequirements(s_device, s_fsr.image, &req);
|
||||
VkMemoryAllocateInfo ai{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
|
||||
ai.allocationSize = req.size;
|
||||
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
vkAllocateMemory(s_device, &ai, nullptr, &s_fsr.memory);
|
||||
vkBindImageMemory(s_device, s_fsr.image, s_fsr.memory, 0);
|
||||
VkImageViewCreateInfo vci{ VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO };
|
||||
vci.image = s_fsr.image;
|
||||
vci.viewType = VK_IMAGE_VIEW_TYPE_2D;
|
||||
vci.format = VK_FORMAT_R8G8B8A8_UNORM;
|
||||
vci.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
|
||||
vkCreateImageView(s_device, &vci, nullptr, &s_fsr.view);
|
||||
s_fsr.width = width;
|
||||
s_fsr.height = height;
|
||||
}
|
||||
|
||||
// EASU: the w x h frame (scaleInput) enlarged to dw x dh in
|
||||
// s_fsr.image, ready for RCAS's reads. Outside a rendering pass.
|
||||
void RecordEasu(VkCommandBuffer cmd, VkImageView scaleInput, uint32_t w, uint32_t h, int32_t dw, int32_t dh,
|
||||
const video::FrameBand& band, bool bandOn)
|
||||
{
|
||||
EnsureFsrImage(uint32_t(dw), uint32_t(dh));
|
||||
// After the last frame's RCAS read it.
|
||||
Barrier(cmd, s_fsr.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL, VK_ACCESS_SHADER_READ_BIT,
|
||||
VK_ACCESS_SHADER_WRITE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
|
||||
VkDescriptorImageInfo in{ s_post.sampler, scaleInput, VK_IMAGE_LAYOUT_GENERAL };
|
||||
VkDescriptorImageInfo out{ VK_NULL_HANDLE, s_fsr.view, VK_IMAGE_LAYOUT_GENERAL };
|
||||
VkWriteDescriptorSet writes[2] = {
|
||||
{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 0, 0, 1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr },
|
||||
{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 1, 0, 1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, &out, nullptr, nullptr },
|
||||
};
|
||||
EasuPush pc{};
|
||||
const std::array<uint32_t, 16> con = fsr1::EasuConstants(w, h, uint32_t(dw), uint32_t(dh));
|
||||
std::copy(con.begin(), con.end(), pc.con);
|
||||
pc.band[0] = band.x0;
|
||||
pc.band[1] = band.x1;
|
||||
pc.band[2] = band.y0;
|
||||
pc.band[3] = band.y1;
|
||||
pc.outW = dw;
|
||||
pc.outH = dh;
|
||||
pc.srcW = int32_t(w);
|
||||
pc.srcH = int32_t(h);
|
||||
pc.bandOn = bandOn ? 1u : 0u;
|
||||
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s_fsr.easu);
|
||||
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s_fsr.easuLayout, 0, 2, writes);
|
||||
vkCmdPushConstants(cmd, s_fsr.easuLayout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(pc), &pc);
|
||||
vkCmdDispatch(cmd, (uint32_t(dw) + 7) / 8, (uint32_t(dh) + 7) / 8, 1);
|
||||
Barrier(cmd, s_fsr.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, VK_ACCESS_SHADER_WRITE_BIT,
|
||||
VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
|
||||
}
|
||||
|
||||
// Inside a rendering pass on the swapchain image (or a capture's
|
||||
// image of its format): the picture at dx, dy, dw x dh, by `pass`
|
||||
// (FSR 1: RCAS over RecordEasu's image, with `rcas` its pipeline),
|
||||
// through the tone table when `tone`.
|
||||
void DrawPicture(VkCommandBuffer cmd, upscale::Pass pass, VkPipeline rcas, bool tone, VkImageView scaleInput, uint32_t w,
|
||||
uint32_t h, const video::FrameBand& band, bool bandOn, int32_t dx, int32_t dy, int32_t dw, int32_t dh, float sharpen)
|
||||
{
|
||||
VkViewport viewport{ float(dx), float(dy), float(dw), float(dh), 0.0f, 1.0f };
|
||||
VkRect2D scissor{ { dx, dy }, { uint32_t(dw), uint32_t(dh) } };
|
||||
vkCmdSetViewport(cmd, 0, 1, &viewport);
|
||||
vkCmdSetScissor(cmd, 0, 1, &scissor);
|
||||
const bool fsr = pass == upscale::Pass::Fsr1;
|
||||
const VkPipelineLayout layout = fsr ? s_fsr.rcasLayout : tone ? s_tone.layout : s_post.scaleLayout;
|
||||
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fsr ? rcas : tone ? s_tone.pipeline : s_post.scale);
|
||||
VkDescriptorImageInfo in{ s_post.sampler, fsr ? s_fsr.view : scaleInput, VK_IMAGE_LAYOUT_GENERAL };
|
||||
VkWriteDescriptorSet write{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 0, 0, 1,
|
||||
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr };
|
||||
if (tone)
|
||||
{
|
||||
VkDescriptorImageInfo table{ s_post.sampler, s_tone.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL };
|
||||
VkWriteDescriptorSet writes[2] = { write, write };
|
||||
writes[1].dstBinding = 1;
|
||||
writes[1].pImageInfo = &table;
|
||||
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, layout, 0, 2, writes);
|
||||
}
|
||||
else
|
||||
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, layout, 0, 1, &write);
|
||||
if (fsr)
|
||||
{
|
||||
RcasPush pc{};
|
||||
const std::array<uint32_t, 4> con = fsr1::RcasConstants(upscale::RcasStops(sharpen));
|
||||
std::copy(con.begin(), con.end(), pc.con);
|
||||
pc.band[0] = band.x0;
|
||||
pc.band[1] = band.x1;
|
||||
pc.band[2] = band.y0;
|
||||
pc.band[3] = band.y1;
|
||||
pc.originX = dx;
|
||||
pc.originY = dy;
|
||||
pc.outW = dw;
|
||||
pc.outH = dh;
|
||||
pc.srcW = int32_t(w);
|
||||
pc.srcH = int32_t(h);
|
||||
pc.bandOn = bandOn ? 1u : 0u;
|
||||
vkCmdPushConstants(cmd, layout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
|
||||
}
|
||||
else
|
||||
{
|
||||
struct
|
||||
{
|
||||
float width, height, sharpen;
|
||||
uint32_t bicubic;
|
||||
float band[4];
|
||||
uint32_t bandOn;
|
||||
} pc = { float(w), float(h), sharpen, pass == upscale::Pass::Bicubic ? 1u : 0u,
|
||||
{ float(band.x0), float(band.x1), float(band.y0), float(band.y1) }, bandOn ? 1u : 0u };
|
||||
static_assert(sizeof(pc) == 36);
|
||||
vkCmdPushConstants(cmd, layout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
|
||||
}
|
||||
vkCmdDraw(cmd, 3, 1, 0, 0);
|
||||
}
|
||||
|
||||
// The pass a frame takes (video/upscale.h), FSR 1 only where its
|
||||
// pipelines are ready (made here the first time); a line in the log
|
||||
// when the pass or the sizes change.
|
||||
upscale::Pass ChoosePass(bool tone, uint32_t w, uint32_t h, int32_t dw, int32_t dh, VkPipeline& rcas)
|
||||
{
|
||||
rcas = VK_NULL_HANDLE;
|
||||
upscale::Pass pass = upscale::Choose(settings::GetInt(settings::Id::Scaling), w, h, dw, dh);
|
||||
if (pass == upscale::Pass::Fsr1 && EnsureFsr())
|
||||
rcas = RcasPipeline(tone);
|
||||
if (pass == upscale::Pass::Fsr1 && !rcas)
|
||||
pass = upscale::Pass::Bicubic;
|
||||
static upscale::Pass lastPass = upscale::Pass::Linear;
|
||||
static uint32_t lastW = 0, lastH = 0;
|
||||
static int32_t lastDw = 0, lastDh = 0;
|
||||
static int lines = 0;
|
||||
if ((pass != lastPass || w != lastW || h != lastH || dw != lastDw || dh != lastDh) && lines < 32)
|
||||
{
|
||||
lines++;
|
||||
const bool fsrChosen = settings::GetInt(settings::Id::Scaling) == int32_t(settings::ScalingMode::Fsr1);
|
||||
fprintf(stderr, "[video] upscaling: %s, %ux%u -> %dx%d%s\n", upscale::PassName(pass), w, h, dw, dh,
|
||||
pass == upscale::Pass::Fsr1 ? std::format(" (RCAS at {:.2f} of its strength)", upscale::RcasAmount(Sharpen())).c_str()
|
||||
: !fsrChosen ? ""
|
||||
: upscale::Enlarged(w, h, dw, dh) ? " (FSR 1 unavailable)" : " (FSR 1 only enlarges)");
|
||||
}
|
||||
lastPass = pass;
|
||||
lastW = w;
|
||||
lastH = h;
|
||||
lastDw = dw;
|
||||
lastDh = dh;
|
||||
return pass;
|
||||
}
|
||||
|
||||
// NFSMW_UPSCALE_COMPARE=<passes> (a diagnostic): with each
|
||||
// NFSMW_CHECK_PRESENT_SEC capture, the same frame drawn again by each
|
||||
// listed pass into an image of its own (the swapchain's size and
|
||||
// format, without the UI), saved as
|
||||
// build/present_check_<k>_<name>.ppm: side-by-sides of one frame.
|
||||
// Passes: bicubic, linear, fsr1, each at Sharpening or with
|
||||
// :<sharpening> (fsr1:0 is EASU alone). fsr1 where the frame isn't
|
||||
// enlarged draws bicubic, as the setting does. "source": the frame
|
||||
// the passes start from (after the anti-aliasing), at its own size
|
||||
// (through the tone table when that is in use).
|
||||
struct CompareEntry
|
||||
{
|
||||
std::string name;
|
||||
int32_t mode;
|
||||
float sharpen; // < 0: Sharpening's
|
||||
bool source = false;
|
||||
};
|
||||
const std::vector<CompareEntry>& CompareList()
|
||||
{
|
||||
static const std::vector<CompareEntry> list = [] {
|
||||
std::vector<CompareEntry> entries;
|
||||
const char* v = std::getenv("NFSMW_UPSCALE_COMPARE");
|
||||
std::string text = v ? v : "";
|
||||
for (size_t at = 0; at < text.size();)
|
||||
{
|
||||
size_t end = text.find(',', at);
|
||||
std::string item = text.substr(at, end == std::string::npos ? std::string::npos : end - at);
|
||||
at = end == std::string::npos ? text.size() : end + 1;
|
||||
std::string kind = item.substr(0, item.find(':'));
|
||||
float sharpen = item.find(':') == std::string::npos ? -1.0f : std::strtof(item.c_str() + item.find(':') + 1, nullptr);
|
||||
int32_t mode = kind == "fsr1" ? int32_t(upscale::Mode::Fsr1) : kind == "linear" || kind == "source" ? int32_t(upscale::Mode::Linear)
|
||||
: kind == "bicubic" ? int32_t(upscale::Mode::Bicubic) : -1;
|
||||
if (mode < 0)
|
||||
{
|
||||
fprintf(stderr, "[video] NFSMW_UPSCALE_COMPARE: \"%s\" isn't bicubic, linear, fsr1[:<sharpening>] or source\n", item.c_str());
|
||||
continue;
|
||||
}
|
||||
std::string name = item;
|
||||
std::replace(name.begin(), name.end(), ':', '-');
|
||||
entries.push_back({ name, mode, sharpen, kind == "source" });
|
||||
}
|
||||
return entries;
|
||||
}();
|
||||
return list;
|
||||
}
|
||||
|
||||
struct CompareShot
|
||||
{
|
||||
std::string name;
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
VkDeviceMemory memory = VK_NULL_HANDLE;
|
||||
VkImageView view = VK_NULL_HANDLE;
|
||||
VkBuffer buffer = VK_NULL_HANDLE;
|
||||
VkDeviceMemory bufferMemory = VK_NULL_HANDLE;
|
||||
uint32_t width = 0, height = 0;
|
||||
};
|
||||
|
||||
// Records each listed pass's picture of this frame and its copy to
|
||||
// the host. Outside a rendering pass.
|
||||
std::vector<CompareShot> RecordCompare(VkCommandBuffer cmd, bool tone, VkImageView scaleInput, uint32_t w, uint32_t h,
|
||||
const video::FrameBand& band, bool bandOn, int32_t dx, int32_t dy, int32_t dw, int32_t dh)
|
||||
{
|
||||
std::vector<CompareShot> shots;
|
||||
for (const CompareEntry& entry : CompareList())
|
||||
{
|
||||
CompareShot shot;
|
||||
shot.name = entry.name;
|
||||
// The source: the frame image 1:1 (a bilinear fetch at each texel's centre).
|
||||
shot.width = entry.source ? w : s_swapExtent.width;
|
||||
shot.height = entry.source ? h : s_swapExtent.height;
|
||||
const int32_t x = entry.source ? 0 : dx, y = entry.source ? 0 : dy;
|
||||
const int32_t width = entry.source ? int32_t(w) : dw, height = entry.source ? int32_t(h) : dh;
|
||||
VkImageCreateInfo ii{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
|
||||
ii.imageType = VK_IMAGE_TYPE_2D;
|
||||
ii.format = s_swapFormat;
|
||||
ii.extent = { shot.width, shot.height, 1 };
|
||||
ii.mipLevels = 1;
|
||||
ii.arrayLayers = 1;
|
||||
ii.samples = VK_SAMPLE_COUNT_1_BIT;
|
||||
ii.tiling = VK_IMAGE_TILING_OPTIMAL;
|
||||
ii.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
|
||||
if (vkCreateImage(s_device, &ii, nullptr, &shot.image) != VK_SUCCESS)
|
||||
break;
|
||||
VkMemoryRequirements req;
|
||||
vkGetImageMemoryRequirements(s_device, shot.image, &req);
|
||||
VkMemoryAllocateInfo ai{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
|
||||
ai.allocationSize = req.size;
|
||||
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
vkAllocateMemory(s_device, &ai, nullptr, &shot.memory);
|
||||
vkBindImageMemory(s_device, shot.image, shot.memory, 0);
|
||||
VkImageViewCreateInfo vci{ VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO };
|
||||
vci.image = shot.image;
|
||||
vci.viewType = VK_IMAGE_VIEW_TYPE_2D;
|
||||
vci.format = s_swapFormat;
|
||||
vci.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
|
||||
vkCreateImageView(s_device, &vci, nullptr, &shot.view);
|
||||
VkBufferCreateInfo bci{ VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO };
|
||||
bci.size = VkDeviceSize(shot.width) * shot.height * 4;
|
||||
bci.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
|
||||
vkCreateBuffer(s_device, &bci, nullptr, &shot.buffer);
|
||||
vkGetBufferMemoryRequirements(s_device, shot.buffer, &req);
|
||||
ai.allocationSize = req.size;
|
||||
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
|
||||
vkAllocateMemory(s_device, &ai, nullptr, &shot.bufferMemory);
|
||||
vkBindBufferMemory(s_device, shot.buffer, shot.bufferMemory, 0);
|
||||
|
||||
VkPipeline rcas = VK_NULL_HANDLE;
|
||||
upscale::Pass pass = upscale::Choose(entry.mode, w, h, width, height);
|
||||
if (pass == upscale::Pass::Fsr1 && EnsureFsr())
|
||||
rcas = RcasPipeline(tone);
|
||||
if (pass == upscale::Pass::Fsr1 && !rcas)
|
||||
pass = upscale::Pass::Bicubic;
|
||||
if (pass == upscale::Pass::Fsr1)
|
||||
RecordEasu(cmd, scaleInput, w, h, width, height, band, bandOn);
|
||||
Barrier(cmd, shot.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, 0, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
|
||||
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT);
|
||||
VkRenderingAttachmentInfoKHR color{ VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO_KHR };
|
||||
color.imageView = shot.view;
|
||||
color.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
||||
color.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
||||
color.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
||||
VkRenderingInfoKHR ri{ VK_STRUCTURE_TYPE_RENDERING_INFO_KHR };
|
||||
ri.renderArea = { { 0, 0 }, { shot.width, shot.height } };
|
||||
ri.layerCount = 1;
|
||||
ri.colorAttachmentCount = 1;
|
||||
ri.pColorAttachments = &color;
|
||||
s_context.cmdBeginRendering(cmd, &ri);
|
||||
DrawPicture(cmd, pass, rcas, tone, scaleInput, w, h, band, bandOn, x, y, width, height,
|
||||
entry.sharpen >= 0.0f ? std::min(entry.sharpen, 1.0f) : Sharpen());
|
||||
s_context.cmdEndRendering(cmd);
|
||||
Barrier(cmd, shot.image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
VK_PIPELINE_STAGE_TRANSFER_BIT);
|
||||
VkBufferImageCopy region{};
|
||||
region.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 };
|
||||
region.imageExtent = { shot.width, shot.height, 1 };
|
||||
vkCmdCopyImageToBuffer(cmd, shot.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, shot.buffer, 1, ®ion);
|
||||
VkBufferMemoryBarrier hostRead{ VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER };
|
||||
hostRead.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
hostRead.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
|
||||
hostRead.srcQueueFamilyIndex = hostRead.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
hostRead.buffer = shot.buffer;
|
||||
hostRead.size = VK_WHOLE_SIZE;
|
||||
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, 1, &hostRead, 0, nullptr);
|
||||
fprintf(stderr, "[video] NFSMW_UPSCALE_COMPARE: %s drawn by %s\n", entry.name.c_str(), upscale::PassName(pass));
|
||||
shots.push_back(shot);
|
||||
}
|
||||
return shots;
|
||||
}
|
||||
|
||||
// After the frame's fence: RecordCompare's pictures saved and freed.
|
||||
void SaveCompare(std::vector<CompareShot>& shots, int captureIndex)
|
||||
{
|
||||
const bool bgr = s_swapFormat == VK_FORMAT_B8G8R8A8_UNORM || s_swapFormat == VK_FORMAT_B8G8R8A8_SRGB;
|
||||
for (CompareShot& shot : shots)
|
||||
{
|
||||
void* mapped = nullptr;
|
||||
const size_t pixels = size_t(shot.width) * shot.height;
|
||||
if (vkMapMemory(s_device, shot.bufferMemory, 0, VkDeviceSize(pixels) * 4, 0, &mapped) == VK_SUCCESS)
|
||||
{
|
||||
std::string path = std::format("build/present_check_{}_{}.ppm", captureIndex, shot.name);
|
||||
if (FILE* out = fopen(path.c_str(), "wb"))
|
||||
{
|
||||
fprintf(out, "P6\n%u %u\n255\n", shot.width, shot.height);
|
||||
const uint8_t* px = static_cast<const uint8_t*>(mapped);
|
||||
std::vector<uint8_t> rgb(pixels * 3);
|
||||
for (size_t i = 0; i < pixels; i++)
|
||||
for (int k = 0; k < 3; k++)
|
||||
rgb[i * 3 + k] = px[i * 4 + (bgr ? 2 - k : k)];
|
||||
fwrite(rgb.data(), 1, rgb.size(), out);
|
||||
fclose(out);
|
||||
fprintf(stderr, "[video] NFSMW_UPSCALE_COMPARE: saved %s\n", path.c_str());
|
||||
}
|
||||
vkUnmapMemory(s_device, shot.bufferMemory);
|
||||
}
|
||||
vkDestroyImageView(s_device, shot.view, nullptr);
|
||||
vkDestroyImage(s_device, shot.image, nullptr);
|
||||
vkFreeMemory(s_device, shot.memory, nullptr);
|
||||
vkDestroyBuffer(s_device, shot.buffer, nullptr);
|
||||
vkFreeMemory(s_device, shot.bufferMemory, nullptr);
|
||||
}
|
||||
shots.clear();
|
||||
}
|
||||
|
||||
// Xenia's Tiled2D for 32x32-aligned pitches (texture_address.h).
|
||||
uint32_t Tiled2D(uint32_t x, uint32_t y, uint32_t pitchAligned, uint32_t bppLog2)
|
||||
{
|
||||
@@ -1759,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
|
||||
@@ -2598,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))
|
||||
{
|
||||
@@ -2639,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,
|
||||
@@ -2762,6 +3282,26 @@ void main()
|
||||
s_postFrames++;
|
||||
s_postAaNs += uint64_t(double(ts[1] - ts[0]) * s_timestampPeriod);
|
||||
s_postNs += uint64_t(double(ts[2] - ts[0]) * s_timestampPeriod);
|
||||
if (s_upscaleAb)
|
||||
{
|
||||
const int k = int(f.pass);
|
||||
s_abFrames[k]++;
|
||||
s_abNs[k] += uint64_t(double(ts[2] - ts[0]) * s_timestampPeriod);
|
||||
s_abAaNs[k] += uint64_t(double(ts[1] - ts[0]) * s_timestampPeriod);
|
||||
if ((s_abFrames[0] + s_abFrames[1] + s_abFrames[2]) % 600 == 0)
|
||||
{
|
||||
std::string text;
|
||||
for (upscale::Pass pass : { upscale::Pass::Bicubic, upscale::Pass::Fsr1, upscale::Pass::Linear })
|
||||
{
|
||||
const int i = int(pass);
|
||||
if (s_abFrames[i])
|
||||
text += std::format(" | {}: {:.3f} ms (AA {:.3f} ms) over {} frames", upscale::PassName(pass),
|
||||
double(s_abNs[i]) / 1e6 / double(s_abFrames[i]), double(s_abAaNs[i]) / 1e6 / double(s_abFrames[i]), s_abFrames[i]);
|
||||
s_abFrames[i] = s_abNs[i] = s_abAaNs[i] = 0;
|
||||
}
|
||||
fprintf(stderr, "[upscale-ab] post-processing GPU per frame%s\n", text.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
f.timed = false;
|
||||
}
|
||||
@@ -2838,6 +3378,7 @@ void main()
|
||||
VkDeviceMemory captureMemory = VK_NULL_HANDLE;
|
||||
VkDeviceSize captureSize = 0;
|
||||
int captureIndex = -1;
|
||||
std::vector<CompareShot> compareShots; // NFSMW_UPSCALE_COMPARE
|
||||
// Letterbox to the source aspect ratio; or, on a screen of another
|
||||
// shape with Aspect Ratio on Auto, fill it (video/picture_fit): the
|
||||
// game draws that frame with its view widened (a wider screen, Hor+)
|
||||
@@ -2963,6 +3504,16 @@ void main()
|
||||
// with the gamma off, and then the scale pass below is exactly as
|
||||
// before.
|
||||
const bool tone = scaleInput && PrepareTone(cmd);
|
||||
// The pass that draws the picture (video/upscale.h): FSR 1's
|
||||
// EASU first, outside the swapchain image's pass.
|
||||
VkPipeline rcas = VK_NULL_HANDLE;
|
||||
upscale::Pass pass = scaleInput ? ChoosePass(tone, w, h, dw, dh, rcas) : upscale::Pass::Bicubic;
|
||||
static uint64_t abCount = 0;
|
||||
if (s_upscaleAb && pass == upscale::Pass::Fsr1 && (abCount++ & 1))
|
||||
pass = upscale::Pass::Bicubic;
|
||||
f.pass = pass;
|
||||
if (scaleInput && pass == upscale::Pass::Fsr1)
|
||||
RecordEasu(cmd, scaleInput, w, h, dw, dh, band, bandOn);
|
||||
Barrier(cmd, dst, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, 0,
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT);
|
||||
VkRenderingAttachmentInfoKHR color{ VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO_KHR };
|
||||
@@ -2980,37 +3531,7 @@ void main()
|
||||
s_context.cmdBeginRendering(cmd, &ri);
|
||||
if (scaleInput)
|
||||
{
|
||||
VkViewport viewport{ float(dx), float(dy), float(dw), float(dh), 0.0f, 1.0f };
|
||||
VkRect2D scissor{ { dx, dy }, { uint32_t(dw), uint32_t(dh) } };
|
||||
vkCmdSetViewport(cmd, 0, 1, &viewport);
|
||||
vkCmdSetScissor(cmd, 0, 1, &scissor);
|
||||
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, tone ? s_tone.pipeline : s_post.scale);
|
||||
VkDescriptorImageInfo in{ s_post.sampler, scaleInput, VK_IMAGE_LAYOUT_GENERAL };
|
||||
VkWriteDescriptorSet write{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 0, 0, 1,
|
||||
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr };
|
||||
const VkPipelineLayout scaleLayout = tone ? s_tone.layout : s_post.scaleLayout;
|
||||
if (tone)
|
||||
{
|
||||
VkDescriptorImageInfo table{ s_post.sampler, s_tone.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL };
|
||||
VkWriteDescriptorSet writes[2] = { write, write };
|
||||
writes[1].dstBinding = 1;
|
||||
writes[1].pImageInfo = &table;
|
||||
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, scaleLayout, 0, 2, writes);
|
||||
}
|
||||
else
|
||||
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, s_post.scaleLayout, 0, 1, &write);
|
||||
bool bicubic = Bicubic();
|
||||
struct
|
||||
{
|
||||
float width, height, sharpen;
|
||||
uint32_t bicubic;
|
||||
float band[4];
|
||||
uint32_t bandOn;
|
||||
} pc = { float(w), float(h), Sharpen(), bicubic ? 1u : 0u, { float(band.x0), float(band.x1), float(band.y0), float(band.y1) },
|
||||
bandOn ? 1u : 0u };
|
||||
static_assert(sizeof(pc) == 36);
|
||||
vkCmdPushConstants(cmd, scaleLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
|
||||
vkCmdDraw(cmd, 3, 1, 0, 0);
|
||||
DrawPicture(cmd, pass, rcas, tone, scaleInput, w, h, band, bandOn, dx, dy, dw, dh, Sharpen());
|
||||
if (!captures.empty() && pictureRect.extent.width)
|
||||
{
|
||||
// The game's picture alone: pause the pass, copy it out,
|
||||
@@ -3099,6 +3620,8 @@ void main()
|
||||
else
|
||||
Barrier(cmd, dst, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, 0, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT);
|
||||
if (captureIndex >= 0 && scaleInput)
|
||||
compareShots = RecordCompare(cmd, tone, scaleInput, w, h, band, bandOn, dx, dy, dw, dh);
|
||||
if (post && !gpuImage)
|
||||
Barrier(cmd, s_source, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_SHADER_READ_BIT,
|
||||
VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
|
||||
@@ -3233,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();
|
||||
@@ -3275,6 +3798,11 @@ void main()
|
||||
vkDestroyBuffer(s_device, captureBuffer, nullptr);
|
||||
vkFreeMemory(s_device, captureMemory, nullptr);
|
||||
}
|
||||
if (!compareShots.empty())
|
||||
{
|
||||
vkWaitForFences(s_device, 1, &f.inFlight, VK_TRUE, UINT64_MAX);
|
||||
SaveCompare(compareShots, captureIndex);
|
||||
}
|
||||
// The next frame's CheckSwapchain builds again (before its acquire).
|
||||
if (present == VK_ERROR_OUT_OF_DATE_KHR || present == VK_SUBOPTIMAL_KHR)
|
||||
s_swapPolicy.Reported(present == VK_ERROR_OUT_OF_DATE_KHR ? SurfaceResult::OutOfDate : SurfaceResult::Suboptimal);
|
||||
@@ -3444,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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Which pass draws the game's picture into the window (video/presenter.cpp),
|
||||
// and Settings > Graphics > Sharpening as FSR 1's RCAS strength. Pure
|
||||
// functions, for tests/upscale_test.cpp.
|
||||
//
|
||||
// Settings > Graphics > Upscaling: Bicubic (Catmull-Rom, sharpened and
|
||||
// clamped to the 2x2 texels around each sample), Bilinear, or FSR 1 (AMD
|
||||
// FidelityFX Super Resolution 1: EASU, an edge-adaptive Lanczos-like
|
||||
// upscale, then RCAS, a contrast-adaptive sharpening pass; video/fsr1.h).
|
||||
// FSR 1 is made for enlarging (AMD: 1x to 4x the pixels): it is used only
|
||||
// when the picture on the screen is larger than the frame image, along one
|
||||
// axis or both, and smaller along neither (1x Internal Resolution on a
|
||||
// 1080p, 1440p or MacBook screen; the Steam Deck's 720 rows stretched to
|
||||
// 800). A frame shown at its own size, or shrunk (2x on a 1080p screen),
|
||||
// takes the bicubic pass exactly as before FSR 1 existed.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::upscale
|
||||
{
|
||||
// Settings > Graphics > Upscaling's values (settings::ScalingMode).
|
||||
enum class Mode : int32_t { Bicubic = 0, Linear = 1, Fsr1 = 2 };
|
||||
|
||||
enum class Pass : uint8_t { Linear, Bicubic, Fsr1 };
|
||||
|
||||
inline const char* PassName(Pass pass)
|
||||
{
|
||||
return pass == Pass::Fsr1 ? "FSR 1" : pass == Pass::Bicubic ? "bicubic" : "bilinear";
|
||||
}
|
||||
|
||||
// The picture enlarged: dstW x dstH (its place on the screen) is larger
|
||||
// than the srcW x srcH image along one axis and smaller along neither.
|
||||
inline bool Enlarged(uint32_t srcW, uint32_t srcH, int32_t dstW, int32_t dstH)
|
||||
{
|
||||
if (!srcW || !srcH || dstW <= 0 || dstH <= 0)
|
||||
return false;
|
||||
const bool notSmaller = uint32_t(dstW) >= srcW && uint32_t(dstH) >= srcH;
|
||||
return notSmaller && (uint32_t(dstW) > srcW || uint32_t(dstH) > srcH);
|
||||
}
|
||||
|
||||
// The pass for this frame. An unknown mode is bicubic, the default.
|
||||
inline Pass Choose(int32_t mode, uint32_t srcW, uint32_t srcH, int32_t dstW, int32_t dstH)
|
||||
{
|
||||
switch (Mode(mode))
|
||||
{
|
||||
case Mode::Linear:
|
||||
return Pass::Linear;
|
||||
case Mode::Fsr1:
|
||||
return Enlarged(srcW, srcH, dstW, dstH) ? Pass::Fsr1 : Pass::Bicubic;
|
||||
default:
|
||||
return Pass::Bicubic;
|
||||
}
|
||||
}
|
||||
|
||||
// Sharpening (0..1, default 0.3) for RCAS: the strength AMD's FsrRcasCon
|
||||
// takes as stops below its maximum (0: the most it sharpens, each stop
|
||||
// half as much), here as the linear factor it becomes (2^-stops), which
|
||||
// multiplies RCAS's sharpening lobe: Sharpening 1 is RCAS's maximum,
|
||||
// 0.5 half of it, and 0 turns RCAS off (EASU's picture as it is), as 0
|
||||
// turns bicubic's sharpening off.
|
||||
inline float RcasAmount(float sharpening)
|
||||
{
|
||||
if (!(sharpening > 0.0f)) // 0, negative or NaN
|
||||
return 0.0f;
|
||||
return std::min(sharpening, 1.0f);
|
||||
}
|
||||
|
||||
// The same as FsrRcasCon's stops (infinity: off).
|
||||
inline float RcasStops(float sharpening)
|
||||
{
|
||||
const float amount = RcasAmount(sharpening);
|
||||
return amount > 0.0f ? -std::log2(amount) : INFINITY;
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ MAC = platform.system() == 'Darwin'
|
||||
RACE = '8-26/2:START,30-130/4:DOWN,31-131/4:A,32.5-132.5/4:START,145:A'
|
||||
|
||||
# The menu's pages and rows (runtime/user/settings.cpp, hidden rows left out).
|
||||
PAGES = ['Display', 'Graphics', 'Audio', 'Input', 'Advanced']
|
||||
PAGES = ['Display', 'Graphics', 'Audio', 'Input', 'Controls', 'Advanced']
|
||||
ROWS = {
|
||||
'Display': ['video.fullscreen', 'video.vsync', 'video.frame_rate', 'video.aspect_ratio', 'video.brightness',
|
||||
'video.xbox_display_gamma'],
|
||||
|
||||
+72
-3
@@ -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)
|
||||
@@ -44,8 +50,33 @@ sb_test(gpu_choice_test)
|
||||
# video::FitPicture's pictures.
|
||||
sb_test(glare_fit_test SOURCES ${RT}/video/picture_fit.cpp)
|
||||
# The post-processing's split at the 16:9 band's edge (video/band_filter.h,
|
||||
# video::BandOf) against the edge-bar fills and a model of the filters.
|
||||
# video::BandOf) against the edge-bar fills and a model of the filters
|
||||
# (FSR 1's EASU taps and RCAS neighbours too).
|
||||
sb_test(band_filter_test SOURCES ${RT}/video/picture_fit.cpp)
|
||||
# Settings > Graphics > Upscaling's FSR 1: which pass draws a frame
|
||||
# (video/upscale.h, header only, on video::FitPicture's pictures), Sharpening
|
||||
# as RCAS's strength, the constants against AMD's formulas, and the shaders'
|
||||
# GLSL (video/fsr1.cpp, with runtime/generated/fsr1_headers.inc): AMD's
|
||||
# headers compiled in unchanged, the band rules in their code, the
|
||||
# presenter's use of them. It writes the GLSL to fsr1_glsl for the check
|
||||
# after it.
|
||||
sb_test(fsr1_test SOURCES ${RT}/video/fsr1.cpp ${RT}/video/picture_fit.cpp
|
||||
ARGS ${RT}/thirdparty/fsr1 ${RT}/video ${CMAKE_CURRENT_BINARY_DIR}/fsr1_glsl)
|
||||
target_include_directories(fsr1_test PRIVATE ${CMAKE_BINARY_DIR}/runtime/generated)
|
||||
# Those shaders compiled to SPIR-V for Vulkan, as the game's glslang does
|
||||
# (only where glslangValidator is installed).
|
||||
find_program(SB_GLSLANG_VALIDATOR glslangValidator)
|
||||
if(SB_GLSLANG_VALIDATOR)
|
||||
foreach(shader easu.comp rcas.frag rcas_tone.frag)
|
||||
add_test(NAME fsr1_glsl_check_${shader} COMMAND ${SB_GLSLANG_VALIDATOR} -V --target-env vulkan1.1
|
||||
${CMAKE_CURRENT_BINARY_DIR}/fsr1_glsl/${shader} -o ${CMAKE_CURRENT_BINARY_DIR}/fsr1_glsl/${shader}.spv)
|
||||
set_tests_properties(fsr1_glsl_check_${shader} PROPERTIES DEPENDS fsr1_test)
|
||||
endforeach()
|
||||
endif()
|
||||
# Upscaling's values in settings.toml (user/settings.cpp as the game links
|
||||
# it): "fsr1" read, saved and read back, the environment's override, an
|
||||
# unknown value; in a scratch folder.
|
||||
sb_test(settings_scaling_test SOURCES ${RT}/user/settings.cpp ARGS ${CMAKE_CURRENT_BINARY_DIR}/settings_scaling_scratch)
|
||||
# Internal Resolution Auto's budget on iPhones and iPads, chip by chip
|
||||
# (video/scale_budget.cpp), and the scale it makes on the phones' screens.
|
||||
sb_test(scale_budget_test SOURCES ${RT}/video/scale_budget.cpp ${RT}/video/picture_fit.cpp)
|
||||
@@ -63,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
|
||||
@@ -137,6 +178,25 @@ sb_test(memory_status_test SOURCES ${RT}/kernel/memory_status.cpp ${RT}/platform
|
||||
# The version line in the log, and when the "What's new" note shows
|
||||
# (user/version_check.h, header only).
|
||||
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).
|
||||
sb_test(settings_keys_test SOURCES ${RT}/user/settings.cpp)
|
||||
target_include_directories(settings_keys_test SYSTEM PRIVATE ${ROOT}/tools/XenonRecomp/thirdparty/tomlplusplus/include)
|
||||
# The keyboard's controls and how held keys become a pad (hid/key_map.h), header only.
|
||||
sb_test(key_map_test)
|
||||
# Check for Updates' logic: versions, GitHub's JSON, curl's output and the
|
||||
# release notes (update/check.cpp, self-contained).
|
||||
sb_test(update_check_test SOURCES ${RT}/update/check.cpp)
|
||||
@@ -145,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;
|
||||
}
|
||||
+78
-11
@@ -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:
|
||||
@@ -16,10 +17,17 @@
|
||||
// every one in the band exactly white (the career intro's movie
|
||||
// letterbox bled ~5% of the band into the strip's last row before);
|
||||
// - every fetch the anti-aliasing pass makes for a pixel reads only its
|
||||
// own span's texels.
|
||||
// own span's texels;
|
||||
// - FSR 1, where the picture is enlarged along the split axis: each of
|
||||
// EASU's taps for a screen pixel (texels floor(t - 0.5) - 1 to + 2 round
|
||||
// its centre t) reads only that pixel's span, and each neighbour RCAS
|
||||
// reads (the pixel's own picture, one pixel either way) lies in the same
|
||||
// span, so the black strips stay black and the band's edge rows equal its
|
||||
// middle ones.
|
||||
// The same checks run with planted faults (no split, a band edge one texel
|
||||
// off either way, clamping to the span's edges instead of its texel
|
||||
// centres): each must fail.
|
||||
// centres, an unclamped gather or EASU tap, RCAS reading across the edge):
|
||||
// each must fail.
|
||||
#include <gpu/edge_bars.h>
|
||||
#include <video/band_filter.h>
|
||||
#include <video/picture_fit.h>
|
||||
@@ -48,6 +56,8 @@ struct Model
|
||||
std::function<Span(float t, int32_t lo, int32_t hi, int32_t size)> spanOf = video::band::SpanOf;
|
||||
std::function<float(float t, Span s)> clamp = video::band::Clamp;
|
||||
std::function<int32_t(int32_t i, Span s)> clampTexel = video::band::ClampTexel;
|
||||
std::function<int32_t(int32_t i, int32_t j, int32_t lo, int32_t hi, int32_t outSize, int32_t srcSize)> rcasNeighbour =
|
||||
video::band::RcasNeighbour;
|
||||
};
|
||||
|
||||
// One axis of the frame image: texel values, the sampler's clamp to edge.
|
||||
@@ -174,13 +184,55 @@ static int AaCase(const Model& m, int32_t size, int32_t lo0, int32_t hi0)
|
||||
return g_failures - before;
|
||||
}
|
||||
|
||||
// FSR 1 along the split axis of one screen and image size, where the
|
||||
// picture is enlarged that way (else bicubic draws it: skipped). EASU's taps
|
||||
// for each screen pixel, and the screen pixels RCAS reads for it, against
|
||||
// where that pixel truly is. Returns the failures it added.
|
||||
static int FsrCase(const Model& m, const Shape& screen, const Shape& image)
|
||||
{
|
||||
const int before = g_failures;
|
||||
const video::PictureFit fit = video::FitPicture(screen.w, screen.h, 1280.0f, 720.0f, true, true);
|
||||
const video::FrameBand band = video::BandOf(image.w, image.h, fit.scaleX, fit.scaleY);
|
||||
const bool tall = fit.scaleY < 1.0f;
|
||||
const int32_t size = int32_t(tall ? image.h : image.w), pixels = tall ? fit.height : fit.width;
|
||||
if (pixels <= size)
|
||||
return 0;
|
||||
const int32_t trueLo = tall ? band.y0 : band.x0, trueHi = tall ? band.y1 : band.x1;
|
||||
int32_t lo = trueLo, hi = trueHi;
|
||||
m.band(lo, hi, size);
|
||||
auto own = [&](int32_t r) { return video::band::SpanOf(video::band::SourceOf(r, pixels, size), trueLo, trueHi, size); };
|
||||
for (int32_t r = 0; r < pixels && g_failures - before <= 8; r++)
|
||||
{
|
||||
const float t = video::band::SourceOf(r, pixels, size);
|
||||
const Span s = m.spanOf(t, lo, hi, size), truth = own(r);
|
||||
const int32_t fp = int32_t(std::floor(t - 0.5f));
|
||||
for (int32_t k = -1; k <= 2; k++)
|
||||
{
|
||||
const int32_t texel = std::clamp(m.clampTexel(fp + k, s), 0, size - 1); // and the sampler's clamp to edge
|
||||
CHECK(texel >= truth.a && texel < truth.b, "%ux%u, image %ux%u: EASU tap %+d of screen pixel %d (frame %.3f) reads texel %d outside its span [%d,%d)",
|
||||
screen.w, screen.h, image.w, image.h, k, r, t, texel, truth.a, truth.b);
|
||||
}
|
||||
for (int32_t d : { -1, 1 })
|
||||
{
|
||||
const int32_t read = m.rcasNeighbour(r, r + d, lo, hi, pixels, size);
|
||||
const Span rs = own(read);
|
||||
CHECK(rs.a == truth.a && rs.b == truth.b, "%ux%u, image %ux%u: RCAS's neighbour %+d of screen pixel %d reads pixel %d, in [%d,%d) not [%d,%d)",
|
||||
screen.w, screen.h, image.w, image.h, d, r, read, rs.a, rs.b, truth.a, truth.b);
|
||||
}
|
||||
}
|
||||
return g_failures - before;
|
||||
}
|
||||
|
||||
static int AllCases(const Model& m)
|
||||
{
|
||||
int failed = 0;
|
||||
for (const Shape& s : kScreens)
|
||||
for (const Shape& im : kImages)
|
||||
{
|
||||
for (int kind = 0; kind < 3; kind++)
|
||||
failed += ScaleCase(m, s, im, kind) ? 1 : 0;
|
||||
failed += FsrCase(m, s, im) ? 1 : 0;
|
||||
}
|
||||
// The anti-aliasing at the frame's resolution: 16:10, 4:3, 3:2 rows and
|
||||
// ultrawide columns at 1x and 2x.
|
||||
for (const Shape& s : { Shape{ 1280, 800 }, Shape{ 1024, 768 }, Shape{ 1200, 800 }, Shape{ 2560, 1080 } })
|
||||
@@ -194,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);
|
||||
@@ -220,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)
|
||||
@@ -281,7 +337,16 @@ int main()
|
||||
|
||||
const Model fixed{ "band_filter.h" };
|
||||
const int failed = AllCases(fixed);
|
||||
printf("scaling (3 filters x %zu screens x %zu images) and anti-aliasing fetches: %d case(s) failed\n", std::size(kScreens), std::size(kImages), failed);
|
||||
int fsrCases = 0;
|
||||
for (const Shape& s : kScreens)
|
||||
for (const Shape& im : kImages)
|
||||
{
|
||||
const video::PictureFit fit = video::FitPicture(s.w, s.h, 1280.0f, 720.0f, true, true);
|
||||
fsrCases += (fit.scaleY < 1.0f ? fit.height > int32_t(im.h) : fit.width > int32_t(im.w)) ? 1 : 0;
|
||||
}
|
||||
CHECK(fsrCases >= 8, "only %d FSR 1 cases enlarge along the split axis", fsrCases);
|
||||
printf("scaling (3 filters + FSR 1 where enlarged, %d cases, x %zu screens x %zu images) and anti-aliasing fetches: %d case(s) failed\n",
|
||||
fsrCases, std::size(kScreens), std::size(kImages), failed);
|
||||
|
||||
printf("planted faults (each must fail):\n");
|
||||
const Model planted[] = {
|
||||
@@ -293,6 +358,8 @@ int main()
|
||||
[](float t, Span s) { return std::clamp(t, float(s.a), float(s.b)); } },
|
||||
{ "no clamp of the gather", [](int32_t&, int32_t&, int32_t) {}, video::band::SpanOf, video::band::Clamp,
|
||||
[](int32_t i, Span) { return i; } },
|
||||
{ "RCAS reads across the edge", [](int32_t&, int32_t&, int32_t) {}, video::band::SpanOf, video::band::Clamp, video::band::ClampTexel,
|
||||
[](int32_t, int32_t j, int32_t, int32_t, int32_t outSize, int32_t) { return std::clamp(j, 0, outSize - 1); } },
|
||||
};
|
||||
for (const Model& m : planted)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+356
-101
@@ -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 ----
|
||||
@@ -691,17 +744,21 @@ static void PlantedBackdrop(const char* what, const BackdropRule& wrong)
|
||||
}
|
||||
|
||||
// The backdrop's fill against pixel centres: every pixel of the scissor
|
||||
// past the band and outside the box is filled, exactly once; nothing in the
|
||||
// band or the box is (ties within 1/256 pixel skipped). `rects` makes the
|
||||
// rectangles (BackdropRects, or a planted fault). Returns the wrong pixels.
|
||||
// outside the box is filled, exactly once, if it lies past the band or in
|
||||
// a row above or below the box (the band's overscan rows, at every shape,
|
||||
// 16:9 too); nothing in the box is, nor in the band beside it within its
|
||||
// rows (ties within 1/256 pixel skipped). `rects` makes the rectangles
|
||||
// (BackdropRects, or a planted fault). Returns the wrong pixels; the
|
||||
// overscan pixels it checked (in the band, above or below the box) go to
|
||||
// `overscan`.
|
||||
using BackdropRectsFn = uint32_t (*)(const Box&, const float*, float, float, const Rect&, float, float, Rect*);
|
||||
static int BackdropOracle(const char* what, float sx, float sy, const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor,
|
||||
BackdropRectsFn rects, bool print = true)
|
||||
BackdropRectsFn rects, bool print = true, int* overscan = nullptr)
|
||||
{
|
||||
Rect r[6]{};
|
||||
Rect r[8]{};
|
||||
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;
|
||||
int wrong = 0, ties = 0;
|
||||
int wrong = 0, ties = 0, rows = 0;
|
||||
for (int32_t y = scissor.y0; y < scissor.y1; y++)
|
||||
for (int32_t x = scissor.x0; x < scissor.x1; x++)
|
||||
{
|
||||
@@ -719,23 +776,28 @@ static int BackdropOracle(const char* what, float sx, float sy, const Box& b, co
|
||||
// centres the first row's centre is past clip 1, still the band).
|
||||
const bool inBand = (sx >= 1.0f || std::fabs(cx) < sx) && (sy >= 1.0f || std::fabs(cy) < sy);
|
||||
const bool inBox = cx > b.x0 && cx < b.x1 && cy > b.y0 && cy < b.y1;
|
||||
const bool pastRows = cy > b.y1 || cy < b.y0; // above or below the box
|
||||
const bool want = !inBox && (!inBand || pastRows);
|
||||
rows += inBand && pastRows;
|
||||
int filled = 0;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
filled += x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1;
|
||||
if (filled != ((inBand || inBox) ? 0 : 1))
|
||||
if (filled != (want ? 1 : 0))
|
||||
{
|
||||
if (print && wrong < 3 && std::getenv("EDGE_BARS_DEBUG"))
|
||||
printf(" wrong px %d,%d: clip %.5f %.5f, band %d box %d filled %d\n", x, y, cx, cy, inBand, inBox, filled);
|
||||
printf(" wrong px %d,%d: clip %.5f %.5f, band %d box %d rows %d filled %d\n", x, y, cx, cy, inBand, inBox, pastRows, filled);
|
||||
wrong++;
|
||||
}
|
||||
}
|
||||
if (overscan)
|
||||
*overscan = rows;
|
||||
if (print)
|
||||
{
|
||||
printf("backdrop fill %s: %u rect(s)", what, n);
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
printf(" [%d,%d)x[%d,%d)", r[i].x0, r[i].x1, r[i].y0, r[i].y1);
|
||||
printf(", %d pixel(s) wrong (%d on an edge, skipped)\n", wrong, ties);
|
||||
CHECK(wrong == 0, "%s: %d pixels filled wrongly (in the band or the box, twice, or left)", what, wrong);
|
||||
printf(", %d overscan px, %d pixel(s) wrong (%d on an edge, skipped)\n", rows, wrong, ties);
|
||||
CHECK(wrong == 0, "%s: %d pixels filled wrongly (in the box or beside it in the band, twice, or left)", what, wrong);
|
||||
}
|
||||
return wrong;
|
||||
}
|
||||
@@ -801,7 +863,8 @@ static void BackdropTests()
|
||||
};
|
||||
const Case cases[] = { { "2560x1080", 0.75f, 1.0f }, { "2868x1320 (iPhone 18 Pro Max)", 0.818182f, 1.0f }, { "3440x1440", 0.744186f, 1.0f },
|
||||
{ "32:9", 0.5f, 1.0f }, { "1.95:1 (the picture past the screen edge)", 0.912f, 1.0f }, { "1280x800", 1.0f, 0.9f },
|
||||
{ "1024x768", 1.0f, 0.75f }, { "1200x800", 1.0f, 0.84375f } };
|
||||
{ "1024x768", 1.0f, 0.75f }, { "1200x800", 1.0f, 0.84375f }, { "1280x720 (16:9)", 1.0f, 1.0f },
|
||||
{ "2732x2048 (iPad Pro)", 1.0f, 0.750366f }, { "3024x1898 (MacBook Pro 14)", 1.0f, 0.896207f } };
|
||||
int wrong = 0;
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
@@ -822,29 +885,66 @@ static void BackdropTests()
|
||||
wrong += BackdropOracle(what, c.sx, c.sy, results, ps, 1280, 2048, Rect{ 100, 10, 1180, 700 }, BackdropRects);
|
||||
}
|
||||
printf("backdrop fill: %d wrong pixel(s) over %zu shapes\n", wrong, std::size(cases));
|
||||
{
|
||||
// The overscan rows at the 8 screen shapes, 1x and 2x (half-pixel
|
||||
// centres): the results picture leaves rows of the band above and
|
||||
// below it at every shape, 16:9 too, and each of them is filled
|
||||
// once (the oracle above, which also counts them).
|
||||
printf("\nbackdrop overscan rows:\n");
|
||||
int rowsWrong = 0;
|
||||
for (const Shape& sh : kShapes)
|
||||
{
|
||||
const Box results{ -kBackdropX * sh.sx, kBackdropX * sh.sx, -kBackdropY * sh.sy, kBackdropY * sh.sy };
|
||||
char what[160];
|
||||
int px1 = 0, px2 = 0;
|
||||
snprintf(what, sizeof(what), "%s, overscan rows, 1x", sh.name);
|
||||
rowsWrong += BackdropOracle(what, sh.sx, sh.sy, results, ps, 1280, 2048, frame, BackdropRects, true, &px1);
|
||||
snprintf(what, sizeof(what), "%s, overscan rows, 2x, half-pixel", sh.name);
|
||||
rowsWrong += BackdropOracle(what, sh.sx, sh.sy, results, half, 2560, 4096, frame2, BackdropRects, true, &px2);
|
||||
// Rows: 0.019 of the band's half height each, ~7 * sy at 1x
|
||||
// (5 at 4:3), across the band's width.
|
||||
const int rowsMin = int(0.019f * sh.sy * 360.0f) - 1, wide = int(1280.0f * std::min(sh.sx, 1.0f)) - 2;
|
||||
CHECK(px1 >= 2 * rowsMin * wide && px2 >= 4 * 2 * rowsMin * wide, "%s: overscan px %d (1x) %d (2x), want >= %d and %d", sh.name, px1,
|
||||
px2, 2 * rowsMin * wide, 8 * rowsMin * wide);
|
||||
}
|
||||
printf("backdrop overscan rows: %d wrong pixel(s) over %zu shapes x 1x, 2x\n", rowsWrong, std::size(kShapes));
|
||||
}
|
||||
{
|
||||
// The rectangles themselves, 2868x1320 at 1x: columns past the
|
||||
// picture's sides (x +-1.104 band = 0.9033 clip: from column 1218,
|
||||
// to 62), and the corners past the band (0.8182 clip: column 1164)
|
||||
// above and below it (y +-0.981: rows 0..6 and 713..719).
|
||||
Rect r[6]{};
|
||||
// to 62), the corners past the band (0.8182 clip: column 1164)
|
||||
// above and below it (y +-0.981: rows 0..6 and 713..719), and the
|
||||
// overscan rows between them, inside the band (columns 116..1163).
|
||||
Rect r[8]{};
|
||||
const Box results{ -kBackdropX * 0.818182f, kBackdropX * 0.818182f, -kBackdropY, kBackdropY };
|
||||
const uint32_t n = BackdropRects(results, ps, 1280, 2048, frame, 0.818182f, 1.0f, r);
|
||||
const Rect want[6] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
|
||||
{ 62, 713, 116, 720 } };
|
||||
CHECK(n == 6, "iPhone: %u rects, want 6", n);
|
||||
for (uint32_t i = 0; i < n && i < 6; i++)
|
||||
const Rect want[8] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
|
||||
{ 62, 713, 116, 720 }, { 116, 0, 1164, 7 }, { 116, 713, 1164, 720 } };
|
||||
CHECK(n == 8, "iPhone: %u rects, want 8", n);
|
||||
for (uint32_t i = 0; i < n && i < 8; i++)
|
||||
CHECK(Same(r[i], want[i]), "iPhone: rect %u [%d,%d)x[%d,%d), want [%d,%d)x[%d,%d)", i, r[i].x0, r[i].x1, r[i].y0, r[i].y1, want[i].x0,
|
||||
want[i].x1, want[i].y0, want[i].y1);
|
||||
// Vert+: the rows above and below the band, whole (the picture stops
|
||||
// inside it in y and past the screen in x).
|
||||
// inside it in y and past the screen in x), then the band's overscan
|
||||
// rows (y +-0.981 x 0.75 = 0.736: rows 90..94 and 625..629).
|
||||
const Box r43{ -kBackdropX, kBackdropX, -kBackdropY * 0.75f, kBackdropY * 0.75f };
|
||||
const uint32_t n43 = BackdropRects(r43, ps, 1280, 2048, frame, 1.0f, 0.75f, r);
|
||||
CHECK(n43 == 2 && Same(r[0], Rect{ 0, 0, 1280, 90 }) && Same(r[1], Rect{ 0, 630, 1280, 720 }), "4:3: %u rects [%d,%d)x[%d,%d) ...", n43,
|
||||
r[0].x0, r[0].x1, r[0].y0, r[0].y1);
|
||||
// 16:9: nothing.
|
||||
CHECK(n43 == 4 && Same(r[0], Rect{ 0, 0, 1280, 90 }) && Same(r[1], Rect{ 0, 630, 1280, 720 }) && Same(r[2], Rect{ 0, 90, 1280, 95 }) &&
|
||||
Same(r[3], Rect{ 0, 625, 1280, 630 }),
|
||||
"4:3: %u rects [%d,%d)x[%d,%d) [%d,%d)x[%d,%d) ...", n43, r[0].x0, r[0].x1, r[0].y0, r[0].y1, r[2].x0, r[2].x1, r[2].y0, r[2].y1);
|
||||
// 16:9: the overscan rows alone, rows 0..6 and 713..719, the whole
|
||||
// width; none for a picture over the whole band.
|
||||
const Box r169{ -kBackdropX, kBackdropX, -kBackdropY, kBackdropY };
|
||||
CHECK(BackdropRects(r169, ps, 1280, 2048, frame, 1.0f, 1.0f, r) == 0, "16:9: a backdrop fills nothing");
|
||||
const uint32_t n169 = BackdropRects(r169, ps, 1280, 2048, frame, 1.0f, 1.0f, r);
|
||||
CHECK(n169 == 2 && Same(r[0], Rect{ 0, 0, 1280, 7 }) && Same(r[1], Rect{ 0, 713, 1280, 720 }), "16:9: %u rects [%d,%d)x[%d,%d) ...", n169,
|
||||
r[0].x0, r[0].x1, r[0].y0, r[0].y1);
|
||||
const Box whole{ -1.0f, 1.0f, -1.0f, 1.0f };
|
||||
CHECK(BackdropRects(whole, ps, 1280, 2048, frame, 1.0f, 1.0f, r) == 0, "16:9: a picture over the whole band fills nothing at 1x");
|
||||
// ... and at 2x with half-pixel centres only the top row, whose
|
||||
// centre lies past clip 1 (the picture is one host row lower: its
|
||||
// bottom edge is past the frame's).
|
||||
const uint32_t n2 = BackdropRects(whole, half, 2560, 4096, frame2, 1.0f, 1.0f, r);
|
||||
CHECK(n2 == 1 && Same(r[0], Rect{ 0, 0, 2560, 1 }), "16:9 2x: %u rects [%d,%d)x[%d,%d) ...", n2, r[0].x0, r[0].x1, r[0].y0, r[0].y1);
|
||||
}
|
||||
printf("\nplanted, backdrop fill (each must fail):\n");
|
||||
{
|
||||
@@ -859,11 +959,11 @@ 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[6]{};
|
||||
Rect r[8]{};
|
||||
const uint32_t n = BackdropRects(b, ps, w, h, sc, sx, sy, r);
|
||||
uint32_t m = 0;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
@@ -877,6 +977,69 @@ static void BackdropTests()
|
||||
wider.x1 = std::min(b.x1, sx);
|
||||
return BackdropRects(wider, ps, w, h, sc, sx, sy, out);
|
||||
});
|
||||
// The overscan rows, planted: each must fail. runOverscan checks
|
||||
// every shape at 1x and 2x; with `every`, each shape must fail (16:9
|
||||
// included), else at least one (a fault only some shapes have).
|
||||
auto runOverscan = [&](const char* what, BackdropRectsFn f, bool every) {
|
||||
int bad = 0, shapesFailing = 0;
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
const Box results{ -kBackdropX * c.sx, kBackdropX * c.sx, -kBackdropY * c.sy, kBackdropY * c.sy };
|
||||
const int here = BackdropOracle(what, c.sx, c.sy, results, ps, 1280, 2048, frame, f, false) +
|
||||
BackdropOracle(what, c.sx, c.sy, results, half, 2560, 4096, frame2, f, false);
|
||||
bad += here;
|
||||
shapesFailing += here > 0;
|
||||
}
|
||||
printf("planted %s: %d wrong pixel(s), %d of %zu shapes\n", what, bad, shapesFailing, std::size(cases));
|
||||
CHECK(every ? shapesFailing == int(std::size(cases)) : bad > 0, "planted %s: %d of %zu shapes failed", what, shapesFailing,
|
||||
std::size(cases));
|
||||
};
|
||||
// 811cdf2's fill: past the band only, the overscan rows left.
|
||||
runOverscan("no overscan rows (past the band only, as 811cdf2)", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx,
|
||||
float sy, Rect* out) { return PastBandRects(b, ps, w, h, sc, sx, sy, out); }, true);
|
||||
// Rows only where something lies past the band: 16:9 left.
|
||||
runOverscan("overscan rows only on wide and tall screens", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx,
|
||||
float sy, Rect* out) {
|
||||
const uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
return n + (sx < 1.0f || sy < 1.0f ? OverscanRows(b, ps, w, h, sc, sx, sy, out + n) : 0u);
|
||||
}, false);
|
||||
// Rows to clip 1 where the band's edge is the screen's (no
|
||||
// kFillEdge): at 2x the outermost row and column are left.
|
||||
runOverscan("overscan rows to clip 1, not kFillEdge", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy,
|
||||
Rect* out) {
|
||||
uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
const float bx = std::min(sx, 1.0f), by = std::min(sy, 1.0f);
|
||||
for (const Rect& r : { ClipToHost(-bx, bx, b.y1, by, ps, w, h), ClipToHost(-bx, bx, -by, b.y0, ps, w, h) })
|
||||
if (const Rect c = Intersect(r, sc); !Empty(c))
|
||||
out[n++] = c;
|
||||
return n;
|
||||
}, false);
|
||||
// Rows across the whole screen under Hor+: twice where the fill
|
||||
// past the band has them already.
|
||||
runOverscan("overscan rows across the whole screen", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy,
|
||||
Rect* out) {
|
||||
const uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
return n + OverscanRows(b, ps, w, h, sc, 1.0f, sy, out + n);
|
||||
}, false);
|
||||
// Rows from just inside the picture: its own top and bottom rows
|
||||
// blacked.
|
||||
runOverscan("overscan rows from inside the picture", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy,
|
||||
Rect* out) {
|
||||
Box in = b;
|
||||
in.y0 += 0.01f;
|
||||
in.y1 -= 0.01f;
|
||||
const uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
return n + OverscanRows(in, ps, w, h, sc, sx, sy, out + n);
|
||||
}, true);
|
||||
// Rows short of the picture: a row of the race left above it.
|
||||
runOverscan("overscan rows short of the picture", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy,
|
||||
Rect* out) {
|
||||
Box out_ = b;
|
||||
out_.y0 -= 0.01f;
|
||||
out_.y1 += 0.01f;
|
||||
const uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
return n + OverscanRows(out_, ps, w, h, sc, sx, sy, out + n);
|
||||
}, true);
|
||||
}
|
||||
|
||||
printf("\nbackdrop plan (the draw as the renderer hands it over):\n");
|
||||
@@ -884,15 +1047,57 @@ static void BackdropTests()
|
||||
const Matrix iphone = FrontEndMatrix(0.818182f, 1.0f), deck = FrontEndMatrix(1.0f, 0.9f), m169 = FrontEndMatrix(1.0f, 1.0f);
|
||||
const EPolys results = Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 0.818182f, 1.0f, kBackdropColour), iphone);
|
||||
const Draw d = BackdropDraw(iphone, 0.818182f, 1.0f, ps);
|
||||
const Rect want[6] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
|
||||
{ 62, 713, 116, 720 } };
|
||||
BackdropPlanCase("iPhone results", d, results, Fill::Backdrop, 6, want);
|
||||
const Rect deckWant[2] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 } };
|
||||
const Rect want[8] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
|
||||
{ 62, 713, 116, 720 }, { 116, 0, 1164, 7 }, { 116, 713, 1164, 720 } };
|
||||
BackdropPlanCase("iPhone results", d, results, Fill::Backdrop, 8, want);
|
||||
// The Deck (1280x800): the rows past the band (0..35, 684..719),
|
||||
// then its overscan rows (y 0.981 x 0.9 = 0.883: 36..41, 678..683).
|
||||
const Rect deckWant[4] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 }, { 0, 36, 1280, 42 }, { 0, 678, 1280, 684 } };
|
||||
BackdropPlanCase("Deck results", BackdropDraw(deck, 1.0f, 0.9f, ps),
|
||||
Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 0.9f, kBackdropColour), deck), Fill::Backdrop, 2, deckWant);
|
||||
// Never at 16:9 (nothing past the band to fill).
|
||||
BackdropPlanCase("16:9 results", BackdropDraw(m169, 1.0f, 1.0f, ps),
|
||||
Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 1.0f, kBackdropColour), m169), Fill::None);
|
||||
Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 0.9f, kBackdropColour), deck), Fill::Backdrop, 4, deckWant);
|
||||
// 16:9 too: its overscan rows alone (rows 0..6 and 713..719), also
|
||||
// from a ppc/ without the Vert+ hooks (no front-end y scale).
|
||||
const Draw d169 = BackdropDraw(m169, 1.0f, 1.0f, ps);
|
||||
const EPolys results169 = Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 1.0f, kBackdropColour), m169);
|
||||
const Rect want169[2] = { { 0, 0, 1280, 7 }, { 0, 713, 1280, 720 } };
|
||||
BackdropPlanCase("16:9 results", d169, results169, Fill::Backdrop, 2, want169);
|
||||
Draw old169 = d169;
|
||||
old169.frontEndY = 0.0f;
|
||||
BackdropPlanCase("16:9 results, no Vert+ hooks", old169, results169, Fill::Backdrop, 2, want169);
|
||||
// ... and at 2x with half-pixel centres: the picture one host row
|
||||
// lower (its top at 2 x 6.84 + 1 = 14.7, its bottom at 1427.3):
|
||||
// rows 0..14 and 1427..1439.
|
||||
float half169[4];
|
||||
FrontEndPs(half169, 640.5f, 360.5f);
|
||||
const Rect want169x2[2] = { { 0, 0, 2560, 15 }, { 0, 1427, 2560, 1440 } };
|
||||
BackdropPlanCase("16:9 results, 2x", BackdropDraw(m169, 1.0f, 1.0f, half169, 2560.0f, 4096.0f, Rect{ 0, 0, 2560, 1440 }), results169,
|
||||
Fill::Backdrop, 2, want169x2);
|
||||
// Every register check refuses at 16:9 as elsewhere (a fade, a dim
|
||||
// or the HUD never gets this far: MatchBackdrop's cases above run
|
||||
// at 16:9 too).
|
||||
auto refuse169 = [&](const char* what, auto change) {
|
||||
Draw x = d169;
|
||||
Matrix m = m169;
|
||||
change(x, m);
|
||||
x.c = m.c;
|
||||
char name[96];
|
||||
snprintf(name, sizeof(name), "16:9, %s", what);
|
||||
BackdropPlanCase(name, x, results169, Fill::None);
|
||||
};
|
||||
refuse169("taken by another rule", [](Draw& x, Matrix&) { x.leftAlone = false; });
|
||||
refuse169("not the front-end shader", [](Draw& x, Matrix&) { x.frontEndShader = false; });
|
||||
refuse169("inside an occlusion query", [](Draw& x, Matrix&) { x.occlusion = true; });
|
||||
refuse169("two quads", [](Draw& x, Matrix&) { x.count = 8; });
|
||||
refuse169("additive blend", [](Draw& x, Matrix&) { x.blend0 = 0x01060106; });
|
||||
refuse169("t0 not sampled", [](Draw& x, Matrix&) { x.t0Sampled = false; });
|
||||
refuse169("t0 32x32 (the white texture)", [](Draw& x, Matrix&) { x.t0Width = x.t0Height = 32; });
|
||||
refuse169("x translation c3", [](Draw&, Matrix& m) { m.c[3] = 0.01f; });
|
||||
refuse169("c5 not the front end's y scale", [](Draw& x, Matrix&) { x.frontEndY = 0.83f; });
|
||||
refuse169("VTE x offset off", [](Draw& x, Matrix&) { x.vte = 0x43D; });
|
||||
EPolys faded169 = results169;
|
||||
for (auto& w : faded169.w)
|
||||
w[3] = 0xB87F7F7F;
|
||||
BackdropPlanCase("16:9, alpha B8 (a fade-in)", d169, faded169, Fill::None);
|
||||
// A flat full-band layer over the 32x32 white texture is not a
|
||||
// picture (the black frame's and the fades' kind).
|
||||
Draw flat = d;
|
||||
@@ -922,7 +1127,7 @@ static void BackdropTests()
|
||||
refuse("VTE y offset off", [](Draw& x, Matrix&) { x.vte = 0x437; });
|
||||
Draw opaque = d;
|
||||
opaque.blend0 = 0x00010001;
|
||||
BackdropPlanCase("blending off", opaque, results, Fill::Backdrop, 6, want);
|
||||
BackdropPlanCase("blending off", opaque, results, Fill::Backdrop, 8, want);
|
||||
// PlanFill (bars and black frames) never takes it: the renderer asks
|
||||
// PlanBackdrop only when PlanFill found nothing.
|
||||
CHECK(RunPlan(d, results).fill == Fill::None, "PlanFill took the backdrop");
|
||||
@@ -1140,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);
|
||||
|
||||
@@ -1190,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 &&
|
||||
@@ -1205,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,276 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Graphics > Upscaling's FSR 1: which pass draws a frame
|
||||
// (video/upscale.h, on video::FitPicture's pictures), Sharpening as RCAS's
|
||||
// strength, the shaders' constants against AMD's formulas, and their GLSL
|
||||
// (video/fsr1.cpp): AMD's two headers compiled in byte for byte, one main
|
||||
// in RCAS's (the tone variant renames it), and the band rules of
|
||||
// video/band_filter.h in their code.
|
||||
// Arguments: runtime/thirdparty/fsr1 (the vendored headers), runtime/video
|
||||
// (presenter.cpp and fsr1.cpp read, and ../user/settings.cpp: FSR 1 isn't
|
||||
// offered on iPhone and iPad, and no FSR 1 shader is compiled unless a
|
||||
// frame's pass is FSR 1), and a folder the GLSL is written to
|
||||
// (fsr1_glsl_check, when glslangValidator is installed, compiles it).
|
||||
//
|
||||
// Build by hand (from the repo root, after a CMake configure has written
|
||||
// build/main/runtime/generated/fsr1_headers.inc):
|
||||
// c++ -std=c++20 -O2 -UNDEBUG -I runtime -I build/main/runtime/generated tests/fsr1_test.cpp \
|
||||
// runtime/video/fsr1.cpp runtime/video/picture_fit.cpp -o build/fsr1_test
|
||||
// build/fsr1_test runtime/thirdparty/fsr1 runtime/video build/fsr1_glsl
|
||||
#include <user/settings.h>
|
||||
#include <video/fsr1.h>
|
||||
#include <video/picture_fit.h>
|
||||
#include <video/upscale.h>
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failures = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
s_failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fprintf(stderr, "\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using video::upscale::Choose;
|
||||
using video::upscale::Mode;
|
||||
using video::upscale::Pass;
|
||||
|
||||
std::string Read(const std::filesystem::path& path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::stringstream text;
|
||||
text << in.rdbuf();
|
||||
return text.str();
|
||||
}
|
||||
|
||||
float Float(uint32_t bits)
|
||||
{
|
||||
float f;
|
||||
memcpy(&f, &bits, 4);
|
||||
return f;
|
||||
}
|
||||
|
||||
size_t Count(const std::string& text, const std::string& what)
|
||||
{
|
||||
size_t n = 0;
|
||||
for (size_t at = text.find(what); at != std::string::npos; at = text.find(what, at + 1))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// The pass for a 1280x720-shaped frame of imageW x imageH on a screen,
|
||||
// where the presenter puts it (Aspect Ratio Auto, the hooks present).
|
||||
Pass PassOn(Mode mode, uint32_t imageW, uint32_t imageH, uint32_t screenW, uint32_t screenH, int32_t* dw = nullptr, int32_t* dh = nullptr)
|
||||
{
|
||||
const video::PictureFit fit = video::FitPicture(screenW, screenH, 1280.0f, 720.0f, true, true);
|
||||
if (dw)
|
||||
*dw = fit.width, *dh = fit.height;
|
||||
return Choose(int32_t(mode), imageW, imageH, fit.width, fit.height);
|
||||
}
|
||||
|
||||
void PassSelection()
|
||||
{
|
||||
static_assert(int32_t(Mode::Bicubic) == int32_t(settings::ScalingMode::Bicubic));
|
||||
static_assert(int32_t(Mode::Linear) == int32_t(settings::ScalingMode::Linear));
|
||||
static_assert(int32_t(Mode::Fsr1) == int32_t(settings::ScalingMode::Fsr1));
|
||||
struct Case
|
||||
{
|
||||
const char* what;
|
||||
uint32_t imageW, imageH, screenW, screenH;
|
||||
Pass fsr; // with Upscaling on FSR 1
|
||||
};
|
||||
const Case cases[] = {
|
||||
{ "1x on 1080p", 1280, 720, 1920, 1080, Pass::Fsr1 },
|
||||
{ "1x on 1440p", 1280, 720, 2560, 1440, Pass::Fsr1 },
|
||||
{ "1x on a MacBook Pro 14 (Vert+)", 1280, 720, 3024, 1898, Pass::Fsr1 },
|
||||
{ "1.5x on 4K", 1920, 1080, 3840, 2160, Pass::Fsr1 },
|
||||
{ "1x on the Steam Deck (720 rows stretched to 800)", 1280, 720, 1280, 800, Pass::Fsr1 },
|
||||
{ "2x on a MacBook Pro 14 (Auto on an M1 Pro)", 2560, 1440, 3024, 1898, Pass::Fsr1 },
|
||||
{ "1x on an ultrawide (Hor+)", 1280, 720, 3440, 1440, Pass::Fsr1 },
|
||||
{ "1x in a 1280x720 window (1:1)", 1280, 720, 1280, 720, Pass::Bicubic },
|
||||
{ "2x on 1080p (shrunk)", 2560, 1440, 1920, 1080, Pass::Bicubic },
|
||||
{ "2x on 1440p (1:1)", 2560, 1440, 2560, 1440, Pass::Bicubic },
|
||||
{ "3x2 on an ultrawide (columns shrunk)", 3840, 1440, 3440, 1440, Pass::Bicubic },
|
||||
{ "1x in a small window (shrunk)", 1280, 720, 960, 540, Pass::Bicubic },
|
||||
{ "1x2 on 1080p (columns enlarged, rows shrunk)", 1280, 1440, 1920, 1080, Pass::Bicubic },
|
||||
};
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
int32_t dw = 0, dh = 0;
|
||||
const Pass fsr = PassOn(Mode::Fsr1, c.imageW, c.imageH, c.screenW, c.screenH, &dw, &dh);
|
||||
CHECK(fsr == c.fsr, "%s (%ux%u -> %dx%d): FSR 1 setting gives %s, want %s", c.what, c.imageW, c.imageH, dw, dh,
|
||||
video::upscale::PassName(fsr), video::upscale::PassName(c.fsr));
|
||||
// The other settings draw as they always did, enlarged or not.
|
||||
CHECK(PassOn(Mode::Bicubic, c.imageW, c.imageH, c.screenW, c.screenH) == Pass::Bicubic, "%s: bicubic", c.what);
|
||||
CHECK(PassOn(Mode::Linear, c.imageW, c.imageH, c.screenW, c.screenH) == Pass::Linear, "%s: bilinear", c.what);
|
||||
printf(" %-50s %4ux%-4u -> %4dx%-4d %s\n", c.what, c.imageW, c.imageH, dw, dh, video::upscale::PassName(fsr));
|
||||
}
|
||||
// No picture, or a value the menu never stores: bicubic.
|
||||
CHECK(Choose(int32_t(Mode::Fsr1), 0, 0, 1920, 1080) == Pass::Bicubic, "no frame");
|
||||
CHECK(Choose(int32_t(Mode::Fsr1), 1280, 720, 0, 0) == Pass::Bicubic, "no picture");
|
||||
CHECK(Choose(7, 1280, 720, 1920, 1080) == Pass::Bicubic, "an unknown mode");
|
||||
// Enlarged along one axis by a single pixel counts; along neither doesn't.
|
||||
CHECK(video::upscale::Enlarged(1280, 720, 1281, 720) && !video::upscale::Enlarged(1280, 720, 1280, 720), "one pixel");
|
||||
}
|
||||
|
||||
void Sharpness()
|
||||
{
|
||||
using video::upscale::RcasAmount;
|
||||
using video::upscale::RcasStops;
|
||||
CHECK(RcasAmount(0.0f) == 0.0f && std::isinf(RcasStops(0.0f)), "0 turns RCAS off");
|
||||
CHECK(RcasAmount(-1.0f) == 0.0f && RcasAmount(NAN) == 0.0f, "below 0, NaN: off");
|
||||
CHECK(RcasAmount(1.0f) == 1.0f && RcasStops(1.0f) == 0.0f, "1 is RCAS's maximum (0 stops)");
|
||||
CHECK(RcasAmount(2.0f) == 1.0f, "above 1: the maximum");
|
||||
CHECK(std::fabs(RcasStops(0.5f) - 1.0f) < 1e-6f, "0.5: one stop below");
|
||||
float last = -1.0f;
|
||||
for (int i = 0; i <= 20; i++)
|
||||
{
|
||||
const float a = RcasAmount(float(i) / 20.0f);
|
||||
CHECK(a > last || (i == 0 && a == 0.0f), "monotonic at %d/20", i);
|
||||
last = a;
|
||||
}
|
||||
// What RCAS gets: FsrRcasCon's con.x is the linear factor 2^-stops.
|
||||
for (float s : { 0.05f, 0.3f, 0.5f, 1.0f })
|
||||
{
|
||||
const auto con = video::fsr1::RcasConstants(RcasStops(s));
|
||||
CHECK(std::fabs(Float(con[0]) - RcasAmount(s)) < 1e-5f, "Sharpening %.2f: con.x %.6f, want %.6f", s, Float(con[0]), RcasAmount(s));
|
||||
}
|
||||
const auto off = video::fsr1::RcasConstants(RcasStops(0.0f));
|
||||
CHECK(off[0] == 0 && off[1] == 0, "off: con 0 (the shader copies EASU's picture)");
|
||||
}
|
||||
|
||||
void EasuConstants()
|
||||
{
|
||||
// FsrEasuCon's formulas (ffx_fsr1.h), for the image's whole size.
|
||||
const std::array<uint32_t, 4> sizes[] = { { 1280, 720, 1920, 1080 }, { 1280, 720, 1280, 800 }, { 1920, 1080, 3840, 2160 },
|
||||
{ 1280, 720, 3024, 1898 } };
|
||||
for (auto [sw, sh, dw, dh] : sizes)
|
||||
{
|
||||
const auto c = video::fsr1::EasuConstants(sw, sh, dw, dh);
|
||||
const float x = float(sw) / float(dw), y = float(sh) / float(dh);
|
||||
CHECK(std::fabs(Float(c[0]) - x) < 1e-6f && std::fabs(Float(c[1]) - y) < 1e-6f, "%ux%u -> %ux%u: con0.xy", sw, sh, dw, dh);
|
||||
CHECK(std::fabs(Float(c[2]) - (0.5f * x - 0.5f)) < 1e-6f && std::fabs(Float(c[3]) - (0.5f * y - 0.5f)) < 1e-6f, "con0.zw");
|
||||
CHECK(Float(c[4]) == 1.0f / float(sw) && Float(c[5]) == 1.0f / float(sh), "con1.xy");
|
||||
CHECK(Float(c[6]) == 1.0f / float(sw) && Float(c[7]) == -1.0f / float(sh), "con1.zw");
|
||||
CHECK(Float(c[8]) == -1.0f / float(sw) && Float(c[9]) == 2.0f / float(sh), "con2.xy");
|
||||
CHECK(Float(c[10]) == 1.0f / float(sw) && Float(c[11]) == 2.0f / float(sh), "con2.zw");
|
||||
CHECK(Float(c[12]) == 0.0f && Float(c[13]) == 4.0f / float(sh) && c[14] == 0 && c[15] == 0, "con3");
|
||||
}
|
||||
}
|
||||
|
||||
void Glsl(const std::filesystem::path& fsrDir, const std::filesystem::path& videoDir, const std::filesystem::path& out)
|
||||
{
|
||||
const std::string easu = video::fsr1::EasuGlsl(), rcas = video::fsr1::RcasGlsl();
|
||||
// AMD's headers inside, byte for byte as vendored (unmodified).
|
||||
size_t halfInHeaders = 0;
|
||||
for (const char* name : { "ffx_a.h", "ffx_fsr1.h" })
|
||||
{
|
||||
const std::string header = Read(fsrDir / name);
|
||||
CHECK(header.size() > 10000, "%s read (%zu bytes)", name, header.size());
|
||||
CHECK(easu.find(header) != std::string::npos && rcas.find(header) != std::string::npos, "%s compiled in unchanged", name);
|
||||
halfInHeaders += Count(header, "#define A_HALF"); // their usage examples, in comments
|
||||
}
|
||||
CHECK(Count(easu, "#define FSR_EASU_F 1") == 1 && Count(rcas, "#define FSR_RCAS_F 1") == 1, "the 32-bit paths");
|
||||
CHECK(Count(easu, "#define A_HALF") == halfInHeaders && Count(rcas, "#define A_HALF") == halfInHeaders,
|
||||
"no 16-bit path of ours (MoltenVK, Intel): %zu, %zu, %zu in the headers' comments", Count(easu, "#define A_HALF"),
|
||||
Count(rcas, "#define A_HALF"), halfInHeaders);
|
||||
CHECK(Count(rcas, "void main()") == 1, "RCAS has one main (the tone variant renames it)");
|
||||
// band_filter.h's rules, as the GLSL writes them.
|
||||
CHECK(easu.find("vec2 pos = (vec2(ip) + 0.5) * vec2(p.srcSize) / vec2(p.outSize);") != std::string::npos, "EASU: SourceOf");
|
||||
CHECK(easu.find("return t < float(lo) ? ivec2(0, lo) : t < float(hi) ? ivec2(lo, hi) : ivec2(hi, size);") != std::string::npos, "EASU: SpanOf");
|
||||
CHECK(easu.find("texelFetch(src, clamp(t, spanLo, spanHi), 0)") != std::string::npos, "EASU: ClampTexel");
|
||||
CHECK(easu.find("p.bandOn == 0u ? textureGather(src, q, 0) : quadIn(q, 0)") != std::string::npos, "EASU: gathers without a band");
|
||||
CHECK(rcas.find("float t = (float(i) + 0.5) * float(srcSize) / float(outSize);") != std::string::npos, "RCAS: SourceOf");
|
||||
CHECK(rcas.find("return t < float(lo) ? 0 : t < float(hi) ? 1 : 2;") != std::string::npos, "RCAS: SpanIndex");
|
||||
CHECK(Count(rcas, "!= centreSpan.") == 2 && rcas.find("t.x = centre.x;") != std::string::npos && rcas.find("t.y = centre.y;") != std::string::npos,
|
||||
"RCAS: RcasNeighbour on both axes");
|
||||
// The presenter: the pass from Choose, RCAS's tone variant from ToneVariant, EASU before the swapchain's pass.
|
||||
const std::string presenter = Read(videoDir / "presenter.cpp");
|
||||
CHECK(presenter.find("upscale::Choose(settings::GetInt(settings::Id::Scaling), w, h, dw, dh)") != std::string::npos, "ChoosePass");
|
||||
CHECK(presenter.find("ToneVariant(fsr1::RcasGlsl().c_str())") != std::string::npos, "RCAS's tone variant");
|
||||
const size_t easuAt = presenter.find("RecordEasu(cmd, scaleInput, w, h, dw, dh, band, bandOn);");
|
||||
const size_t passAt = presenter.find("color.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // black bars");
|
||||
CHECK(easuAt != std::string::npos && passAt != std::string::npos && easuAt < passAt, "EASU before the swapchain image's pass");
|
||||
// No FSR 1 shader or pipeline unless a frame's pass is FSR 1: the
|
||||
// GLSL is compiled only in EnsureFsr and RcasPipeline, and every
|
||||
// EnsureFsr() call is behind "pass == upscale::Pass::Fsr1".
|
||||
const size_t ensureAt = presenter.find("bool EnsureFsr()"), imageAt = presenter.find("void EnsureFsrImage(");
|
||||
CHECK(ensureAt != std::string::npos && imageAt != std::string::npos && ensureAt < imageAt, "EnsureFsr, RcasPipeline, EnsureFsrImage in order");
|
||||
size_t glslAt = 0, glslCount = 0;
|
||||
while ((glslAt = presenter.find("fsr1::", glslAt)) != std::string::npos)
|
||||
{
|
||||
if (presenter.compare(glslAt, 15, "fsr1::EasuGlsl(") == 0 || presenter.compare(glslAt, 15, "fsr1::RcasGlsl(") == 0)
|
||||
{
|
||||
glslCount++;
|
||||
CHECK(glslAt > ensureAt && glslAt < imageAt, "FSR 1 GLSL compiled outside EnsureFsr/RcasPipeline (offset %zu)", glslAt);
|
||||
}
|
||||
glslAt += 6;
|
||||
}
|
||||
CHECK(glslCount == 3, "FSR 1 GLSL compiled in 3 places (EASU, RCAS, RCAS's tone variant): %zu", glslCount);
|
||||
size_t calls = 0;
|
||||
for (size_t callAt = presenter.find("EnsureFsr()"); callAt != std::string::npos; callAt = presenter.find("EnsureFsr()", callAt + 11))
|
||||
{
|
||||
if (callAt == ensureAt + 5)
|
||||
continue; // the definition
|
||||
calls++;
|
||||
const size_t lineAt = presenter.rfind('\n', callAt) + 1;
|
||||
CHECK(presenter.substr(lineAt, callAt - lineAt).find("pass == upscale::Pass::Fsr1 && ") != std::string::npos,
|
||||
"EnsureFsr() called without the FSR 1 pass: %s", presenter.substr(lineAt, presenter.find('\n', callAt) - lineAt).c_str());
|
||||
}
|
||||
CHECK(calls == 2, "EnsureFsr() called from ChoosePass and NFSMW_UPSCALE_COMPARE only: %zu", calls);
|
||||
// iPhone and iPad: no FSR 1 in the menu (user/settings.cpp: its entry
|
||||
// compiled out there, so "fsr1" reads as unknown = bicubic).
|
||||
const std::string settingsCpp = Read(videoDir / ".." / "user" / "settings.cpp");
|
||||
const size_t entryAt = settingsCpp.find("{ int32_t(ScalingMode::Fsr1), \"fsr1\", \"FSR 1\" },");
|
||||
const size_t ifAt = settingsCpp.rfind("#if !(defined(__APPLE__) && TARGET_OS_IOS)", entryAt);
|
||||
const size_t endifAt = settingsCpp.find("#endif", ifAt == std::string::npos ? 0 : ifAt);
|
||||
CHECK(entryAt != std::string::npos && ifAt != std::string::npos && endifAt > entryAt
|
||||
&& settingsCpp.substr(ifAt, entryAt - ifAt).find("#endif") == std::string::npos,
|
||||
"the FSR 1 Upscaling entry is compiled out on iPhone and iPad");
|
||||
// For fsr1_glsl_check: the shaders as glslang gets them (the tone
|
||||
// variant as ToneVariant makes it).
|
||||
std::filesystem::create_directories(out);
|
||||
std::ofstream(out / "easu.comp") << easu;
|
||||
std::ofstream(out / "rcas.frag") << rcas;
|
||||
std::string tone = rcas;
|
||||
tone.replace(tone.find("void main()"), 11, "void scaleMain()");
|
||||
tone += "\nlayout(set = 0, binding = 1) uniform sampler2D toneTable;\nvoid main()\n{\n scaleMain();\n"
|
||||
" color = vec4(textureLod(toneTable, vec2(color.r, 0.5), 0.0).r, color.gb, color.a);\n}\n";
|
||||
std::ofstream(out / "rcas_tone.frag") << tone;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
printf("the pass with Upscaling on FSR 1:\n");
|
||||
PassSelection();
|
||||
Sharpness();
|
||||
EasuConstants();
|
||||
if (argc >= 4)
|
||||
Glsl(argv[1], argv[2], argv[3]);
|
||||
else
|
||||
printf("(no folders given: the GLSL checks skipped)\n");
|
||||
if (s_failures)
|
||||
{
|
||||
printf("%d check(s) FAILED\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Tests for hid/key_map.h: the 24 keyboard-driven controls, their two keys each, and how held keys
|
||||
// become buttons, triggers and stick axes. Header only; from the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/key_map_test.cpp -o build/key_map_test && build/key_map_test
|
||||
#include <hid/key_map.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <initializer_list>
|
||||
#include <set>
|
||||
|
||||
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)
|
||||
|
||||
using namespace hid::keymap;
|
||||
|
||||
static Resolved Press(std::initializer_list<Control> held)
|
||||
{
|
||||
bool pressed[kControls] = {};
|
||||
for (Control c : held)
|
||||
pressed[size_t(c)] = true;
|
||||
return Resolve(pressed);
|
||||
}
|
||||
|
||||
static bool NoAxis(const Resolved& r)
|
||||
{
|
||||
for (size_t a = 0; a < size_t(Axis::Count); a++)
|
||||
if (r.axisSet[a] || r.axis[a] != 0.0f)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
// The table: every control once, in order, each with its own setting
|
||||
// among the Key options.
|
||||
CHECK(kControls == 24, "%zu controls", kControls);
|
||||
std::set<settings::Id> ids;
|
||||
for (size_t i = 0; i < kControls; i++)
|
||||
{
|
||||
CHECK(size_t(kEntries[i].control) == i, "entry %zu is its control", i);
|
||||
CHECK(ids.insert(kEntries[i].id).second && ids.insert(kEntries[i].id2).second, "entry %zu: its own two settings", i);
|
||||
CHECK(kEntries[i].id == settings::Id(size_t(settings::Id::KeyRightTrigger) + 2 * i), "entry %zu: in the options' order", i);
|
||||
CHECK(kEntries[i].id2 == settings::Id(size_t(kEntries[i].id) + 1), "entry %zu: its second key is the next option", i);
|
||||
CHECK(kEntries[i].id2 <= settings::Id::KeyRightStickClick2, "entry %zu: a Key option", i);
|
||||
if (kEntries[i].role == Role::Button)
|
||||
CHECK(kEntries[i].button != 0 && (kEntries[i].button & (kEntries[i].button - 1)) == 0, "entry %zu: one bit", i);
|
||||
else
|
||||
CHECK(kEntries[i].button == 0, "entry %zu: no bit", i);
|
||||
}
|
||||
CHECK(kEntries[size_t(Control::RightTrigger)].id == settings::Id::KeyRightTrigger &&
|
||||
kEntries[size_t(Control::RightStickClick)].id2 == settings::Id::KeyRightStickClick2, "first and last");
|
||||
// Either key's option finds its control.
|
||||
CHECK(&EntryOf(settings::Id::KeyRightTrigger) == &kEntries[0] && &EntryOf(settings::Id::KeyRightTrigger2) == &kEntries[0] &&
|
||||
&EntryOf(settings::Id::KeyLeftStickLeft2) == &kEntries[size_t(Control::LeftStickLeft)] &&
|
||||
&EntryOf(settings::Id::KeyRightStickClick2) == &kEntries[kControls - 1], "EntryOf");
|
||||
// The bits are XInput's (xbox.h's XAMINPUT_GAMEPAD_*; input.cpp asserts the same).
|
||||
CHECK(button::DpadUp == 0x0001 && button::DpadDown == 0x0002 && button::DpadLeft == 0x0004 && button::DpadRight == 0x0008 &&
|
||||
button::Start == 0x0010 && button::Back == 0x0020 && button::LeftThumb == 0x0040 && button::RightThumb == 0x0080 &&
|
||||
button::LeftShoulder == 0x0100 && button::RightShoulder == 0x0200 &&
|
||||
button::A == 0x1000 && button::B == 0x2000 && button::X == 0x4000 && button::Y == 0x8000, "XInput's bits");
|
||||
|
||||
// Nothing held: nothing.
|
||||
Resolved r = Press({});
|
||||
CHECK(r.buttons == 0 && !r.leftTrigger && !r.rightTrigger && NoAxis(r), "idle");
|
||||
|
||||
// A button key sets its bit and nothing else.
|
||||
r = Press({ Control::A });
|
||||
CHECK(r.buttons == button::A && !r.leftTrigger && !r.rightTrigger && NoAxis(r), "A: 0x%04x", r.buttons);
|
||||
CHECK(Press({ Control::B }).buttons == button::B && Press({ Control::X }).buttons == button::X && Press({ Control::Y }).buttons == button::Y, "B X Y");
|
||||
CHECK(Press({ Control::LB }).buttons == button::LeftShoulder && Press({ Control::RB }).buttons == button::RightShoulder, "LB RB");
|
||||
CHECK(Press({ Control::DpadUp }).buttons == button::DpadUp && Press({ Control::DpadDown }).buttons == button::DpadDown &&
|
||||
Press({ Control::DpadLeft }).buttons == button::DpadLeft && Press({ Control::DpadRight }).buttons == button::DpadRight, "D-pad");
|
||||
CHECK(Press({ Control::Start }).buttons == button::Start && Press({ Control::Back }).buttons == button::Back, "Start Back");
|
||||
CHECK(Press({ Control::LeftStickClick }).buttons == button::LeftThumb && Press({ Control::RightStickClick }).buttons == button::RightThumb, "clicks");
|
||||
r = Press({ Control::DpadLeft, Control::Back, Control::X });
|
||||
CHECK(r.buttons == (button::DpadLeft | button::Back | button::X), "several: 0x%04x", r.buttons);
|
||||
|
||||
// A trigger key is that trigger at full.
|
||||
r = Press({ Control::RightTrigger });
|
||||
CHECK(r.rightTrigger && !r.leftTrigger && r.buttons == 0 && NoAxis(r), "right trigger");
|
||||
r = Press({ Control::LeftTrigger });
|
||||
CHECK(r.leftTrigger && !r.rightTrigger, "left trigger");
|
||||
|
||||
// An axis is the difference of its keys, set while either is down, so
|
||||
// both keys centre the stick and no key leaves the controller's value.
|
||||
const size_t LX = size_t(Axis::LX), LY = size_t(Axis::LY), RX = size_t(Axis::RX), RY = size_t(Axis::RY);
|
||||
r = Press({ Control::LeftStickRight });
|
||||
CHECK(r.axisSet[LX] && r.axis[LX] == 1.0f && !r.axisSet[LY] && !r.axisSet[RX] && !r.axisSet[RY] && r.buttons == 0, "right: +1");
|
||||
r = Press({ Control::LeftStickLeft });
|
||||
CHECK(r.axisSet[LX] && r.axis[LX] == -1.0f, "left: -1");
|
||||
r = Press({ Control::LeftStickLeft, Control::LeftStickRight });
|
||||
CHECK(r.axisSet[LX] && r.axis[LX] == 0.0f, "both: set, centred");
|
||||
r = Press({ Control::LeftStickUp });
|
||||
CHECK(r.axisSet[LY] && r.axis[LY] == 1.0f && !r.axisSet[LX], "up is positive");
|
||||
r = Press({ Control::LeftStickDown });
|
||||
CHECK(r.axisSet[LY] && r.axis[LY] == -1.0f, "down is negative");
|
||||
r = Press({ Control::RightStickUp });
|
||||
CHECK(r.axisSet[RY] && r.axis[RY] == 1.0f && !r.axisSet[RX] && !r.axisSet[LY], "right stick up");
|
||||
r = Press({ Control::RightStickDown });
|
||||
CHECK(r.axisSet[RY] && r.axis[RY] == -1.0f, "right stick down");
|
||||
r = Press({ Control::RightStickLeft });
|
||||
CHECK(r.axisSet[RX] && r.axis[RX] == -1.0f && !r.axisSet[RY], "right stick left");
|
||||
r = Press({ Control::RightStickRight });
|
||||
CHECK(r.axisSet[RX] && r.axis[RX] == 1.0f, "right stick right");
|
||||
|
||||
// One key on a button and an axis (the default Up): both fire.
|
||||
r = Press({ Control::DpadUp, Control::LeftStickUp });
|
||||
CHECK(r.buttons == button::DpadUp && r.axisSet[LY] && r.axis[LY] == 1.0f, "Up: D-pad and stick");
|
||||
|
||||
// Everything at once is still each thing.
|
||||
bool all[kControls];
|
||||
for (bool& b : all)
|
||||
b = true;
|
||||
r = Resolve(all);
|
||||
CHECK(r.buttons == 0xF3FF && r.leftTrigger && r.rightTrigger, "all buttons: 0x%04x", r.buttons);
|
||||
for (size_t a = 0; a < size_t(Axis::Count); a++)
|
||||
CHECK(r.axisSet[a] && r.axis[a] == 0.0f, "axis %zu: both keys, centred", a);
|
||||
|
||||
// Usable at compile time (constexpr), so the table is checked when it's built.
|
||||
constexpr bool kNone[kControls] = {};
|
||||
static_assert(Resolve(kNone).buttons == 0, "constexpr");
|
||||
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Tests for user/key_names.h: the keys a Controls row can hold and their
|
||||
// names in settings.toml. Links SDL3 to check every code against SDL's name
|
||||
// for that scancode (no SDL_Init: the names are SDL's built-in ones, before
|
||||
// a platform renames any). From the repo root, with the deps in build/deps:
|
||||
// clang++ -std=c++20 -O2 -Iruntime -Ibuild/deps/include tests/key_names_test.cpp -Lbuild/deps/lib -lSDL3 \
|
||||
// -Wl,-rpath,build/deps/lib -o build/key_names_test && build/key_names_test
|
||||
#include <user/key_names.h>
|
||||
|
||||
#include <SDL3/SDL_keyboard.h>
|
||||
#include <SDL3/SDL_scancode.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
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)
|
||||
|
||||
using namespace settings::keynames;
|
||||
|
||||
int main()
|
||||
{
|
||||
CHECK(kKeyCount == 123, "%zu keys", kKeyCount);
|
||||
|
||||
// Tokens: lower-case letters, digits and underscores, each once. Codes:
|
||||
// scancodes, ascending, each once. And both ways round.
|
||||
std::set<std::string_view> tokens;
|
||||
uint16_t last = 0;
|
||||
for (const Key& k : kKeys)
|
||||
{
|
||||
bool wellFormed = k.token && k.token[0];
|
||||
for (const char* c = k.token; wellFormed && *c; c++)
|
||||
wellFormed = (*c >= 'a' && *c <= 'z') || (*c >= '0' && *c <= '9') || *c == '_';
|
||||
CHECK(wellFormed, "%s: a token is [a-z0-9_]+", k.token ? k.token : "(null)");
|
||||
CHECK(tokens.insert(k.token).second, "%s: once", k.token);
|
||||
CHECK(k.code > last, "%s (%d): codes ascend", k.token, k.code);
|
||||
last = k.code;
|
||||
CHECK(k.code != kNone && k.code != kUnknown && k.code < SDL_SCANCODE_COUNT, "%s: a scancode", k.token);
|
||||
CHECK(Token(k.code) == k.token, "%s: Token(code)", k.token);
|
||||
CHECK(Code(k.token) == k.code, "%s: Code(token)", k.token);
|
||||
CHECK(Known(k.code), "%s: Known", k.token);
|
||||
}
|
||||
CHECK(!tokens.count("none"), "\"none\" is not a row of the table");
|
||||
|
||||
// none, any case, and what isn't a name.
|
||||
CHECK(std::string_view(Token(kNone)) == "none" && Code("none") == kNone && Code("NONE") == kNone && Known(kNone), "none is 0");
|
||||
CHECK(Code("Left_Shift") == SDL_SCANCODE_LSHIFT && Code("W") == SDL_SCANCODE_W && Code("w") == SDL_SCANCODE_W, "any case");
|
||||
CHECK(Code("") == kUnknown && Code("Left Shift") == kUnknown && Code("left_shif") == kUnknown &&
|
||||
Code("left_shiftx") == kUnknown && Code("none ") == kUnknown, "not a token");
|
||||
CHECK(Token(1) == nullptr && Token(SDL_SCANCODE_LGUI) == nullptr && Token(kUnknown) == nullptr && Token(512) == nullptr, "not a key");
|
||||
CHECK(!Known(1) && !Known(SDL_SCANCODE_F1) && !Known(SDL_SCANCODE_LGUI) && !Known(SDL_SCANCODE_RGUI) && !Known(kUnknown),
|
||||
"F1, Cmd / Windows and nameless codes are out");
|
||||
CHECK(Code("f1") == kUnknown && Code("left_gui") == kUnknown && Code("right_gui") == kUnknown, "...by name too");
|
||||
CHECK(Code("escape") == SDL_SCANCODE_ESCAPE && Known(SDL_SCANCODE_ESCAPE), "Esc is a key (Back's default)");
|
||||
|
||||
// SDL agrees with every row: its name for the scancode, lowered with
|
||||
// spaces as underscores, is the token, bar the keys SDL names by their
|
||||
// symbol or runs together.
|
||||
const std::map<uint16_t, std::string_view> exceptions = {
|
||||
{ SDL_SCANCODE_MINUS, "minus" }, { SDL_SCANCODE_EQUALS, "equals" }, { SDL_SCANCODE_LEFTBRACKET, "left_bracket" },
|
||||
{ SDL_SCANCODE_RIGHTBRACKET, "right_bracket" }, { SDL_SCANCODE_BACKSLASH, "backslash" }, { SDL_SCANCODE_NONUSHASH, "non_us_hash" },
|
||||
{ SDL_SCANCODE_SEMICOLON, "semicolon" }, { SDL_SCANCODE_APOSTROPHE, "apostrophe" }, { SDL_SCANCODE_GRAVE, "grave" },
|
||||
{ SDL_SCANCODE_COMMA, "comma" }, { SDL_SCANCODE_PERIOD, "period" }, { SDL_SCANCODE_SLASH, "slash" },
|
||||
{ SDL_SCANCODE_CAPSLOCK, "caps_lock" }, { SDL_SCANCODE_PRINTSCREEN, "print_screen" }, { SDL_SCANCODE_SCROLLLOCK, "scroll_lock" },
|
||||
{ SDL_SCANCODE_PAGEUP, "page_up" }, { SDL_SCANCODE_PAGEDOWN, "page_down" }, { SDL_SCANCODE_NUMLOCKCLEAR, "num_lock" },
|
||||
{ SDL_SCANCODE_KP_DIVIDE, "keypad_divide" }, { SDL_SCANCODE_KP_MULTIPLY, "keypad_multiply" }, { SDL_SCANCODE_KP_MINUS, "keypad_minus" },
|
||||
{ SDL_SCANCODE_KP_PLUS, "keypad_plus" }, { SDL_SCANCODE_KP_PERIOD, "keypad_period" }, { SDL_SCANCODE_KP_EQUALS, "keypad_equals" },
|
||||
{ SDL_SCANCODE_KP_COMMA, "keypad_comma" }, { SDL_SCANCODE_NONUSBACKSLASH, "non_us_backslash" },
|
||||
};
|
||||
for (const Key& k : kKeys)
|
||||
{
|
||||
std::string expected;
|
||||
if (auto e = exceptions.find(k.code); e != exceptions.end())
|
||||
expected = e->second;
|
||||
else
|
||||
for (const char* c = SDL_GetScancodeName(SDL_Scancode(k.code)); *c; c++)
|
||||
expected += *c == ' ' ? '_' : char(std::tolower(uint8_t(*c)));
|
||||
CHECK(expected == k.token, "%d: SDL calls it \"%s\", the table \"%s\"", k.code, SDL_GetScancodeName(SDL_Scancode(k.code)), k.token);
|
||||
}
|
||||
|
||||
// Usable at compile time (the option table's defaults are checked when it's built).
|
||||
static_assert(Code("w") == 26 && Token(26)[0] == 'w' && Known(0) && !Known(227) && Code("None") == kNone, "constexpr");
|
||||
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -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,216 @@
|
||||
// Tests for the Key options (Settings > Controls, user/settings.h): the
|
||||
// table, the [keys] names in settings.toml, and what the menu's Set accepts.
|
||||
// Links user/settings.cpp (and toml++ from XenonRecomp's vendored copy); no
|
||||
// SDL. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime -Itools/XenonRecomp/thirdparty/tomlplusplus/include \
|
||||
// tests/settings_keys_test.cpp runtime/user/settings.cpp -o build/settings_keys_test && build/settings_keys_test
|
||||
#include <user/key_names.h>
|
||||
#include <user/settings.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <unistd.h> // mkdtemp
|
||||
|
||||
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)
|
||||
|
||||
static std::string ReadAll(const std::filesystem::path& file)
|
||||
{
|
||||
std::ifstream f(file);
|
||||
std::stringstream s;
|
||||
s << f.rdbuf();
|
||||
return s.str();
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
using namespace settings;
|
||||
using keynames::Code;
|
||||
|
||||
// The table: 48 Key options, every one in [keys], on the Controls page,
|
||||
// live, shown, with no environment variable and a default the key table
|
||||
// knows (none included).
|
||||
int keys = 0;
|
||||
std::set<std::string> names;
|
||||
for (const Option& o : Options())
|
||||
{
|
||||
if (o.type != Type::Key)
|
||||
{
|
||||
CHECK(std::string_view(o.table) != "keys", "%s.%s: only Key options live in [keys]", o.table, o.key);
|
||||
CHECK(o.env != nullptr, "%s.%s: every other option has an environment variable", o.table, o.key);
|
||||
continue;
|
||||
}
|
||||
keys++;
|
||||
CHECK(std::string_view(o.table) == "keys", "%s.%s: a Key option lives in [keys]", o.table, o.key);
|
||||
CHECK(o.category == Category::Controls, "%s: on the Controls page", o.key);
|
||||
CHECK(o.apply == Apply::Live && !o.hidden, "%s: live and shown", o.key);
|
||||
CHECK(o.env == nullptr, "%s: no environment variable", o.key);
|
||||
CHECK(names.insert(o.key).second, "%s: one row per key", o.key);
|
||||
CHECK(o.defaultValue >= 0 && o.defaultValue == std::floor(o.defaultValue) && keynames::Known(uint16_t(o.defaultValue)),
|
||||
"%s: the default is a key, or none", o.key);
|
||||
CHECK(o.min == 0 && o.max == 0 && o.step == 0 && o.entries.empty(), "%s: no range", o.key);
|
||||
}
|
||||
CHECK(keys == 48, "%d Key options", keys);
|
||||
CHECK(std::string_view(CategoryName(Category::Controls)) == "Controls", "the category's name");
|
||||
|
||||
// The defaults: v0.1.4's keys (the arrows steer as well as A and D, and
|
||||
// are the D-pad; O/L/K/; are the right stick), plus the left stick
|
||||
// click, which had none. In page order, in pairs: the second key is the first's id +
|
||||
// 1, named <key>_2, with the row's label.
|
||||
struct Default { Id id; const char* key; const char* label; const char* first; const char* second; };
|
||||
const Default defaults[] = {
|
||||
{ Id::KeyRightTrigger, "right_trigger", "Right Trigger", "w", "none" },
|
||||
{ Id::KeyLeftTrigger, "left_trigger", "Left Trigger", "s", "none" },
|
||||
{ Id::KeyLeftStickLeft, "left_stick_left", "Left Stick Left", "a", "left" },
|
||||
{ Id::KeyLeftStickRight, "left_stick_right", "Left Stick Right", "d", "right" },
|
||||
{ Id::KeyLeftStickUp, "left_stick_up", "Left Stick Up", "up", "none" },
|
||||
{ Id::KeyLeftStickDown, "left_stick_down", "Left Stick Down", "down", "none" },
|
||||
{ Id::KeyA, "a", "A", "space", "none" },
|
||||
{ Id::KeyB, "b", "B", "backspace", "none" },
|
||||
{ Id::KeyX, "x", "X", "left_shift", "none" },
|
||||
{ Id::KeyY, "y", "Y", "left_ctrl", "none" },
|
||||
{ Id::KeyLB, "lb", "LB", "q", "none" },
|
||||
{ Id::KeyRB, "rb", "RB", "e", "none" },
|
||||
{ Id::KeyDpadUp, "dpad_up", "D-pad Up", "up", "none" },
|
||||
{ Id::KeyDpadDown, "dpad_down", "D-pad Down", "down", "none" },
|
||||
{ Id::KeyDpadLeft, "dpad_left", "D-pad Left", "left", "none" },
|
||||
{ Id::KeyDpadRight, "dpad_right", "D-pad Right", "right", "none" },
|
||||
{ Id::KeyStart, "start", "Start", "return", "none" },
|
||||
{ Id::KeyBack, "back", "Back", "escape", "none" },
|
||||
{ Id::KeyRightStickUp, "right_stick_up", "Right Stick Up", "o", "none" },
|
||||
{ Id::KeyRightStickDown, "right_stick_down", "Right Stick Down", "l", "none" },
|
||||
{ Id::KeyRightStickLeft, "right_stick_left", "Right Stick Left", "k", "none" },
|
||||
{ Id::KeyRightStickRight, "right_stick_right", "Right Stick Right", "semicolon", "none" },
|
||||
{ Id::KeyLeftStickClick, "left_stick_click", "Left Stick Click", "z", "none" },
|
||||
{ Id::KeyRightStickClick, "right_stick_click", "Right Stick Click", "c", "none" },
|
||||
};
|
||||
size_t previous = size_t(Id::Vibration);
|
||||
for (const Default& d : defaults)
|
||||
{
|
||||
const Option& o = Info(d.id);
|
||||
const Option& o2 = Info(Id(size_t(d.id) + 1));
|
||||
CHECK(std::string_view(o.key) == d.key, "%s is %s", o.key, d.key);
|
||||
CHECK(std::string_view(o2.key) == std::string(d.key) + "_2", "%s: the second key is %s_2", o2.key, d.key);
|
||||
CHECK(o2.type == Type::Key, "%s: the second key is a Key option", o2.key);
|
||||
CHECK(std::string_view(o.label) == d.label && std::string_view(o2.label) == d.label, "%s: both keys carry the row's label", d.key);
|
||||
CHECK(o.defaultValue == double(Code(d.first)), "%s defaults to %s, not %s", o.key, d.first, FormatValue(o.id, o.defaultValue).c_str());
|
||||
CHECK(o2.defaultValue == double(Code(d.second)), "%s defaults to %s, not %s", o2.key, d.second, FormatValue(o2.id, o2.defaultValue).c_str());
|
||||
CHECK(size_t(d.id) == previous + 1, "%s follows the row before it", o.key);
|
||||
previous = size_t(d.id) + 1;
|
||||
}
|
||||
CHECK(size_t(Id::AsyncShaders) == previous + 1, "Advanced follows the keys");
|
||||
CHECK(EnvOverride(Id::KeyA) == nullptr, "nothing overrides a key");
|
||||
|
||||
// A scratch folder of our own.
|
||||
std::string pattern = (std::filesystem::temp_directory_path() / "speedbreaker-keys-XXXXXX").string();
|
||||
if (!mkdtemp(pattern.data()))
|
||||
{
|
||||
printf("can't make a scratch folder: %s\n", strerror(errno));
|
||||
return 2;
|
||||
}
|
||||
const std::filesystem::path dir = pattern;
|
||||
const std::filesystem::path file = dir / "settings.toml";
|
||||
|
||||
// The file: names in, in any case; "none" empties a key; anything else
|
||||
// keeps the default, SDL's spelling from the first build included.
|
||||
{
|
||||
std::ofstream f(file);
|
||||
f << "[keys]\n"
|
||||
"right_trigger = \"up\"\n"
|
||||
"left_trigger = \"Keypad_1\"\n"
|
||||
"left_stick_left_2 = \"none\"\n"
|
||||
"right_stick_left = \"K\"\n"
|
||||
"right_stick_up = \"LEFT_SHIFT\"\n"
|
||||
"a = \"Left Shift\"\n"
|
||||
"b = 42\n"
|
||||
"x = true\n"
|
||||
"y = \"\"\n"
|
||||
"lb = \"f1\"\n"
|
||||
"rb = \"left_gui\"\n";
|
||||
}
|
||||
CHECK(Load(file), "loads: %s", LastError().c_str());
|
||||
CHECK(GetInt(Id::KeyRightTrigger) == Code("up"), "\"up\": %d", GetInt(Id::KeyRightTrigger));
|
||||
CHECK(GetInt(Id::KeyLeftTrigger) == Code("keypad_1"), "a token in another case: %d", GetInt(Id::KeyLeftTrigger));
|
||||
CHECK(GetInt(Id::KeyLeftStickLeft2) == keynames::kNone, "\"none\" is no key: %d", GetInt(Id::KeyLeftStickLeft2));
|
||||
CHECK(GetInt(Id::KeyRightStickLeft) == Code("k"), "\"K\" is k: %d", GetInt(Id::KeyRightStickLeft));
|
||||
CHECK(GetInt(Id::KeyRightStickUp) == Code("left_shift"), "\"LEFT_SHIFT\" is left_shift: %d", GetInt(Id::KeyRightStickUp));
|
||||
CHECK(GetInt(Id::KeyA) == Code("space"), "SDL's spelling keeps the default");
|
||||
CHECK(GetInt(Id::KeyB) == Code("backspace"), "a number keeps the default");
|
||||
CHECK(GetInt(Id::KeyX) == Code("left_shift"), "a boolean keeps the default");
|
||||
CHECK(GetInt(Id::KeyY) == Code("left_ctrl"), "an empty name keeps the default");
|
||||
CHECK(GetInt(Id::KeyLB) == Code("q") && GetInt(Id::KeyRB) == Code("e"), "F1 and Cmd / Windows can't be bound from the file");
|
||||
CHECK(GetInt(Id::KeyStart) == Code("return") && GetInt(Id::KeyLeftStickRight2) == Code("right"), "a missing key keeps the default");
|
||||
CHECK(IsDefault(Id::KeyLB) && !IsDefault(Id::KeyRightTrigger) && !IsDefault(Id::KeyLeftStickLeft2), "IsDefault follows the file");
|
||||
|
||||
// Shown in the log and the bug report by name.
|
||||
CHECK(FormatValue(Id::KeyRightTrigger, GetValue(Id::KeyRightTrigger)) == "up", "FormatValue: %s",
|
||||
FormatValue(Id::KeyRightTrigger, GetValue(Id::KeyRightTrigger)).c_str());
|
||||
CHECK(FormatValue(Id::KeyX, Code("left_shift")) == "left_shift" && FormatValue(Id::KeyX, 0) == "none", "a token, or none");
|
||||
CHECK(FormatValue(Id::KeyA, 1) == "?" && FormatValue(Id::KeyA, 227) == "?" && FormatValue(Id::KeyA, 1e9) == "?" &&
|
||||
FormatValue(Id::KeyA, NAN) == "?" && FormatValue(Id::KeyA, -1) == "?", "not a key: ?");
|
||||
|
||||
// Set: a key of the table, or none; anything else is Invalid.
|
||||
CHECK(Set(Id::KeyA, Code("return")) == SetResult::Applied, "a key");
|
||||
CHECK(Set(Id::KeyA, Code("return")) == SetResult::Unchanged, "the same key again");
|
||||
CHECK(Set(Id::KeyA, keynames::kNone) == SetResult::Applied && GetInt(Id::KeyA) == 0, "none");
|
||||
CHECK(Set(Id::KeyA2, Code("return")) == SetResult::Applied && Set(Id::KeyA, Code("return")) == SetResult::Applied,
|
||||
"the same key in both cells of a row is allowed");
|
||||
CHECK(Set(Id::KeyA2, keynames::kNone) == SetResult::Applied, "and so is none in both");
|
||||
CHECK(Set(Id::KeyA, 1) == SetResult::Invalid, "1 is no key");
|
||||
CHECK(Set(Id::KeyA, 58) == SetResult::Invalid, "F1 is no key");
|
||||
CHECK(Set(Id::KeyA, 227) == SetResult::Invalid, "Left GUI is no key");
|
||||
CHECK(Set(Id::KeyA, keynames::kUnknown) == SetResult::Invalid && Set(Id::KeyA, 1e9) == SetResult::Invalid &&
|
||||
Set(Id::KeyA, -1) == SetResult::Invalid, "out of the table");
|
||||
CHECK(Set(Id::KeyA, double(Code("return")) + 0.5) == SetResult::Invalid, "not a whole number");
|
||||
CHECK(Set(Id::KeyA, NAN) == SetResult::Invalid, "NaN");
|
||||
CHECK(GetInt(Id::KeyA) == Code("return"), "invalid sets change nothing: %d", GetInt(Id::KeyA));
|
||||
CHECK(StepValue(Id::KeyA, Code("return"), 1) == double(Code("return")) && StepValue(Id::KeyA, Code("return"), -1) == double(Code("return")),
|
||||
"Left and Right do nothing to a key");
|
||||
CHECK(!IsDefault(Id::KeyA) && Reset(Id::KeyA) == SetResult::Applied && IsDefault(Id::KeyA) && GetInt(Id::KeyA) == Code("space"), "Reset");
|
||||
|
||||
// Save: every key by name, the comment, no environment variable; and
|
||||
// what it wrote loads back.
|
||||
CHECK(Save(file), "saves: %s", LastError().c_str());
|
||||
std::string text = ReadAll(file);
|
||||
size_t at = text.find("\n[keys]\n"), advanced = text.find("\n[advanced]\n");
|
||||
CHECK(at != std::string::npos && advanced != std::string::npos && at < advanced, "[keys] before [advanced]");
|
||||
std::string section = at == std::string::npos ? "" : text.substr(at, advanced == std::string::npos ? std::string::npos : advanced - at);
|
||||
CHECK(section.find("right_trigger = \"up\"\n") != std::string::npos, "saved by name");
|
||||
CHECK(section.find("left_trigger = \"keypad_1\"\n") != std::string::npos, "the table's case, not the file's");
|
||||
CHECK(section.find("left_stick_left_2 = \"none\"\n") != std::string::npos, "an emptied key");
|
||||
CHECK(section.find("right_stick_left = \"k\"\n") != std::string::npos, "lower case");
|
||||
CHECK(section.find("right_stick_right = \"semicolon\"\n") != std::string::npos, "a symbol key by its name");
|
||||
CHECK(section.find("right_stick_up = \"left_shift\"\n") != std::string::npos, "a modifier");
|
||||
CHECK(section.find("left_stick_left = \"a\"\n") != std::string::npos && section.find("dpad_left_2 = \"none\"\n") != std::string::npos,
|
||||
"defaults are written too, both keys");
|
||||
CHECK(section.find("# A key name such as \"w\"") != std::string::npos, "the comment says what goes here");
|
||||
CHECK(section.find("NFSMW_") == std::string::npos, "no environment variable on a key");
|
||||
CHECK(text.find("(NFSMW_VIBRATION)") != std::string::npos, "the other options keep theirs");
|
||||
CHECK(Load(file), "loads what it saved: %s", LastError().c_str());
|
||||
CHECK(GetInt(Id::KeyRightTrigger) == Code("up") && GetInt(Id::KeyLeftTrigger) == Code("keypad_1") &&
|
||||
GetInt(Id::KeyRightStickUp) == Code("left_shift") && GetInt(Id::KeyLeftStickLeft2) == 0, "round trip");
|
||||
CHECK(!Dirty(), "nothing changed since the save");
|
||||
|
||||
// The keys SDL names by a symbol are words here: nothing to escape.
|
||||
CHECK(Set(Id::KeyA, Code("backslash")) == SetResult::Applied && Set(Id::KeyB, Code("apostrophe")) == SetResult::Applied,
|
||||
"the backslash and apostrophe keys are keys");
|
||||
CHECK(Save(file), "saves them: %s", LastError().c_str());
|
||||
text = ReadAll(file);
|
||||
CHECK(text.find("\na = \"backslash\"\n") != std::string::npos && text.find("\nb = \"apostrophe\"\n") != std::string::npos, "by name");
|
||||
CHECK(Load(file), "and the file still parses: %s", LastError().c_str());
|
||||
CHECK(GetInt(Id::KeyA) == Code("backslash") && GetInt(Id::KeyB) == Code("apostrophe"), "round trip: %d %d", GetInt(Id::KeyA), GetInt(Id::KeyB));
|
||||
CHECK(GetInt(Id::KeyRightTrigger) == Code("up") && GetInt(Id::MasterVolume) == 100, "and the rest of the file survived");
|
||||
std::filesystem::remove_all(dir);
|
||||
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Graphics > Upscaling's values in settings.toml (user/settings.cpp,
|
||||
// linked as the game links it): "fsr1" (FSR 1, v0.1.5) read, saved and read
|
||||
// back, next to "bicubic" and "linear"; the menu's order (Bicubic, Bilinear,
|
||||
// FSR 1); NFSMW_SCALING=fsr1 for one run, never saved; a token this version
|
||||
// doesn't know (a later version's) falls back to the default, Bicubic; and
|
||||
// Sharpening's default, 0.3, unchanged.
|
||||
// The argument is a scratch folder (settings.toml is written there).
|
||||
//
|
||||
// Build by hand (from the repo root):
|
||||
// c++ -std=c++20 -O2 -UNDEBUG -I runtime tests/settings_scaling_test.cpp runtime/user/settings.cpp -o build/settings_scaling_test
|
||||
// build/settings_scaling_test build/settings_scaling_scratch
|
||||
#include <user/settings.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failures = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
s_failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fprintf(stderr, "\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using settings::Id;
|
||||
using settings::ScalingMode;
|
||||
|
||||
std::string Read(const std::filesystem::path& path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::stringstream text;
|
||||
text << in.rdbuf();
|
||||
return text.str();
|
||||
}
|
||||
|
||||
void Write(const std::filesystem::path& path, const std::string& text)
|
||||
{
|
||||
std::ofstream(path, std::ios::binary) << text;
|
||||
}
|
||||
|
||||
int32_t Scaling() { return settings::GetInt(Id::Scaling); }
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
unsetenv("NFSMW_SCALING");
|
||||
unsetenv("NFSMW_SHARPEN");
|
||||
const std::filesystem::path dir = argc > 1 ? argv[1] : "settings_scaling_scratch";
|
||||
std::filesystem::remove_all(dir);
|
||||
std::filesystem::create_directories(dir);
|
||||
const std::filesystem::path file = dir / "settings.toml";
|
||||
|
||||
// The option: three entries in menu order, Bicubic the default.
|
||||
const settings::Option& o = settings::Info(Id::Scaling);
|
||||
CHECK(o.entries.size() == 3, "%zu entries", o.entries.size());
|
||||
if (o.entries.size() == 3)
|
||||
{
|
||||
CHECK(o.entries[0].value == int32_t(ScalingMode::Bicubic) && std::string(o.entries[0].token) == "bicubic", "first: bicubic");
|
||||
CHECK(o.entries[1].value == int32_t(ScalingMode::Linear) && std::string(o.entries[1].token) == "linear", "second: linear");
|
||||
CHECK(o.entries[2].value == int32_t(ScalingMode::Fsr1) && std::string(o.entries[2].token) == "fsr1", "third: fsr1");
|
||||
}
|
||||
CHECK(settings::DefaultValue(Id::Scaling) == double(ScalingMode::Bicubic), "default bicubic");
|
||||
CHECK(settings::FormatValue(Id::Scaling, double(ScalingMode::Fsr1)) == "FSR 1", "label %s", settings::FormatValue(Id::Scaling, 2).c_str());
|
||||
CHECK(settings::StepValue(Id::Scaling, double(ScalingMode::Linear), 1) == double(ScalingMode::Fsr1), "Bilinear, right: FSR 1");
|
||||
CHECK(settings::StepValue(Id::Scaling, double(ScalingMode::Fsr1), -1) == double(ScalingMode::Linear), "FSR 1, left: Bilinear");
|
||||
|
||||
// No file: the defaults.
|
||||
CHECK(settings::Load(file), "load without a file: %s", settings::LastError().c_str());
|
||||
CHECK(Scaling() == int32_t(ScalingMode::Bicubic), "no file: %d", Scaling());
|
||||
CHECK(std::fabs(settings::GetFloat(Id::Sharpening) - 0.3f) < 1e-6f, "Sharpening's default 0.3: %.3f", settings::GetFloat(Id::Sharpening));
|
||||
|
||||
// Chosen in the menu, saved, read back.
|
||||
CHECK(settings::Set(Id::Scaling, double(ScalingMode::Fsr1)) == settings::SetResult::Applied, "set FSR 1");
|
||||
CHECK(Scaling() == int32_t(ScalingMode::Fsr1), "in effect at once: %d", Scaling());
|
||||
CHECK(settings::Save(file), "save: %s", settings::LastError().c_str());
|
||||
const std::string saved = Read(file);
|
||||
CHECK(saved.find("scaling = \"fsr1\"") != std::string::npos, "saved as a token:\n%s", saved.c_str());
|
||||
CHECK(settings::Set(Id::Scaling, double(ScalingMode::Linear)) == settings::SetResult::Applied, "set bilinear");
|
||||
CHECK(settings::Load(file) && Scaling() == int32_t(ScalingMode::Fsr1), "read back: %d", Scaling());
|
||||
|
||||
// Each token by hand.
|
||||
for (auto [token, value] : { std::pair{ "bicubic", ScalingMode::Bicubic }, std::pair{ "linear", ScalingMode::Linear }, std::pair{ "fsr1", ScalingMode::Fsr1 } })
|
||||
{
|
||||
Write(file, std::string("[video]\nscaling = \"") + token + "\"\n");
|
||||
CHECK(settings::Load(file) && Scaling() == int32_t(value), "%s: %d", token, Scaling());
|
||||
}
|
||||
// A later version's value: the default, and the file isn't rewritten by loading it.
|
||||
Write(file, "[video]\nscaling = \"fsr3\"\n");
|
||||
settings::Load(file);
|
||||
CHECK(Scaling() == int32_t(ScalingMode::Bicubic), "unknown token: %d", Scaling());
|
||||
CHECK(Read(file).find("fsr3") != std::string::npos, "the file is left as it was");
|
||||
|
||||
// NFSMW_SCALING=fsr1 for this run: in effect, never saved.
|
||||
Write(file, "[video]\nscaling = \"linear\"\n");
|
||||
setenv("NFSMW_SCALING", "fsr1", 1);
|
||||
CHECK(settings::Load(file), "load with the environment");
|
||||
CHECK(Scaling() == int32_t(ScalingMode::Fsr1), "environment: %d", Scaling());
|
||||
CHECK(settings::EnvOverride(Id::Scaling) != nullptr, "the menu shows the override");
|
||||
CHECK(settings::GetStoredValue(Id::Scaling) == double(ScalingMode::Linear), "stored: %g", settings::GetStoredValue(Id::Scaling));
|
||||
CHECK(settings::Save(file) && Read(file).find("scaling = \"linear\"") != std::string::npos, "saved: the file's value:\n%s", Read(file).c_str());
|
||||
unsetenv("NFSMW_SCALING");
|
||||
|
||||
std::filesystem::remove_all(dir);
|
||||
if (s_failures)
|
||||
{
|
||||
printf("%d check(s) FAILED\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("all checks 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