1 Commits
Author SHA1 Message Date
ad14799d14 SpeedBreaker v0.1.5
Co-authored-by: love-gamer <340996855+love-gamer@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:30:22 +00:00
36 changed files with 6972 additions and 242 deletions

No files matched your search

+32
View File
@@ -4,6 +4,38 @@ 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.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
View File
@@ -66,7 +66,7 @@ endif()
# apps' versions, the SteamOS bundles' VERSION file and the release tag
# (v<version>). CHANGELOG.md has an entry for each version (the in-game
# "What's new" note is built from it).
project(SpeedBreaker VERSION 0.1.4 LANGUAGES C CXX)
project(SpeedBreaker VERSION 0.1.5 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
+23
View File
@@ -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.
+18 -9
View File
@@ -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
@@ -346,7 +346,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 +355,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 +363,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 +412,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 +424,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 +558,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.5-<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 +757,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 |
+1
View File
@@ -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
+3
View File
@@ -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
+14
View File
@@ -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,
+65 -18
View File
@@ -316,14 +316,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 +372,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 +417,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
@@ -459,7 +505,7 @@ namespace gpu::bars
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 2, a backdrop's 8
float posScale[4]{};
};
@@ -482,13 +528,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;
}
+130 -87
View File
@@ -7833,7 +7833,105 @@ void main()
// 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.
// band, drawn right after it; and the black around a full-band
// backdrop (the results screen), cleared right after it, at 16:9 too.
// NFSMW_BAR_FILL=0 turns all the edge fills off (an A/B switch).
// planEdgeFills: the plan for a front-end draw (its registers, what
// the rules below did, a reader for its ePolys): with `bars`,
// PlanFill's bars and black frames first (wide and tall screens
// only), then PlanBackdrop's full-band picture (every screen shape).
// logEdgeFill: what a plan does, once per kind, screen shape and
// outcome, in every log (a tester's too).
static const bool barFill = [] { const char* v = std::getenv("NFSMW_BAR_FILL"); return !(v && v[0] == '0'); }();
auto planEdgeFills = [&](const float* c, float feY, float aspectX, float aspectY, bool feShader, bool leftAlone, bool bars) {
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,
.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 = bars ? gpu::bars::PlanFill(draw, read) : gpu::bars::Plan{};
// 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);
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 gpu::bars::Box& box = barPlan.box;
const uint64_t key = (0x36ull << 56) | (uint64_t(kind) << 40) | (uint64_t(barPlan.rectCount) << 36) |
(uint64_t(std::lround(aspectX * 4096.0f)) << 18) | uint64_t(std::lround(aspectY * 4096.0f));
static uint64_t loggedKey[5] = { ~0ull, ~0ull, ~0ull, ~0ull, ~0ull };
if (loggedKey[kind] != key)
{
loggedKey[kind] = key;
char fills[128];
if (barPlan.rectCount)
snprintf(fills, sizeof(fills), "fills %u rectangle(s) %s, first [%d,%d)x[%d,%d) (host px)", barPlan.rectCount,
backdrop ? "outside it (past the 16:9 band, and the band's overscan rows)" : "past the 16:9 band", barPlan.rects[0].x0,
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 outside it)" : blackout ? "a black frame"
: kind == uint32_t(gpu::bars::Fill::RightBar) ? "the right vid-cam bar" : "the left vid-cam bar",
box.x0, box.x1, box.y0, box.y1, barPlan.argb, aspectX, aspectY, fills);
}
// NFSMW_LOG_FRONTEND=1: the first frame of each run of frames
// with a match, bars and black frames apart (one run per vid-cam
// scene and per cut; any other is a draw this should not take).
static uint64_t lastBarFrame[3] = { ~0ull - 1, ~0ull - 1, ~0ull - 1 };
uint64_t& last = lastBarFrame[backdrop ? 2 : blackout ? 1 : 0];
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, 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)
{
@@ -8135,94 +8233,12 @@ void main()
// 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'); }();
// 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, true);
logEdgeFill(barPlan, s, sv);
}
// The edge candidates (above) that still stop short of
// the screen's edge after the rules, each distinct line
@@ -8361,6 +8377,33 @@ void main()
}
}
}
else if (barFill)
{
// 16:9 (or a 16:9 picture letterboxed on another shape): none of
// the rules above has anything to do, and no bar or black frame
// needs a fill, but a full-band backdrop (the results screen)
// leaves the race showing in the band's overscan rows above and
// below it: those are cleared to black (gpu::bars::OverscanRows).
// A front-end draw as above (the front-end matrix in c0..c3,
// ePolys at fetch constant 95, read by the vertex shader); nothing
// is measured, and only one quad over a large t0 gets as far as
// reading its vertices (gpu::bars::BackdropCandidate).
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, false);
logEdgeFill(barPlan, s, sv);
}
}
}
// NFSMW_LOG_DRAWS=<seconds>: every draw of one frame (target, viewport,
// scissor, textures, shaders), consecutive duplicates folded.
if (s_logDrawsNow)
@@ -8600,7 +8643,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
View File
@@ -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
View File
@@ -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
+142
View File
@@ -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;
}
}
+19
View File
@@ -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.
+16
View File
@@ -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.
+2656
View File
File diff suppressed because it is too large. Load diff
+1199
View File
File diff suppressed because it is too large. Load diff
+327 -10
View File
@@ -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
+25 -2
View File
@@ -638,8 +638,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 +729,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 +745,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 +760,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 +840,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 +852,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);
}
}
+6
View File
@@ -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
+109
View File
@@ -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
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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;
}
}
+180
View File
@@ -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;
}
}
+33
View File
@@ -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();
}
+533 -32
View File
@@ -19,6 +19,8 @@
#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>
@@ -102,12 +104,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{};
@@ -1362,8 +1372,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 +1558,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, &region);
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)
{
@@ -2762,6 +3255,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 +3351,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 +3477,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 +3504,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 +3593,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);
@@ -3275,6 +3771,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);
+77
View File
@@ -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;
}
}
+1 -1
View File
@@ -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'],
+38 -1
View File
@@ -44,8 +44,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)
@@ -137,6 +162,18 @@ 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.
find_package(SDL3 REQUIRED CONFIG)
sb_test(key_names_test)
target_link_libraries(key_names_test PRIVATE SDL3::SDL3)
# 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)
+65 -3
View File
@@ -16,10 +16,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 +55,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 +183,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 } })
@@ -281,7 +332,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 +353,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)
{
+185 -33
View File
@@ -691,17 +691,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 +723,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 +810,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 +832,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");
{
@@ -863,7 +910,7 @@ static void BackdropTests()
return BlackoutRects(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 +924,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 +994,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 +1074,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");
+276
View File
@@ -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;
}
+124
View File
@@ -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;
}
+89
View File
@@ -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;
}
+216
View File
@@ -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;
}
+126
View File
@@ -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;
}