mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-18 00:02:34 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2cb93f2d | ||
|
|
ad14799d14 | ||
|
|
d4c206ab98 |
No files matched your search
+153
@@ -4,6 +4,159 @@ What changed in each SpeedBreaker release, newest first. Each release's notes on
|
||||
its entry here, and the game shows the entry once, the first time a new version starts (offline:
|
||||
it's built into the game).
|
||||
|
||||
## v0.1.6
|
||||
|
||||
### What's new
|
||||
|
||||
- No more lost sound followed by a freeze. If your audio output stopped taking sound for a while,
|
||||
the game's sound stopped for good, and the game later froze, for example on the save screen
|
||||
after an event. Every version so far had this. Now the game keeps running, unheard, and the
|
||||
sound comes back as soon as the output takes audio again. Thanks to Omega3komma3 for the report
|
||||
and the two bug reports (GitHub issue #20).
|
||||
- No more rectangular borders in the Showcase (where you rotate and zoom around a car, in the car
|
||||
lot, the customize shop and on a Blacklist rival's car) or around a Blacklist rival's bio. The
|
||||
Showcase darkens the edges of the garage, and that darkening stopped short of the edges of the
|
||||
screen; the bio's black panel did the same. On a 16:9 screen that left a thin border, as on an
|
||||
Xbox 360, whose TV hid it past the edge of its screen; on a taller screen such as a MacBook or a
|
||||
Steam Deck the Showcase's border was wide at the top and bottom. Both now reach the edges of the
|
||||
screen, on every screen shape. Thanks to DoubleDips, who reported it, and m9cy884mv6-a11y, who
|
||||
confirmed it on the Steam Deck (GitHub issue #19).
|
||||
- Mac: in a window, the picture now runs up under a transparent title bar, and the window's
|
||||
buttons show only while you move the mouse over it. Thanks to aleszczynskig, who wrote it
|
||||
(GitHub pull request #23).
|
||||
- Mac: on a MacBook Pro's 120 Hz ProMotion screen, switching fullscreen on or off, or resizing the
|
||||
window, could close the game now and then (since v0.1.4; its crash report named "mutex lock
|
||||
failed"). It no longer does.
|
||||
- A quit asked for from outside the game is now a normal quit: the stop signal a launcher sends,
|
||||
Ctrl+C, or closing the terminal the game was started from. Your settings are saved, as with
|
||||
Quit Game. Closing its terminal used to end it on the spot, and every session, even one ended
|
||||
with Quit Game, left a crash marker in its logs folder until the next start.
|
||||
|
||||
### More about it
|
||||
|
||||
- The sound: the log now says when the audio output stopped taking audio and when it came back,
|
||||
and a hang report includes the audio's state.
|
||||
- The borders: the few black frames between two shots of the career's opening scene are now
|
||||
black to the edges of the screen too.
|
||||
- The title bar: the window's saved size now takes in the title bar, so a window from an earlier
|
||||
version opens a title bar shorter, with the same picture.
|
||||
- Quitting: a crash still saves a crash report, and the next start still offers it. A session
|
||||
that ended without quitting (stopped by force, or the power going) now leaves its marker, and
|
||||
the next start's log says so.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.5
|
||||
|
||||
### What's new
|
||||
|
||||
- Settings > Controls: every keyboard control can be reassigned, and each control has two keys.
|
||||
Choose a row, Left or Right for its first or second key, press A or Enter, then the new key; Y
|
||||
clears a key, X puts a row's defaults back, Reset page the whole table. The default keys are
|
||||
unchanged (the arrows steer as well as A and D), and the left stick click gets a key of its own,
|
||||
Z. Thanks to love-gamer, who wrote it (GitHub pull request #12).
|
||||
- Settings > Graphics > Upscaling has a third choice, FSR 1 (AMD FidelityFX Super Resolution 1),
|
||||
for a window much larger than the Internal Resolution (1x on a 1440p, MacBook or 4K screen):
|
||||
curves, wires and diagonals stay smooth where bicubic shows steps, such as lamp posts against
|
||||
the sky or the speedometer's needle. It is used only when the picture is enlarged. Bicubic stays
|
||||
the default. Not on iPhone and iPad.
|
||||
- The race results screen no longer shows a thin strip of the race above and below it. An Xbox 360
|
||||
draws that strip too, and a TV of the time hid it past the edge of its screen; today's screens
|
||||
show the whole picture, so it is now black, on every screen shape (Steam Deck, 16:9 and others).
|
||||
|
||||
### More about it
|
||||
|
||||
- The Controls page: the keys are saved by name in settings.toml's `[keys]` table (`w`,
|
||||
`left_shift`, `none`). F1, Esc (it cancels a wait) and the Cmd and Windows keys can't be
|
||||
assigned. If you go back to v0.1.4 and change a setting there, it forgets the Controls page's
|
||||
keys.
|
||||
- FSR 1: Sharpening sets its sharpening pass too (0.3 of its full strength by default). Bicubic
|
||||
stays the default because FSR 1 makes foliage look a little painted, costs more GPU time (about
|
||||
1 ms a frame on an M1 Pro at 1080p, 0.1 ms on a Steam Machine), and with Internal Resolution on
|
||||
Auto most screens aren't enlarged at all.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.4
|
||||
|
||||
### What's new
|
||||
|
||||
- The picture now matches a real Xbox 360 on a TV: slightly deeper shadows and slightly brighter
|
||||
mid-tones. The game sets a gamma ramp for the console's TV output, and SpeedBreaker never
|
||||
applied it until now. The new Settings > Display > Xbox 360 Display Gamma is on by default, also
|
||||
after an update: to get the previous look back, turn it off.
|
||||
- Settings > Display > Brightness: lighter or darker shadows and mid-tones, from -5 to +5, a
|
||||
gentle step at a time, like a TV's brightness. Black and white stay put, and only the game's
|
||||
picture changes, never the menus. At 0, the default, it leaves the picture as it is.
|
||||
- The game's own Brightness slider (pause menu > Options > Video) now changes the picture as you
|
||||
move it, as on an Xbox 360: the game writes a new gamma ramp for each step, and SpeedBreaker
|
||||
applies it. It needs Settings > Display > Xbox 360 Display Gamma, which is on by default; with
|
||||
that off it does nothing, as before. If you moved the slider in an earlier version and the game
|
||||
has saved since, that brightness now takes effect: a slider left at maximum gives a much
|
||||
brighter, washed-out picture, as a 360 with that save would show. Set the slider back to the
|
||||
middle, or press Y (Defaults) and choose Yes. Thanks to m9cy884mv6-a11y for the report (GitHub
|
||||
issue #18).
|
||||
- Keyboard: the right stick has keys, O and L for up and down, K and ; for left and right. The
|
||||
game shifts gears with the right stick, so you can now shift from the keyboard and finish a
|
||||
drag race. Thanks to love-gamer, who wrote it (GitHub pull request #11).
|
||||
- Settings > Quit Game, at the bottom of the menu after Close: quits SpeedBreaker with a controller,
|
||||
the mouse or the keyboard, so a fullscreen game no longer needs a keyboard shortcut to leave. It
|
||||
asks first, with Cancel chosen, so no single press quits. Not on iPhone and iPad, where apps are
|
||||
closed from the app switcher.
|
||||
- Settings > Display > Fullscreen now decides how the game starts, every time. On a Mac, the
|
||||
window's green button (or Ctrl+Cmd+F) switches fullscreen until you quit, so the game no longer
|
||||
sometimes starts fullscreen and sometimes not.
|
||||
- Mac: the mouse pointer no longer sits over the game. It hides after two seconds without moving
|
||||
while you play, and comes back as soon as you move the mouse; in the settings menu and the
|
||||
installer it stays. Thanks to DoubleDips for the report (GitHub issue #15).
|
||||
- Mac: smoother frame pacing. The game's 60 Hz clock now keeps time on macOS (it ran up to 9 ms
|
||||
late), and on a MacBook Pro's 120 Hz ProMotion screen each frame now follows the last by 1/60 s
|
||||
instead of arriving whenever the GPU finished it. Other screens show frames as before, and the
|
||||
timing turns itself off if a screen doesn't keep it.
|
||||
- On slow GPUs, below 15 fps, the game no longer runs a few percent slow: its clock now keeps up
|
||||
with the screen (a laptop with Intel graphics at Internal Resolution 1.5x, about 12 fps, ran 4%
|
||||
slow).
|
||||
- Changing the window's shape while a movie plays (for example the green button during the
|
||||
opening movie) no longer shows a stray "EVENT HANDLER" label above the picture.
|
||||
|
||||
### More about it
|
||||
|
||||
- The gamma ramp: the game sets it when it starts, and the console applies it to every frame on
|
||||
its way to the TV. Two real Xbox 360 recordings of the intro movie and the title screen match it
|
||||
to within half a level, against about two levels without it. With Xbox 360 Display Gamma off
|
||||
and Brightness at 0, the picture is exactly the one of earlier versions.
|
||||
- The game's slider: each step writes a new ramp, which shows on that frame. At its lowest, its
|
||||
default and its highest, the picture's average brightness moves by about -13, +1 and +24 levels
|
||||
(of 255) against Xbox 360 Display Gamma off. The game keeps the slider in your alias's save the
|
||||
next time it saves the profile; without an alias it starts at the middle every time.
|
||||
- The right-stick keys: holding both keys of a pair centres the stick. No other key changed.
|
||||
- Fullscreen: a switch the game didn't ask for (on a Mac, the green button or Ctrl+Cmd+F) lasts
|
||||
until you quit and never changes the setting. While the window differs from the setting,
|
||||
Settings > Display > Fullscreen says NOW FULLSCREEN or NOW WINDOWED; choosing On or Off there
|
||||
saves it and switches the window.
|
||||
- The pointer hides only over the game's window, so in a window it still shows over the desktop,
|
||||
and the mouse is never captured.
|
||||
- The pacing: macOS let the thread that drives the game's 60 Hz clock wake up to 9 ms late; it
|
||||
now runs with real-time priority. And a ProMotion screen shows each frame as soon as it's ready,
|
||||
so frames that took a little longer to draw arrived unevenly: SpeedBreaker now asks for each
|
||||
frame to be shown at a set time after its tick. Measured on a MacBook Pro (M1 Pro) in
|
||||
fullscreen: 99.9% of frames 1/60 s apart in menus and about 94% while driving (99-100% on the
|
||||
open road, less in the heaviest scenes, where a frame takes longer than the time allowed),
|
||||
against about a quarter without it, for about 5 ms more latency.
|
||||
- The slow clock: when a frame took longer than six screen refreshes, SpeedBreaker started its
|
||||
count over and dropped up to three refreshes of game time each time; below 15 fps that happened
|
||||
every second or so. That time is now given back over the next frames. Above 20 fps nothing
|
||||
changes. Checked by replaying frame timings recorded on that laptop: game time went from 96% to
|
||||
99.9% of real time.
|
||||
- The label: it belongs to a menu the game keeps just above the picture. After a window change
|
||||
mid-movie the game kept the old window's layout for it, so the label slipped into view; it now
|
||||
gets the new layout at once.
|
||||
- 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.3
|
||||
|
||||
### What's new
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ endif()
|
||||
# apps' versions, the SteamOS bundles' VERSION file and the release tag
|
||||
# (v<version>). CHANGELOG.md has an entry for each version (the in-game
|
||||
# "What's new" note is built from it).
|
||||
project(SpeedBreaker VERSION 0.1.3 LANGUAGES C CXX)
|
||||
project(SpeedBreaker VERSION 0.1.6 LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
AMD FidelityFX Super Resolution 1 (FSR 1): ffx_a.h and ffx_fsr1.h
|
||||
https://github.com/GPUOpen-Effects/FidelityFX-FSR (commit a21ffb8f6c13233ba336352bdff293894c706575, files ffx-fsr/ffx_a.h and ffx-fsr/ffx_fsr1.h)
|
||||
Vendored unmodified in runtime/thirdparty/fsr1; their shader code is compiled into SpeedBreaker.
|
||||
|
||||
Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -69,15 +69,16 @@ and a settings menu you can open mid-race.
|
||||
speed, or *Auto*, which drops to 30 only while a device is hot or can't hold 60.
|
||||
- **The sun glare is back.** The low sun glows in open sky and fades behind buildings, as it did
|
||||
on the console.
|
||||
- **The original look:** sunlit roads and shadows as on the console, correct colours on Apple
|
||||
devices, proper car paint and mirror reflections, and distant textures that don't shimmer. Anti-aliasing, sharpening and up to 16x
|
||||
anisotropic filtering are also available.
|
||||
- **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,
|
||||
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.
|
||||
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
|
||||
carries on where it stopped.
|
||||
- **One-button bug reports.** Logs, settings and a screenshot go into one zip file. Nothing is
|
||||
@@ -268,9 +269,9 @@ put your `.iso` in your home folder, Downloads, Desktop, Documents or Games (or
|
||||
and run `speedbreaker.sh`: always start the game with it, since it picks the libraries the
|
||||
program in `lib/` needs (started on its own, the program fails on most systems with a message
|
||||
such as `GLIBC_2.44 not found`). The game starts in a window; Display > Fullscreen switches to full
|
||||
screen. The installer puts the game in `~/.local/share/speedbreaker/game`, or on a drive, or in
|
||||
any folder you pick with *Choose a folder...* (it needs a file chooser portal or zenity; without
|
||||
one, use `--dest`, below).
|
||||
screen and decides how the game starts. The installer puts the game in
|
||||
`~/.local/share/speedbreaker/game`, or on a drive, or in any folder you pick with *Choose a
|
||||
folder...* (it needs a file chooser portal or zenity; without one, use `--dest`, below).
|
||||
|
||||
On a laptop with two GPUs, SpeedBreaker uses the dedicated graphics card; with NVIDIA's driver the
|
||||
launcher also turns on PRIME render offload. That hasn't been confirmed on a real two-GPU laptop
|
||||
@@ -299,7 +300,10 @@ its name>`, e.g. `NFSMW_GPU=intel %command%` in Steam's launch options.
|
||||
upload it and can remove its files from the Mac to save space.
|
||||
|
||||
**Settings:** **F1** or **Cmd+,**, or Back + Start on a controller. The game starts in a window;
|
||||
Display > Fullscreen switches to full screen. SpeedBreaker needs macOS 15 (Sequoia) or later.
|
||||
Display > Fullscreen switches to full screen and decides how the game starts from then on. The
|
||||
window's green button (or Ctrl+Cmd+F) switches only until you quit. In a window, the picture
|
||||
runs up under a transparent title bar, and the window's buttons show while you move the mouse
|
||||
over it. While you play, the mouse pointer hides after two seconds without moving. SpeedBreaker needs macOS 15 (Sequoia) or later.
|
||||
|
||||
### iPhone and iPad
|
||||
|
||||
@@ -343,7 +347,7 @@ is B, Square is X and Triangle is Y.
|
||||
|
||||
| Xbox 360 control | Controller | Keyboard |
|
||||
|---|---|---|
|
||||
| Left stick | Left stick | A / D steer (the arrow keys also move it) |
|
||||
| Left stick | Left stick | A / D or Left / Right steer; Up / Down push it up and down |
|
||||
| Right trigger | Right trigger | W |
|
||||
| Left trigger | Left trigger | S |
|
||||
| A / B / X / Y | Bottom / right / left / top face button | Space / Backspace / Left Shift / Left Ctrl |
|
||||
@@ -351,13 +355,20 @@ is B, Square is X and Triangle is Y.
|
||||
| D-pad | D-pad | Arrow keys |
|
||||
| Start | Start / Menu / Options | Enter |
|
||||
| Back | Back / View / Create | Esc |
|
||||
| Right stick, stick clicks | Same on the controller | C (right stick click) |
|
||||
| Right stick | Right stick | O / L up and down, K / ; left and 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.
|
||||
- Because Back + Start opens the settings, the game sees Back a moment late: a tap arrives when
|
||||
you let go, a hold after a quarter of a second.
|
||||
- There is no button remapping and no mouse control in game.
|
||||
- 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.
|
||||
- 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:**
|
||||
|
||||
@@ -374,7 +385,9 @@ is B, Square is X and Triangle is Y.
|
||||
The game pauses while the settings are open. The D-pad (or arrow keys) chooses and changes a row,
|
||||
LB and RB (or Q and E) turn pages, and B (or Esc) closes the menu. On a touchscreen, tap and
|
||||
drag. The mouse works too. Every change applies at once and is saved when you close the menu.
|
||||
Each page has a *Reset page* button.
|
||||
The bottom row has *Reset page* (the page's defaults), *Close*, and *Quit Game*, which asks
|
||||
first, with *Cancel* chosen (not on iPhone and iPad, where apps are closed from the app
|
||||
switcher).
|
||||
|
||||
<details>
|
||||
<summary><b>All settings and their defaults</b></summary>
|
||||
@@ -383,19 +396,25 @@ Each page has a *Reset page* button.
|
||||
|
||||
| Option | Choices | Default |
|
||||
|---|---|---|
|
||||
| Fullscreen | On / Off | Off: a 1280x720 window you can resize. Always on in SteamOS Game Mode. Not shown on iPhone and iPad, which are always full screen. |
|
||||
| Fullscreen | On / Off | Off: a 1280x720 window you can resize. The game always starts as set here; on a Mac, the window's green button switches only until you quit, and the row then says NOW FULLSCREEN or NOW WINDOWED. Always on in SteamOS Game Mode. Not shown on iPhone and iPad, which are always full screen. |
|
||||
| V-Sync | On / Off | On. Off can tear, but shows frames sooner. |
|
||||
| Frame Rate | Auto / 60 fps / 30 fps | 60 fps on most devices. 30 fps keeps the game at full speed. Auto runs at 60, or at a steady 30 while the device is hot or can't hold 60. |
|
||||
| Screen Resolution | Full / 75% | Full. iPhone and iPad only: 75% is slightly softer and saves graphics work. |
|
||||
| Aspect Ratio | Fill Screen / 16:9 | Fill Screen: wider screens get a wider view; 4:3, 3:2 and 16:10 screens (the Steam Deck, many Macs) a taller one. 16:9 always shows the classic picture with bars. |
|
||||
| Brightness | -5 to +5 | 0, the picture as it is. Each step makes the shadows and mid-tones a little lighter or darker, like a TV's brightness; black and white stay put, and the menus never change. |
|
||||
| Xbox 360 Display Gamma | On / Off | On: the gamma ramp the game sets for a real Xbox 360's TV output, with slightly deeper shadows and slightly brighter mid-tones. Off: the flatter picture of v0.1.3 and earlier. |
|
||||
|
||||
The game's own Brightness slider, in the pause menu's Options > Video, works too while Xbox 360
|
||||
Display Gamma is on (with it off, it does nothing). Its middle is the default look; Y (Defaults),
|
||||
then Yes, puts it back.
|
||||
|
||||
**Graphics**
|
||||
|
||||
| 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. |
|
||||
@@ -406,6 +425,10 @@ game while its window isn't in front.
|
||||
**Input:** Stick Deadzone (8%; raise it if the car steers by itself), Trigger Deadzone (2%), and
|
||||
Vibration (On).
|
||||
|
||||
**Controls:** the keyboard keys for each Xbox 360 control, two per control, as in the table under
|
||||
[Controls](#controls). The keys are saved by name in `settings.toml`'s `[keys]` table (`w`,
|
||||
`left_shift`, `keypad_1`, or `none`; `runtime/user/key_names.h` lists them).
|
||||
|
||||
**Advanced:** Asynchronous Shaders (On: an object is skipped for a frame or two while its shader
|
||||
compiles, instead of the game stuttering), Performance Overlay (Off: fps, frame times, and CPU
|
||||
and GPU time per frame), **Save Bug Report**, and **Check for Updates** (the only thing that goes
|
||||
@@ -417,6 +440,10 @@ online, and only when you press it). The page also shows which build you're runn
|
||||
|
||||
- **Run your display at 60 Hz.** On a Steam Deck OLED, set the refresh rate to 60 Hz and leave
|
||||
Steam's Framerate Limit off.
|
||||
- **MacBook Pro with a 120 Hz (ProMotion) screen:** since v0.1.4 SpeedBreaker asks for each frame
|
||||
to reach the screen 1/60 s after the last. Measured in fullscreen on an M1 Pro: 99.9% of frames
|
||||
in menus and about 94% while driving (fewer in the heaviest scenes). Other screens show frames
|
||||
as before.
|
||||
- **Start with Auto.** If busy scenes drop below 60, set Internal Resolution one step lower, or
|
||||
let Frame Rate *Auto* fall back to a steady 30.
|
||||
- **30 fps is a real option.** It's a steady 30 at full game speed, and the smooth choice in the
|
||||
@@ -499,6 +526,17 @@ says so: connect it and start the game again.
|
||||
|
||||
**The car steers by itself.** Raise Settings > Input > Stick Deadzone.
|
||||
|
||||
**The picture looks different since v0.1.4.** v0.1.4 applies the gamma ramp the game sets for a
|
||||
real Xbox 360's TV output: slightly deeper shadows and slightly brighter mid-tones, as on the
|
||||
console. For the previous, flatter picture, turn off Settings > Display > Xbox 360 Display Gamma.
|
||||
For lighter or darker shadows either way, use Settings > Display > Brightness. If the picture is
|
||||
much brighter and washed out, the game's own Brightness slider (pause menu > Options > Video) was
|
||||
probably moved up in an earlier version, where it did nothing, and saved: it now takes effect. Set
|
||||
the slider back to the middle, or press Y (Defaults) and choose Yes.
|
||||
|
||||
**How do I quit in fullscreen without a keyboard?** Open the settings and choose *Quit Game* on
|
||||
the bottom row; it asks first.
|
||||
|
||||
**The game ignores my touches on iPhone or iPad.** The game needs a controller or a keyboard.
|
||||
Touch only works in SpeedBreaker's own screens; a three-finger tap opens the settings.
|
||||
|
||||
@@ -521,7 +559,7 @@ SpeedBreaker folder instead: `data/`, `data/game` and `cache/`.
|
||||
2. A notice says where the zip file went: your **Desktop** (on SteamOS, pick it up in Desktop
|
||||
Mode, or in the Desktop app on a Steam Frame), the **SpeedBreaker** folder in the Files app on
|
||||
iPhone and iPad, or the data folder above when there is no Desktop. Its name says which
|
||||
version you were running, for example `speedbreaker-bug-report-v0.1.3-<date>_<time>.zip`.
|
||||
version you were running, for example `speedbreaker-bug-report-v0.1.6-<date>_<time>.zip`.
|
||||
3. Open an [issue][issues], attach the zip, and say what you were doing, what you expected, what
|
||||
happened, and roughly how long into the session it happened.
|
||||
|
||||
@@ -558,7 +596,9 @@ native program for x86-64 or arm64. A runtime written for this project stands in
|
||||
- **Resolution:** the 3D scene renders at 1x, 1.5x, 2x or 3x; the bloom and haze stay at the original
|
||||
resolution, so the game keeps its colour grade.
|
||||
- **Frame pacing:** the game's clock locks to the display's measured refresh (58.5 to 61.5 Hz),
|
||||
one game step per refresh. 30 fps runs two game steps per frame.
|
||||
one game step per refresh. 30 fps runs two game steps per frame. On a Mac the clock's thread
|
||||
runs with real-time priority, and on a 120 Hz ProMotion screen each frame is shown at a set time
|
||||
after its tick (turned off for the session if the screen doesn't keep those times).
|
||||
- **Sun glare:** the game measures the sun with occlusion queries; SpeedBreaker counts them on
|
||||
the GPU.
|
||||
|
||||
@@ -718,6 +758,7 @@ research.
|
||||
| [SDL3](https://www.libsdl.org/) | Windows, input, audio, app lifecycle | zlib |
|
||||
| [Dear ImGui](https://github.com/ocornut/imgui) (Omar Cornut) | The installer and settings UI | MIT |
|
||||
| [Roboto](https://fonts.google.com/specimen/Roboto) (Google) | The UI font | Apache-2.0 |
|
||||
| [FidelityFX Super Resolution 1](https://github.com/GPUOpen-Effects/FidelityFX-FSR) (AMD) | The FSR 1 Upscaling choice | MIT |
|
||||
| [o1heap](https://github.com/pavel-kirienko/o1heap) (Pavel Kirienko) | The guest heap allocator | MIT |
|
||||
| [glslang](https://github.com/KhronosGroup/glslang) and [SPIRV-Tools](https://github.com/KhronosGroup/SPIRV-Tools) (Khronos) | Shader compilation at runtime | BSD-3-Clause and others; Apache-2.0 |
|
||||
| [MoltenVK](https://github.com/KhronosGroup/MoltenVK) and the [Vulkan Loader](https://github.com/KhronosGroup/Vulkan-Loader) (Khronos) | Vulkan on Mac, iPhone and iPad | Apache-2.0 |
|
||||
|
||||
@@ -28,6 +28,7 @@ collected when the bundle is made), and the iPhone and iPad IPA in `SpeedBreaker
|
||||
| **Roboto** (Medium): Copyright 2011 Google Inc. | The menus' font, embedded in the program. Roboto is a trademark of Google. | Apache-2.0 | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt) |
|
||||
| **o1heap**: Copyright (c) 2020 Pavel Kirienko | The game's heap allocator (`runtime/thirdparty/o1heap`). | MIT | [`LICENSES/o1heap-MIT.txt`](LICENSES/o1heap-MIT.txt) |
|
||||
| **QR Code generator library** (C) 1.8.0: Copyright (c) 2022 Project Nayuki | The QR code of the download page in Settings > Advanced > Check for Updates (`runtime/thirdparty/qrcodegen`). | MIT | [`LICENSES/qrcodegen-MIT.txt`](LICENSES/qrcodegen-MIT.txt) |
|
||||
| **AMD FidelityFX Super Resolution 1** (FSR 1, `ffx_a.h` and `ffx_fsr1.h` at commit a21ffb8): Copyright (c) 2021 Advanced Micro Devices, Inc. | Settings > Graphics > Upscaling's FSR 1: its EASU and RCAS shader code, compiled into the game's shaders, and its constant setup (`runtime/thirdparty/fsr1`, unmodified). | MIT | [`LICENSES/FidelityFX-FSR-MIT.txt`](LICENSES/FidelityFX-FSR-MIT.txt) |
|
||||
| **FFmpeg** 7.1.1: the FFmpeg developers | The XMA1, XMA2 and WMA Pro audio decoders, linked statically. Built without any GPL parts, with one SpeedBreaker change: [`patches/ffmpeg/0001-xma-single-stream-no-holdback.patch`](patches/ffmpeg/0001-xma-single-stream-no-holdback.patch) (no sample hold-back for single-stream XMA, which the game's audio mixer depends on). Source: `https://ffmpeg.org/releases/ffmpeg-7.1.1.tar.xz` plus that patch; [`scripts/build_ffmpeg_xma.sh`](scripts/build_ffmpeg_xma.sh) builds it. | LGPL-2.1-or-later | [`LICENSES/FFmpeg-LGPL-2.1.txt`](LICENSES/FFmpeg-LGPL-2.1.txt) |
|
||||
|
||||
## Libraries in the downloads
|
||||
|
||||
@@ -22,6 +22,9 @@ from you.
|
||||
buttons on either side of the screen).
|
||||
- The D-pad picks a row, left/right changes it, A selects, LB/RB switch
|
||||
pages, B closes. The game pauses while the menu is open.
|
||||
- The **Controls** page: choose a row, Left or Right for its first or second key, press A (or
|
||||
Enter), then the key you want; Esc or B cancels. Y (Backspace) clears a key, X (Delete) restores
|
||||
the row, *Reset page* the whole table.
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
|
||||
@@ -79,6 +79,20 @@ target_link_libraries(imgui PUBLIC SDL3::SDL3 Vulkan::Vulkan)
|
||||
file(READ ${IMGUI}/fonts/Roboto-Medium.ttf UI_FONT_HEX HEX)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," UI_FONT_BYTES "${UI_FONT_HEX}")
|
||||
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/generated/ui_font.inc "${UI_FONT_BYTES}\n")
|
||||
# AMD FidelityFX Super Resolution 1 (thirdparty/fsr1, vendored, unmodified):
|
||||
# the text of its two headers, compiled in (generated/fsr1_headers.inc) for
|
||||
# Upscaling's FSR 1 shaders, which glslang compiles while the game runs
|
||||
# (video/fsr1.cpp). An edit of either reconfigures.
|
||||
set(FSR1 ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/fsr1)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${FSR1}/ffx_a.h ${FSR1}/ffx_fsr1.h)
|
||||
set(fsr1_inc "// Generated from runtime/thirdparty/fsr1 by runtime/CMakeLists.txt. Do not edit.\n")
|
||||
foreach(name ffx_a ffx_fsr1)
|
||||
file(READ ${FSR1}/${name}.h fsr1_hex HEX)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," fsr1_bytes "${fsr1_hex}")
|
||||
string(APPEND fsr1_inc "constexpr unsigned char k_${name}[] = { ${fsr1_bytes}0 };\n")
|
||||
endforeach()
|
||||
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/generated/fsr1_headers.inc.new "${fsr1_inc}")
|
||||
configure_file(${CMAKE_CURRENT_BINARY_DIR}/generated/fsr1_headers.inc.new ${CMAKE_CURRENT_BINARY_DIR}/generated/fsr1_headers.inc COPYONLY)
|
||||
target_include_directories(SpeedBreaker PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||
# The "What's new" note (ui/whats_new.cpp): this version's entry in
|
||||
# CHANGELOG.md, from its "## v<version>" heading to the next "## " one,
|
||||
|
||||
+125
-7
@@ -13,7 +13,12 @@
|
||||
// left every voice empty for half of each burst: gameplay audio came out
|
||||
// chopped 50% (two frames on, two off), thin and "tinny". SDL's queue is
|
||||
// kept near two device buffers to absorb the device's pulls, and the
|
||||
// cadence gains or skips a frame to follow the device clock. NFSMW_MUTE=1
|
||||
// cadence gains or skips a frame to follow the device clock, but never stops
|
||||
// (apu/audio_cadence.h): all of the game's sound, its sound commands
|
||||
// included, runs on this callback, so a device that stops taking audio must
|
||||
// not stop it (GitHub issue #20: the sound stopped for good, then the game
|
||||
// hung after the race). What such a device can't take is dropped, and the
|
||||
// log says when it stopped and started taking audio again. NFSMW_MUTE=1
|
||||
// (or no audio device) keeps the same cadence with no output. The driver
|
||||
// handle and wrapped argument follow Xenia.
|
||||
//
|
||||
@@ -24,6 +29,7 @@
|
||||
// frames owed.
|
||||
#include <stdafx.h>
|
||||
#include "audio.h"
|
||||
#include "audio_cadence.h"
|
||||
#include <kernel/function.h>
|
||||
#include <cpu/guest_thread.h>
|
||||
#include <cpu/guest_time.h>
|
||||
@@ -33,6 +39,7 @@
|
||||
#endif
|
||||
#include <user/settings.h>
|
||||
#include <video/presenter.h>
|
||||
#include <report/report.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -54,6 +61,19 @@ namespace
|
||||
constexpr int FRAME_BYTES = SAMPLES_PER_FRAME * CHANNELS * sizeof(float);
|
||||
std::atomic<int> s_targetFrames{ 4 }; // SDL queue level to hold (frames of 256 samples)
|
||||
int s_deviceFrames = 0; // the device's buffer at the stream's rate (FollowDevice)
|
||||
// What the hang report says about the audio (apu::DescribeState): when
|
||||
// the render callback last ran, when the game's mix last had sound, and
|
||||
// since when the device hasn't taken audio (0: it has); steady-clock ns.
|
||||
std::atomic<int64_t> s_lastRenderNs{ 0 }, s_lastSoundNs{ 0 }, s_deviceStalledNs{ 0 };
|
||||
std::atomic<uint64_t> s_dropped{ 0 }; // frames the device didn't take (dropped)
|
||||
// The cadence (the audio thread; the frames it renders are submitted on
|
||||
// that thread too).
|
||||
apu::AudioCadence s_cadence;
|
||||
|
||||
int64_t SteadyNs()
|
||||
{
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
// The queue level for the stream's device as it is now: two device
|
||||
// buffers (at the stream's rate), at least 4 frames. Logs it when it
|
||||
@@ -140,9 +160,42 @@ namespace
|
||||
paused = guesttime::PausedNs();
|
||||
next = steady_clock::now();
|
||||
} while (guesttime::Suspended() || guesttime::PausedNs() != paused);
|
||||
// NFSMW_TEST_AUDIO_DEVICE_STALL=<s>[+<secs>] (tests): the output
|
||||
// device stops taking audio <s> seconds after this thread starts
|
||||
// (paused, as a device that stalls or that another app holds), for
|
||||
// good or for <secs>.
|
||||
double stallAt = -1, stallFor = 0;
|
||||
if (const char* v = std::getenv("NFSMW_TEST_AUDIO_DEVICE_STALL"))
|
||||
{
|
||||
char* end = nullptr;
|
||||
stallAt = std::strtod(v, &end);
|
||||
if (end && *end == '+')
|
||||
stallFor = std::strtod(end + 1, nullptr);
|
||||
}
|
||||
int stallState = 0; // 0 before, 1 stalled, 2 over
|
||||
const auto started = steady_clock::now();
|
||||
while (s_running)
|
||||
{
|
||||
std::this_thread::sleep_until(next);
|
||||
if (stallAt >= 0 && stallState < 2 && s_stream)
|
||||
{
|
||||
const double t = duration<double>(steady_clock::now() - started).count();
|
||||
if (stallState == 0 && t >= stallAt)
|
||||
{
|
||||
stallState = 1;
|
||||
fprintf(stderr, "[test] NFSMW_TEST_AUDIO_DEVICE_STALL: the output device stops taking audio (%.1f s)\n", t);
|
||||
std::lock_guard lock(s_streamMutex);
|
||||
SDL_PauseAudioStreamDevice(s_stream);
|
||||
}
|
||||
else if (stallState == 1 && stallFor > 0 && t >= stallAt + stallFor)
|
||||
{
|
||||
stallState = 2;
|
||||
fprintf(stderr, "[test] NFSMW_TEST_AUDIO_DEVICE_STALL: the output device takes audio again (%.1f s)\n", t);
|
||||
std::lock_guard lock(s_streamMutex);
|
||||
if (!guesttime::Suspended())
|
||||
SDL_ResumeAudioStreamDevice(s_stream);
|
||||
}
|
||||
}
|
||||
// No guest audio time passes while the game is suspended: render
|
||||
// nothing (the main thread has paused the device). After it the
|
||||
// cadence moves on by exactly the time suspended, keeping its
|
||||
@@ -172,17 +225,31 @@ namespace
|
||||
FollowDevice(false);
|
||||
}
|
||||
// Follow the device clock: one frame more or less when the
|
||||
// queue drifts from its target.
|
||||
int queued = SDL_GetAudioStreamQueued(s_stream) / FRAME_BYTES;
|
||||
if (queued > s_targetFrames + 2)
|
||||
frames = 0;
|
||||
else if (queued < s_targetFrames - 2)
|
||||
frames = 2;
|
||||
// queue drifts from its target, never stopping the game's
|
||||
// sound (audio_cadence.h).
|
||||
int queued = std::max(0, SDL_GetAudioStreamQueued(s_stream)) / FRAME_BYTES;
|
||||
frames = s_cadence.Frames(queued, s_targetFrames);
|
||||
if (s_cadence.Stalled() && !s_deviceStalledNs.load(std::memory_order_relaxed))
|
||||
{
|
||||
s_deviceStalledNs.store(SteadyNs(), std::memory_order_relaxed);
|
||||
fprintf(stderr, "[audio] the output device has taken no audio for %.1f s (%.0f ms queued): the game's sound "
|
||||
"carries on, unheard, until it does\n", apu::AudioCadence::StallTicks(s_targetFrames) * apu::kFrameNs / 1e9,
|
||||
1000.0 * queued * SAMPLES_PER_FRAME / SAMPLE_RATE);
|
||||
}
|
||||
if (s_cadence.Resumed())
|
||||
{
|
||||
// What was queued before the stall is stale.
|
||||
SDL_ClearAudioStream(s_stream);
|
||||
const int64_t since = s_deviceStalledNs.exchange(0, std::memory_order_relaxed);
|
||||
fprintf(stderr, "[audio] the output device takes audio again after %.1f s (%llu frames dropped in all)\n",
|
||||
since ? double(SteadyNs() - since) / 1e9 : 0.0, (unsigned long long)s_dropped.load());
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < frames && !guesttime::Suspended(); i++)
|
||||
{
|
||||
RenderFrame(ctx);
|
||||
s_rendered.fetch_add(1, std::memory_order_relaxed);
|
||||
s_lastRenderNs.store(SteadyNs(), std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +276,22 @@ namespace apu
|
||||
return s_rendered.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::string DescribeState()
|
||||
{
|
||||
const int64_t now = SteadyNs();
|
||||
auto ago = [now](int64_t t) { return t ? std::format("{:.1f} s ago", double(now - t) / 1e9) : std::string("never"); };
|
||||
std::string text = std::format("Render callbacks: {} (the last {}); frames from the game: {}; the game's mix last had sound {}.\n",
|
||||
s_rendered.load(), ago(s_lastRenderNs.load()), s_framesSubmitted.load(), ago(s_lastSoundNs.load()));
|
||||
if (!s_stream)
|
||||
return text + "Output device: none (muted by NFSMW_MUTE, or no device).\n";
|
||||
const int64_t stalled = s_deviceStalledNs.load();
|
||||
text += std::format("Output device: {:.1f} ms queued (target {:.1f} ms); {}; {} frames dropped in all.\n", QueuedMs(),
|
||||
1000.0 * s_targetFrames * SAMPLES_PER_FRAME / SAMPLE_RATE,
|
||||
stalled ? std::format("not taking audio for {:.1f} s", double(now - stalled) / 1e9) : std::string("taking audio"),
|
||||
s_dropped.load());
|
||||
return text;
|
||||
}
|
||||
|
||||
double QueuedMs()
|
||||
{
|
||||
std::lock_guard lock(s_streamMutex);
|
||||
@@ -256,6 +339,34 @@ uint32_t XAudioUnregisterRenderDriverClient(uint32_t driver)
|
||||
uint32_t XAudioSubmitRenderDriverFrame(uint32_t driver, void* samples)
|
||||
{
|
||||
s_framesSubmitted++;
|
||||
// When the game's mix last had sound (the hang report), and a line once it
|
||||
// has been silent for 10 s in a row (its sound stopped, or a silent
|
||||
// screen) and when it sounds again: a player's log shows when it dropped.
|
||||
if (samples)
|
||||
{
|
||||
static uint64_t silentFrames = 0;
|
||||
static double silentFrom = 0;
|
||||
const auto* words = static_cast<const uint32_t*>(samples);
|
||||
bool silent = true;
|
||||
for (uint32_t i = 0; i < SAMPLES_PER_FRAME * CHANNELS && silent; i++)
|
||||
silent = (words[i] & 0xFFFFFF7Fu) == 0; // +0.0 or -0.0, big-endian
|
||||
constexpr uint64_t kSilentLog = 10 * SAMPLE_RATE / SAMPLES_PER_FRAME;
|
||||
if (!silent)
|
||||
{
|
||||
s_lastSoundNs.store(SteadyNs(), std::memory_order_relaxed);
|
||||
if (silentFrames >= kSilentLog)
|
||||
fprintf(stderr, "[audio] the game's mix sounds again after %.1f s of silence\n",
|
||||
double(silentFrames) * SAMPLES_PER_FRAME / SAMPLE_RATE);
|
||||
silentFrames = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (silentFrames == 0)
|
||||
silentFrom = report::SessionSeconds();
|
||||
if (++silentFrames == kSilentLog)
|
||||
fprintf(stderr, "[audio] the game's mix has been silent for 10 s (since %.1f s)\n", silentFrom);
|
||||
}
|
||||
}
|
||||
if (!s_stream || !samples)
|
||||
return 0;
|
||||
// Volume, mute and mute-in-background, when they or the focus change.
|
||||
@@ -322,6 +433,13 @@ uint32_t XAudioSubmitRenderDriverFrame(uint32_t driver, void* samples)
|
||||
nextLog = now + std::chrono::seconds(2);
|
||||
}
|
||||
}
|
||||
// A device that isn't taking audio (audio_cadence.h): the frame is
|
||||
// dropped, and the game's sound carries on, unheard.
|
||||
if (s_cadence.Stalled())
|
||||
{
|
||||
s_dropped.fetch_add(1, std::memory_order_relaxed);
|
||||
return 0;
|
||||
}
|
||||
SDL_PutAudioStreamData(s_stream, out, sizeof(out));
|
||||
|
||||
// NFSMW_AUDIO_DUMP=<file>: the final mix as raw interleaved 6-channel
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// cpu/guest_time.h).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace apu
|
||||
{
|
||||
@@ -24,4 +25,9 @@ namespace apu
|
||||
// and the audio queued for the device (ms; 0 without one).
|
||||
uint64_t RenderedFrames();
|
||||
double QueuedMs();
|
||||
|
||||
// Any thread: the audio's state for a hang report, a line or two: the
|
||||
// render callbacks (the game's sound runs on them), when the game's mix
|
||||
// last had sound, and the output device (GitHub issue #20).
|
||||
std::string DescribeState();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The render callback's cadence against the output device (apu/audio.cpp),
|
||||
// header only so that tests/audio_cadence_test.cpp runs it.
|
||||
//
|
||||
// All of the game's sound runs on the render callback. XAudio processes its
|
||||
// voices once per call, and NFSMW's own mixer thread (sub_825E3C30) mixes its
|
||||
// next 256 samples only when XAudio's pass over its voice wakes it; that
|
||||
// thread also carries out the game's sound commands (play, stop, unload:
|
||||
// sub_825CF588). The console's hardware calls the callback every 5.33 ms,
|
||||
// whatever the TV or speakers do. Here the cadence follows the output
|
||||
// device's clock: a frame more or less when SDL's queue strays more than
|
||||
// kSlack frames from its target. Up to v0.1.5 that was all, so a device that
|
||||
// stopped taking audio (one that stalls, another app holding it) kept the
|
||||
// queue over its target and the callback skipped for good: the game's sound
|
||||
// stopped, its sound commands with it, and the game hung the next time it
|
||||
// waited for a sound to stop (sub_821BD920 polls until the sound system no
|
||||
// longer lists a stream it stopped, as when it unloads an event's sounds
|
||||
// after the race): GitHub issue #20.
|
||||
//
|
||||
// Now a device that has taken nothing for StallTicks ticks (half a second,
|
||||
// or four queue targets: far longer than any device takes between its
|
||||
// pulls) is stalled: the callback runs at real time again, every frame is
|
||||
// dropped (audio.cpp), and the stall is over the moment the device takes
|
||||
// some: what was queued before it is stale and cleared (Resumed), and the
|
||||
// queue fills up again as at the start. A device that does take audio, at
|
||||
// whatever pace, is followed exactly as before.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
namespace apu
|
||||
{
|
||||
class AudioCadence
|
||||
{
|
||||
public:
|
||||
static constexpr int kSlack = 2; // frames the queue may stray from its target
|
||||
static constexpr int kStallTicks = 94; // ~0.5 s
|
||||
|
||||
// Ticks without the device taking anything before it counts as
|
||||
// stalled, for a queue target of `target` frames.
|
||||
static constexpr int StallTicks(int target) { return std::max(kStallTicks, 4 * target); }
|
||||
|
||||
// One tick (every 5.33 ms): how many frames to render now (0, 1 or
|
||||
// 2), with `queued` frames waiting for the device and a queue target
|
||||
// of `target` frames.
|
||||
int Frames(int queued, int target)
|
||||
{
|
||||
// What the device took since the last tick: the queue then, plus
|
||||
// what was queued since, less the queue now.
|
||||
m_idle = m_primed && m_expected - queued <= 0 ? m_idle + 1 : 0;
|
||||
m_primed = true;
|
||||
if (m_stalled)
|
||||
{
|
||||
if (m_idle > 0)
|
||||
{
|
||||
m_expected = queued; // the frame is dropped
|
||||
return 1;
|
||||
}
|
||||
// It took some: over. The caller clears what is queued.
|
||||
m_stalled = false;
|
||||
m_resumed = true;
|
||||
queued = 0;
|
||||
}
|
||||
else if (m_idle >= StallTicks(target))
|
||||
{
|
||||
m_stalled = true;
|
||||
m_stalls++;
|
||||
m_expected = queued;
|
||||
return 1;
|
||||
}
|
||||
const int frames = queued > target + kSlack ? 0 : queued < target - kSlack ? 2 : 1;
|
||||
m_expected = queued + frames;
|
||||
return frames;
|
||||
}
|
||||
|
||||
// The device isn't taking audio: every frame rendered is dropped,
|
||||
// not queued.
|
||||
bool Stalled() const { return m_stalled; }
|
||||
// True once after a stall ends: the audio still queued from before
|
||||
// it is stale (clear it).
|
||||
bool Resumed()
|
||||
{
|
||||
const bool r = m_resumed;
|
||||
m_resumed = false;
|
||||
return r;
|
||||
}
|
||||
uint64_t Stalls() const { return m_stalls; }
|
||||
|
||||
private:
|
||||
bool m_primed = false, m_stalled = false, m_resumed = false;
|
||||
int m_expected = 0; // the queue expected now if the device took nothing
|
||||
int m_idle = 0; // ticks in a row the device took nothing
|
||||
uint64_t m_stalls = 0;
|
||||
};
|
||||
}
|
||||
@@ -34,9 +34,12 @@
|
||||
// count, its length rounded either way. Game time adds up to the vblanks
|
||||
// that fired, to within those few. It runs at the display's rate over 60:
|
||||
// 0.044% slow on a 59.97 Hz monitor, at most 2.5% (the lock's range). Frames
|
||||
// of more than 6 vblanks (loading) keep their measured time. At Frame Rate
|
||||
// 30 (video::GuestVblankDivider) a guest vblank comes every other refresh
|
||||
// and is given 2/60 s: two steps a frame, the game at its speed.
|
||||
// of more than 6 vblanks (a hitch, loading) keep their measured time; below
|
||||
// 20 fps, where they are the frame rate's own, only those over half a second
|
||||
// do, the rest get 6 (the stepper's own cap) and later frames give back what
|
||||
// that leaves owed, so game time keeps up down to 10 fps. At Frame Rate 30
|
||||
// (video::GuestVblankDivider) a guest vblank comes every other refresh and is
|
||||
// given 2/60 s: two steps a frame, the game at its speed.
|
||||
#include <stdafx.h>
|
||||
#include "frame_time.h"
|
||||
#include "whole_vblanks.h"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The front end's matrix and the game's cache of it, after the window
|
||||
// changes shape (game/ultrawide.cpp's UltrawideFrontEnd).
|
||||
//
|
||||
// The front-end builder (sub_82440D80) makes the HUD and menu matrix on its
|
||||
// stack (x 0.92 x 0.75, y 0.92 for the 16:9 frame; the hook narrows them by
|
||||
// the view scales) and hands it to sub_82453768 with the view entry
|
||||
// (0x82C3F590) and an object. That function keeps the three pointers of its
|
||||
// last call at 0x82A2D190 (view), +4 (matrix) and +8 (object) and returns
|
||||
// at once when a call brings the same three: on the 360 the front end's
|
||||
// matrix never changes, so the same address means the same matrix. In menus
|
||||
// and races the game's other views go through it every frame, so the front
|
||||
// end's call never repeats the last one; through the boot movies (the EA
|
||||
// logos, the PSA, the attract movie) nothing else does. So when the window
|
||||
// changed shape during them (the green button mid-movie), the front end kept
|
||||
// the matrix narrowed for the old shape: the renderer, which recognises
|
||||
// front-end draws by the matrix the hook made last, left them alone, and what
|
||||
// the game parks just above the 16:9 frame (an "EVENT HANDLER" label at
|
||||
// 1.04..1.09 of the frame's height, right of centre) showed in the rows
|
||||
// Vert+ adds above it.
|
||||
//
|
||||
// The fix: when the hook leaves a matrix other than the one it left last, it
|
||||
// clears the cache's matrix pointer (a real pointer is never 0), so the call
|
||||
// right after it uploads the new matrix. Once per change; a session that
|
||||
// never changes shape uploads exactly as before, past the first frames.
|
||||
//
|
||||
// Header only (tests/front_end_matrix_test.cpp).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace game::front_end
|
||||
{
|
||||
// sub_82453768's cache of its last call: view, matrix, object pointers.
|
||||
constexpr uint32_t kMatrixCache = 0x82A2D190;
|
||||
constexpr uint32_t kMatrixCachePointer = kMatrixCache + 4;
|
||||
|
||||
// The matrix scales the front-end hook left last (its thread only).
|
||||
class MatrixTracker
|
||||
{
|
||||
public:
|
||||
// Whether the matrix (x, y scales) differs from the one left last,
|
||||
// and the first time: the game must upload it again.
|
||||
bool Changed(float x, float y)
|
||||
{
|
||||
const bool changed = !m_seen || x != m_x || y != m_y;
|
||||
m_seen = true;
|
||||
m_x = x;
|
||||
m_y = y;
|
||||
return changed;
|
||||
}
|
||||
|
||||
private:
|
||||
bool m_seen = false;
|
||||
float m_x = 0.0f, m_y = 0.0f;
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,10 @@
|
||||
// widescreen case of the front-end matrix (sub_82440D80): f13 is its x
|
||||
// scale (0.92 safe area x 0.75, a 4:3 canvas centred in 16:9) and f0 its
|
||||
// y scale (0.92), stored at m00 and m11 (shader constants c0 and c5).
|
||||
// When the matrix it leaves differs from the last (the window changed
|
||||
// shape), it also clears the game's cached matrix pointer, so the new
|
||||
// matrix is uploaded even when nothing else used the cache since
|
||||
// (game/front_end_matrix.h: the boot movies).
|
||||
// - UltrawideMirror, at the branch at 0x8245CED8 that ends the widescreen
|
||||
// case of the rear-view mirror draw (sub_8245CDF0). The mirror is not a
|
||||
// view: its picture is one quad whose pixel shader (ps_40d7f669a326708d)
|
||||
@@ -41,6 +45,7 @@
|
||||
// of the build failing on an undefined symbol.
|
||||
#include <stdafx.h>
|
||||
#include "ultrawide.h"
|
||||
#include "front_end_matrix.h"
|
||||
#include "glare.h"
|
||||
|
||||
#include <kernel/function.h>
|
||||
@@ -110,6 +115,30 @@ namespace
|
||||
{
|
||||
f.f64 = double(0.5f + (float(f.f64) - 0.5f) * scale);
|
||||
}
|
||||
|
||||
// The front-end matrix the hook left last (the game's render thread),
|
||||
// and NFSMW_FE_RESEND=0 to leave the game's matrix cache alone (the
|
||||
// matrix then stays narrowed for an old window shape through the boot
|
||||
// movies, as before; an A/B switch).
|
||||
game::front_end::MatrixTracker s_frontEndMatrix;
|
||||
const bool s_frontEndResend = [] {
|
||||
const char* v = std::getenv("NFSMW_FE_RESEND");
|
||||
return !(v && v[0] == '0');
|
||||
}();
|
||||
|
||||
// The hook changed the front end's matrix (a new window shape, or the
|
||||
// first frames): clear sub_82453768's cached matrix pointer so the call
|
||||
// right after the hook uploads it (game/front_end_matrix.h).
|
||||
void ResendIfChanged(float x, float y)
|
||||
{
|
||||
if (!s_frontEndMatrix.Changed(x, y) || !s_frontEndResend)
|
||||
return;
|
||||
*static_cast<uint32_t*>(g_memory.Translate(game::front_end::kMatrixCachePointer)) = 0;
|
||||
static uint32_t logged = 0;
|
||||
if (++logged <= 16)
|
||||
fprintf(stderr, "[uw] front-end matrix x %.4f y %.4f: sent to the game again%s\n", x, y,
|
||||
logged == 16 ? " (no more of these lines)" : "");
|
||||
}
|
||||
}
|
||||
|
||||
namespace game
|
||||
@@ -188,6 +217,7 @@ void UltrawideFrontEnd(PPCRegister& f13, PPCRegister& f0)
|
||||
ScaleRegister(f0, scale.y);
|
||||
s_frontEndX.store(float(f13.f64), std::memory_order_relaxed);
|
||||
s_frontEndY.store(float(f0.f64), std::memory_order_relaxed);
|
||||
ResendIfChanged(float(f13.f64), float(f0.f64));
|
||||
}
|
||||
|
||||
void UltrawideMirror(PPCRegister& f1, PPCRegister& f2, PPCRegister& f3, PPCRegister& f4)
|
||||
@@ -223,6 +253,7 @@ void UltrawideFrontEnd(PPCRegister& f13)
|
||||
if (scale != 1.0f)
|
||||
ScaleRegister(f13, scale);
|
||||
s_frontEndX.store(float(f13.f64), std::memory_order_relaxed);
|
||||
ResendIfChanged(float(f13.f64), 0.0f);
|
||||
}
|
||||
|
||||
void UltrawideMirror(PPCRegister& f1, PPCRegister& f3)
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
namespace game
|
||||
{
|
||||
// Game time follows the guest vblanks that fire: over any run of frames
|
||||
// it adds up to them, to within kSlack vblanks, and each frame that saw
|
||||
// any gets whole ones (1 to 6), so the fixed 1/60 s world step runs a
|
||||
// whole number of times a frame.
|
||||
// of up to 6 vblanks it adds up to them, to within kSlack vblanks (below
|
||||
// 20 fps, frames of up to kLoadVblanks and kSlack + kMaxOwed), and each
|
||||
// frame that saw any gets whole ones (1 to 6), so the fixed 1/60 s world
|
||||
// step runs a whole number of times a frame.
|
||||
// - A frame the vblank paces (60 fps held, or 30 at Frame Rate 30) saw
|
||||
// one and gets one.
|
||||
// - The main loop runs up to a few frames ahead of the display (its
|
||||
@@ -36,6 +37,20 @@ namespace game
|
||||
// - Ahead by kSlack (more frames than vblanks for a while: the queue
|
||||
// filling after a load), a frame no vblank fired in keeps its
|
||||
// measured time.
|
||||
// - A frame of more than 6 (a hitch, or a load's) keeps its measured
|
||||
// time, and game time starts over (the game's stepper runs at most 6
|
||||
// steps a frame and drops the rest).
|
||||
// - Below 20 fps (the frames before averaging 50 ms or more) long
|
||||
// frames are the frame rate's own: at 12 fps (5 vblanks a frame) one
|
||||
// of 7-8 comes every second or so. Starting over after each, kSlack
|
||||
// behind again, the frame after it got kSlack fewer than fired, and
|
||||
// game time ran 4% slow (until 2026-10-12; an Intel laptop GPU at
|
||||
// 1.5x). There a frame of up to kLoadVblanks (half a second; longer:
|
||||
// a load, as above) gets 6, the cap leaves the rest owed, up to
|
||||
// kMaxOwed beyond kSlack, and later frames give it back, each at most
|
||||
// doubling its own count (a frame of 5 gets 6, one of 1 gets 2).
|
||||
// Frames over 6 on average (below 10 fps) owe more than comes back:
|
||||
// the game runs slow there, as its stepper made it on the console.
|
||||
// The rule until 2026-10-07 gave a frame its count only when its
|
||||
// measured time was within half a vblank of it, else the measured time:
|
||||
// GPU-bound 44 ms frames got 3 vblanks or their own 2.64, so the game ran
|
||||
@@ -47,24 +62,53 @@ namespace game
|
||||
{
|
||||
// Game time given beyond the vblanks that fired, in vblanks (below 0:
|
||||
// behind), within +-kSlack: what can't be given back is forgotten (a
|
||||
// held vblank, as a GPU-only suspension's, isn't replayed).
|
||||
// held vblank, as a GPU-only suspension's, isn't replayed). Below
|
||||
// 20 fps it may be down to -(kSlack + kMaxOwed).
|
||||
double ahead = 0.0;
|
||||
// The recent frames' length, ms on average (a frame counted as its
|
||||
// vblanks, up to kMaxGive of them): 16.7 at 60 fps, 83 at 12.
|
||||
double frameMs = kHighRateMs;
|
||||
// How far the main loop's frames may drift from the vblanks without
|
||||
// the display showing it: the frames queued between the loop and the
|
||||
// display (2-3, measured on the Steam Machine).
|
||||
static constexpr double kSlack = 3.0;
|
||||
// The most a frame gets: the game's stepper runs at most 6 world
|
||||
// steps a frame (sub_823A20C0) and drops the rest. (At Frame Rate 30
|
||||
// a guest vblank is two steps, so 3 of them would be the cap there;
|
||||
// a frame still gets up to 6, as before.)
|
||||
static constexpr int64_t kMaxGive = 6;
|
||||
// Frames this long on average and longer: below 20 fps, where a
|
||||
// frame of up to kLoadVblanks gets kMaxGive and what the cap leaves
|
||||
// stays owed, up to kMaxOwed beyond kSlack (half a second). Longer
|
||||
// frames are loads (half a second: nobody drives at 2 fps).
|
||||
static constexpr double kLowRateMs = 50.0;
|
||||
static constexpr double kMaxOwed = 30.0;
|
||||
static constexpr uint64_t kLoadVblanks = 30;
|
||||
static constexpr double kHighRateMs = 1000.0 / 60.0;
|
||||
|
||||
// A frame the main loop measured at `measuredMs`, during which
|
||||
// `vblanks` guest vblanks fired (from its start to the next frame's),
|
||||
// each `vblankMs` long: the vblanks to give it, or 0 to keep its
|
||||
// measured time. More than 6 (loading) keeps it too, and starts over.
|
||||
// measured time. More than 6 (a hitch, loading) keeps it too, and
|
||||
// starts over; below 20 fps only more than kLoadVblanks does.
|
||||
uint64_t Give(double measuredMs, uint64_t vblanks, double vblankMs)
|
||||
{
|
||||
if (vblanks > 6 || !(vblankMs > 0.0))
|
||||
if (vblanks > kLoadVblanks || !(vblankMs > 0.0))
|
||||
{
|
||||
Reset();
|
||||
return 0;
|
||||
}
|
||||
// Judged by the frames before this one: a long frame at 60 fps
|
||||
// is a hitch, wherever it takes the average.
|
||||
const bool low = frameMs >= kLowRateMs;
|
||||
frameMs += (double(std::min<uint64_t>(vblanks, kMaxGive)) * vblankMs - frameMs) / 4.0;
|
||||
if (!low && vblanks > uint64_t(kMaxGive))
|
||||
{
|
||||
ahead = 0.0;
|
||||
return 0;
|
||||
}
|
||||
if (!low)
|
||||
ahead = std::max(ahead, -kSlack); // back above 20 fps: owed no more
|
||||
ahead -= double(vblanks);
|
||||
if (ahead + 1.0 > kSlack)
|
||||
{
|
||||
@@ -73,12 +117,21 @@ namespace game
|
||||
}
|
||||
int64_t give = 1;
|
||||
if (ahead + 1.0 < -kSlack)
|
||||
give = std::clamp<int64_t>(std::llround(-kSlack - ahead), 1, 6);
|
||||
ahead = std::max(ahead + double(give), -kSlack);
|
||||
{
|
||||
// Owed beyond its own count (only after a frame the cap held
|
||||
// back): at most as much again.
|
||||
give = std::min<int64_t>(std::llround(-kSlack - ahead), std::max<int64_t>(2 * int64_t(vblanks), 1));
|
||||
give = std::clamp<int64_t>(give, 1, kMaxGive);
|
||||
}
|
||||
ahead = std::max(ahead + double(give), -kSlack - (low ? kMaxOwed : 0.0));
|
||||
return uint64_t(give);
|
||||
}
|
||||
|
||||
// Game time measured again (NFSMW_GAME_TIME=0, a rate far from 60).
|
||||
void Reset() { ahead = 0.0; }
|
||||
void Reset()
|
||||
{
|
||||
ahead = 0.0;
|
||||
frameMs = kHighRateMs;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
#include <video/presenter.h>
|
||||
#include <video/frame_rate.h>
|
||||
#include <video/vblank_lock.h>
|
||||
#include <video/vblank_timer.h>
|
||||
#include <video/display_gamma.h>
|
||||
#include <platform/thermal.h>
|
||||
#include <game/frame_time.h>
|
||||
#include "renderer.h"
|
||||
@@ -259,13 +261,19 @@ namespace gpu
|
||||
{
|
||||
s_regs[index] |= 0x80000000u;
|
||||
}
|
||||
else if (video::display_gamma::IsLutRegister(index))
|
||||
{
|
||||
// The display gamma ramp (video/display_gamma.h): stored as
|
||||
// any register, and followed for the presenter.
|
||||
video::display_gamma::OnRegisterWrite(index, value);
|
||||
}
|
||||
if (index != REG_CP_RB_WPTR) // the game owns that one
|
||||
*MmioRegister(index) = s_regs[index];
|
||||
}
|
||||
|
||||
// A run of registers WriteRegister has nothing special for (it treats
|
||||
// only the scratch fences, COHER_STATUS_HOST and the write pointer
|
||||
// specially) can be written in bulk.
|
||||
// only the scratch fences, COHER_STATUS_HOST, the write pointer and
|
||||
// the display gamma ramp's registers specially) can be written in bulk.
|
||||
bool BulkSafe(uint32_t base, uint32_t count)
|
||||
{
|
||||
uint32_t end = base + count;
|
||||
@@ -273,7 +281,8 @@ namespace gpu
|
||||
return false;
|
||||
auto hits = [&](uint32_t lo, uint32_t hi) { return base <= hi && end > lo; };
|
||||
return !hits(REG_CP_RB_WPTR, REG_CP_RB_WPTR) && !hits(REG_SCRATCH_REG0, REG_SCRATCH_REG7) &&
|
||||
!hits(REG_COHER_STATUS_HOST, REG_COHER_STATUS_HOST);
|
||||
!hits(REG_COHER_STATUS_HOST, REG_COHER_STATUS_HOST) &&
|
||||
!hits(video::display_gamma::kRegRwMode, video::display_gamma::kRegLutaControl);
|
||||
}
|
||||
|
||||
// WriteRegister for `count` registers from `src`, big-endian dwords
|
||||
@@ -1111,29 +1120,41 @@ namespace gpu
|
||||
}
|
||||
}
|
||||
|
||||
// One line for the vblank lock's state (on changes, and each minute).
|
||||
void LogVblank(const video::VblankLock::Status& s)
|
||||
// One line for the vblank lock's state (on changes, and each minute),
|
||||
// and how late the last minute's vblanks fired.
|
||||
void LogVblank(const video::VblankLock::Status& s, const video::VblankTiming& timing, const char* timer)
|
||||
{
|
||||
const video::RefreshEstimate& d = s.display;
|
||||
std::string line;
|
||||
if (s.locked)
|
||||
fprintf(stderr, "[vblank] locked to the display: %.4f Hz (fit %u presents over %.1f s, rms %.2f ms, %u set aside); "
|
||||
"vblank %.2f ms before its refresh, error %+.3f ms; vblank to screen %.1f ms (median), to present %.1f ms (99%%); %llu slips\n",
|
||||
line = std::format("locked to the display: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside); "
|
||||
"vblank {:.2f} ms before its refresh, error {:+.3f} ms; vblank to screen {:.1f} ms (median), to present {:.1f} ms (99%); {} slips",
|
||||
1e9 / d.periodNs, d.samples, d.spanNs / 1e9, d.rmsNs / 1e6, d.rejected, s.leadNs / 1e6, s.errorNs / 1e6,
|
||||
s.latencyNs / 1e6, s.readyNs / 1e6, (unsigned long long)s.slips);
|
||||
s.latencyNs / 1e6, s.readyNs / 1e6, s.slips);
|
||||
else if (s.follows)
|
||||
fprintf(stderr, "[vblank] free-running every %.3f ms: the display follows the vblank (variable refresh?), "
|
||||
"so there is no refresh to lock to\n", s.periodNs / 1e6);
|
||||
line = std::format("free-running every {:.3f} ms: the display follows the vblank (variable refresh?), "
|
||||
"so there is no refresh to lock to", s.periodNs / 1e6);
|
||||
else if (d.samples)
|
||||
fprintf(stderr, "[vblank] free-running every %.3f ms; display %s: %.4f Hz (fit %u presents over %.1f s, rms %.2f ms, %u set aside)\n",
|
||||
line = std::format("free-running every {:.3f} ms; display {}: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside)",
|
||||
s.periodNs / 1e6, d.valid ? "measured (lock off)" : "not accepted", 1e9 / d.periodNs, d.samples, d.spanNs / 1e9,
|
||||
d.rmsNs / 1e6, d.rejected);
|
||||
else
|
||||
fprintf(stderr, "[vblank] free-running every %.3f ms\n", s.periodNs / 1e6);
|
||||
line = std::format("free-running every {:.3f} ms", s.periodNs / 1e6);
|
||||
if (video::VblankTiming::Summary t = timing.Summarize(); t.count)
|
||||
line += std::format("; timer {}: vblanks late {:.2f} ms (median), {:.2f} ms (99%), {:.2f} ms at most, handler {:.2f} ms (99%)",
|
||||
timer, t.lateMedianNs / 1e6, t.late99Ns / 1e6, t.lateMaxNs / 1e6, t.handler99Ns / 1e6);
|
||||
fprintf(stderr, "[vblank] %s\n", line.c_str());
|
||||
}
|
||||
|
||||
void Vsync()
|
||||
{
|
||||
SetHostThreadName("nfsmw-vsync"); // (Linux would give it its creator's name, nfsmw-main)
|
||||
// Woken on time (video/vblank_timer.h): on macOS an ordinary
|
||||
// thread's sleeps ran up to 9 ms late.
|
||||
const video::VblankTimer timer = video::ChosenVblankTimer();
|
||||
const bool realtime = video::PrepareVblankThread(timer, 16'666'667);
|
||||
const char* timerName = video::VblankTimerName(timer, realtime);
|
||||
video::VblankTiming timing;
|
||||
GuestThreadContext thread(2);
|
||||
using namespace std::chrono;
|
||||
video::VblankLock& clock = video::GuestVblank();
|
||||
@@ -1173,14 +1194,16 @@ namespace gpu
|
||||
paused = nowPaused;
|
||||
clock.Resumed();
|
||||
}
|
||||
video::NoteGuestVblank(now());
|
||||
const int64_t fired = now();
|
||||
video::NoteGuestVblank(fired);
|
||||
s_counter++;
|
||||
s_vblanks++;
|
||||
DispatchInterrupt(thread.ppcContext, 0, 2);
|
||||
timing.Add(fired - next, now() - fired);
|
||||
auto status = clock.GetStatus();
|
||||
if (status.locked != locked || status.slips != slips || ++count % 3600 == 0)
|
||||
{
|
||||
LogVblank(status);
|
||||
LogVblank(status, timing, timerName);
|
||||
locked = status.locked;
|
||||
slips = status.slips;
|
||||
}
|
||||
|
||||
+256
-52
@@ -23,14 +23,27 @@
|
||||
// a thin border of the world shows around it, as on the console; a TV's
|
||||
// overscan hid it). Under Vert+ the world showed above and below it (63
|
||||
// rows on the Deck), under Hor+ beside it (720 px a side at 2880x810). The
|
||||
// intro's start (~57 s) has another, 0.920 of the frame's height.
|
||||
// intro's start (~57 s) has another, 0.920 of the frame's height. The
|
||||
// Blacklist's rival bio (SafeHouseRivalBio.fng, "View Rival Movie / View
|
||||
// Rival Car") sets its text and picture on one more, 0.976 x 0.985 of the
|
||||
// frame (FF000000, fading in from 11000000), for as long as the screen is
|
||||
// up: a thin rectangular border of the scene behind it all round it, on
|
||||
// every screen shape (GitHub #19).
|
||||
//
|
||||
// The fix leaves those draws as they are and, right after each side bar or
|
||||
// black frame, draws it once or twice more, stretched over the whole frame
|
||||
// and scissored to (its half of) what lies outside the 16:9 band: the rows
|
||||
// above and below it (Vert+), or the columns beyond its side (Hor+; for a
|
||||
// bar only when it ends short of the screen edge). Nothing inside the band
|
||||
// changes, and the strips get the quad's own colour, alpha and blend.
|
||||
// black frame, draws it once or more again, stretched over the whole frame
|
||||
// and scissored to what it must fill: for a bar, (its half of) what lies
|
||||
// outside the 16:9 band, the rows above and below it (Vert+) or the columns
|
||||
// beyond its side (Hor+; only when it ends short of the screen edge), and
|
||||
// nothing inside the band changes; for a black frame, everything outside
|
||||
// the quad itself, the band's thin border too, on every screen shape, 16:9
|
||||
// included (the period TV's look, as the results screen's overscan rows).
|
||||
// The copies get the quad's own colour, alpha and blend.
|
||||
//
|
||||
// Two pictures the front end draws just short of the 16:9 frame's edges
|
||||
// get the screen's edges instead, on every screen shape: around the results
|
||||
// screen's backdrop the rows are cleared to black (PlanBackdrop), and the
|
||||
// Showcase's vignette is stretched to the screen's edges (PlanVignette).
|
||||
//
|
||||
// Units: the frame's clip space after the front-end matrix (w = 1), and
|
||||
// "band units" = clip / view scale (x / scaleX, y / scaleY): +-1 are the
|
||||
@@ -157,13 +170,16 @@ namespace gpu::bars
|
||||
}
|
||||
|
||||
// A black frame's thresholds, in band units. Logged on the Deck and at
|
||||
// 32:9: x +-0.970, y +-0.939, centred, FF000000 (the cut at ~95 s), and
|
||||
// x +-0.970, y +-0.920, FE000000 (the intro's start, ~57 s). The nearest
|
||||
// draws it must not take: the fades and dims (+-1.29: past the band,
|
||||
// the widen, stretch and cover rules' own), the title key art (x
|
||||
// +-1.104, y +-0.981, grey), the overlay at ~57 s (x +-1.104, B8 alpha),
|
||||
// the vid cam's noise and dark overlays (x +-0.70; 3C alpha), its dialog
|
||||
// boxes (x +-0.58), a full-band black layer (+-1.0, the cover rule's).
|
||||
// 32:9: x +-0.970, y +-0.939, centred, FF000000 (the cut at ~95 s), and x
|
||||
// +-0.970, y +-0.920, FE000000 (the intro's start, ~57 s); x +-0.976, y
|
||||
// +-0.985, FB000000 then FF000000 (the Blacklist bio's panel, its fade in
|
||||
// from 11000000 short of minAlpha: the border shows for those ~15 frames,
|
||||
// as before). The nearest draws it must not take: the fades and dims
|
||||
// (+-1.29: past the band, the widen, stretch and cover rules' own), the
|
||||
// title key art (x +-1.104, y +-0.981, grey), the overlay at ~57 s (x
|
||||
// +-1.104, B8 alpha), the vid cam's noise and dark overlays (x +-0.70; 3C
|
||||
// alpha), its dialog boxes (x +-0.58), a full-band black layer (+-1.0, the
|
||||
// cover rule's).
|
||||
struct BlackoutRule
|
||||
{
|
||||
float centreMax = 0.01f; // |the box's centre|, each axis
|
||||
@@ -286,29 +302,28 @@ namespace gpu::bars
|
||||
}
|
||||
|
||||
// What a black frame fills, inside the draw's scissor: everything
|
||||
// outside the band, the rows above and below it across the whole width
|
||||
// (Vert+) or the columns beside it down the whole height (Hor+; at most
|
||||
// one of the view scales is below 1). The far edges reach kFillEdge.
|
||||
inline uint32_t BlackoutRects(const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[2])
|
||||
// outside its box, on every screen shape (16:9 too): the rows above and
|
||||
// below it across the whole width, then the columns beside it within its
|
||||
// rows; up to 4 rectangles, which never overlap and meet the box on the
|
||||
// same pixel edges (Edge) as its own rasterised pixels. The far edges
|
||||
// reach kFillEdge. (v0.1.1 to v0.1.5 filled only past the band: the
|
||||
// thin border inside it showed what was behind, as a 360's own output
|
||||
// does and a period TV's overscan hid: GitHub #19's Blacklist bio.)
|
||||
inline uint32_t BlackoutRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, Rect out[4])
|
||||
{
|
||||
uint32_t n = 0;
|
||||
auto add = [&](const Rect& r) {
|
||||
const Rect c = Intersect(r, scissor);
|
||||
if (!Empty(c))
|
||||
auto add = [&](float cx0, float cx1, float cy0, float cy1) {
|
||||
if (cx1 <= cx0 || cy1 <= cy0)
|
||||
return;
|
||||
const Rect c = Intersect(ClipToHost(cx0, cx1, cy0, cy1, ps, hostW, hostH), scissor);
|
||||
if (!Empty(c) && n < 4)
|
||||
out[n++] = c;
|
||||
};
|
||||
const float e = kFillEdge;
|
||||
if (scaleY < 1.0f)
|
||||
{
|
||||
add(ClipToHost(-e, e, scaleY, e, ps, hostW, hostH));
|
||||
add(ClipToHost(-e, e, -e, -scaleY, ps, hostW, hostH));
|
||||
}
|
||||
else if (scaleX < 1.0f)
|
||||
{
|
||||
add(ClipToHost(scaleX, e, -e, e, ps, hostW, hostH));
|
||||
add(ClipToHost(-e, -scaleX, -e, e, ps, hostW, hostH));
|
||||
}
|
||||
add(-e, e, b.y1, e); // past its top in clip y (either way up on screen)
|
||||
add(-e, e, -e, b.y0); // past its bottom
|
||||
add(-e, b.x0, b.y0, b.y1); // left of it, within its rows
|
||||
add(b.x1, e, b.y0, b.y1); // right of it
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -316,14 +331,20 @@ namespace gpu::bars
|
||||
// "WINNER" polaroid over asphalt and leaves; the title's and Controls'
|
||||
// key art have the same shape) is one opaque textured quad of the front
|
||||
// end, centred, x +-1.104 and y +-0.981 of the 16:9 band. On a 16:9
|
||||
// screen it hides the world (a thin border of it shows at the top and
|
||||
// bottom, as on the console, where a TV's overscan hid it); past the band
|
||||
// (Hor+ beside it, Vert+ above and below it) the world rendered behind
|
||||
// it showed: the race's road and cars at the sides of the results on an
|
||||
// iPhone (2868x1320). What a 16:9 screen shows of it is kept, the art's
|
||||
// own pixels too; everything else past the band (what no 16:9 screen
|
||||
// shows) is filled black, as a 16:9 picture's letterbox. Thresholds in
|
||||
// band units. The nearest draws it must not take: the fades and dims
|
||||
// screen it hides the world but for a thin border at the top and bottom
|
||||
// (y 0.981 to 1: ~7 of 720 rows each), where the race behind it shows;
|
||||
// a real 360's own output has that border too (HDMI captures of the
|
||||
// results screen), and a TV's overscan hid it. Past the band (Hor+
|
||||
// beside it, Vert+ above and below it) the world rendered behind it
|
||||
// showed as well: the race's road and cars at the sides of the results
|
||||
// on an iPhone (2868x1320). The art's own pixels are all kept, past the
|
||||
// band too; everything else is filled black on every screen shape, 16:9
|
||||
// too: past the band (what no 16:9 screen shows, as a 16:9 picture's
|
||||
// letterbox) and, inside it, the rows between the picture's top or
|
||||
// bottom edge and the band's (what a period TV's overscan hid). The
|
||||
// title's and Controls' key art have black around them already, so their
|
||||
// fill changes nothing on screen. Thresholds in band units. The nearest
|
||||
// draws it must not take: the fades and dims
|
||||
// (+-1.29: flat, and the widen, stretch and cover rules' own), the pause
|
||||
// dim (C8 alpha) and its gradients, the vid cam's noise and dark overlays
|
||||
// (3C alpha, x +-0.70), the overlay at the intro's start (x +-1.104, B8
|
||||
@@ -366,13 +387,14 @@ namespace gpu::bars
|
||||
return true;
|
||||
}
|
||||
|
||||
// What a backdrop fills, inside the draw's scissor: what lies past the
|
||||
// band (the columns beside it under Hor+, the rows above and below it
|
||||
// under Vert+; at most one of the view scales is below 1) and outside
|
||||
// the backdrop's own box, up to kFillEdge: per side, the part beyond the
|
||||
// box and, where the box reaches into that side, the parts above and
|
||||
// below it (Hor+; left and right of it under Vert+). At most 6.
|
||||
inline uint32_t BackdropRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
// What a backdrop fills past the band, inside the draw's scissor: what
|
||||
// lies past it (the columns beside it under Hor+, the rows above and
|
||||
// below it under Vert+; at most one of the view scales is below 1) and
|
||||
// outside the backdrop's own box, up to kFillEdge: per side, the part
|
||||
// beyond the box and, where the box reaches into that side, the parts
|
||||
// above and below it (Hor+; left and right of it under Vert+). At most
|
||||
// 6; none at 16:9.
|
||||
inline uint32_t PastBandRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[6])
|
||||
{
|
||||
uint32_t n = 0;
|
||||
@@ -410,6 +432,45 @@ namespace gpu::bars
|
||||
return n;
|
||||
}
|
||||
|
||||
// The overscan rows, inside the draw's scissor: inside the band, the
|
||||
// rows between the backdrop's top edge and the band's and between its
|
||||
// bottom edge and the band's (y +-0.981 to +-1 of the band for the
|
||||
// results picture), across the band's width. Where the band's edge is
|
||||
// the screen's (y at 16:9 and under Hor+, x at 16:9 and under Vert+)
|
||||
// they reach kFillEdge, as the fills past the band do (at 2x with
|
||||
// half-pixel centres the outermost row's centre lies past clip 1); where
|
||||
// it isn't, they stop at it and PastBandRects' rectangles go on from
|
||||
// there, meeting them on the same pixel edge (gpu::bars::Edge). None
|
||||
// where the box reaches the band's edge. At most 2.
|
||||
inline uint32_t OverscanRows(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[2])
|
||||
{
|
||||
uint32_t n = 0;
|
||||
auto add = [&](float cx0, float cx1, float cy0, float cy1) {
|
||||
if (cx1 <= cx0 || cy1 <= cy0)
|
||||
return;
|
||||
const Rect c = Intersect(ClipToHost(cx0, cx1, cy0, cy1, ps, hostW, hostH), scissor);
|
||||
if (!Empty(c) && n < 2)
|
||||
out[n++] = c;
|
||||
};
|
||||
const float e = kFillEdge;
|
||||
const float bandX = scaleX < 1.0f ? scaleX : e, bandY = scaleY < 1.0f ? scaleY : e;
|
||||
add(-bandX, bandX, b.y1, bandY);
|
||||
add(-bandX, bandX, -bandY, b.y0);
|
||||
return n;
|
||||
}
|
||||
|
||||
// What a backdrop fills, inside the draw's scissor: everything outside
|
||||
// its box but the columns inside the band beside it (none for a picture
|
||||
// as wide as the band): PastBandRects' and then OverscanRows'
|
||||
// rectangles, which never overlap. At most 8.
|
||||
inline uint32_t BackdropRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[8])
|
||||
{
|
||||
const uint32_t n = PastBandRects(b, ps, hostW, hostH, scissor, scaleX, scaleY, out);
|
||||
return n + OverscanRows(b, ps, hostW, hostH, scissor, scaleX, scaleY, out + n);
|
||||
}
|
||||
|
||||
// The posScale that stretches the quad's box over [-edge, edge] in clip
|
||||
// x and y (x' = ax x + bx, likewise y), folded in: NDC = x' ps0 + ps2 =
|
||||
// x (ax ps0) + (ps2 + bx ps0). Orientation is kept (ax, ay > 0) whatever
|
||||
@@ -442,24 +503,27 @@ namespace gpu::bars
|
||||
uint32_t blend0 = 0; // RB_BLENDCONTROL0 & 0x1FFF1FFF
|
||||
bool t0Sampled = false; // the pixel shader samples t0
|
||||
uint32_t t0Width = 0, t0Height = 0; // t0's size in texels
|
||||
uint32_t t0Format = 0; // t0's fetch constant format (6: k_8_8_8_8, 32-bit texels)
|
||||
float posScale[4] = {}; // the draw's own: NDC = clip * ps.xy + ps.zw
|
||||
float hostW = 0.0f, hostH = 0.0f; // the host viewport: the target's size times the render scale
|
||||
Rect scissor{}; // the draw's scissor (host pixels)
|
||||
};
|
||||
|
||||
enum class Fill : uint8_t { None, LeftBar, RightBar, Blackout, Backdrop };
|
||||
enum class Fill : uint8_t { None, LeftBar, RightBar, Blackout, Backdrop, Vignette };
|
||||
|
||||
// What to draw after the draw: for a bar or a black frame, `rectCount`
|
||||
// more copies of it (none for a bar that reaches the screen edge), with
|
||||
// `posScale` and each rectangle as the scissor; for a backdrop, each
|
||||
// rectangle cleared to opaque black (its picture must not be copied).
|
||||
// rectangle cleared to opaque black (its picture must not be copied);
|
||||
// for a vignette, nothing after it: the draw itself is drawn with
|
||||
// `posScale` instead of its own (no rectangles).
|
||||
struct Plan
|
||||
{
|
||||
Fill fill = Fill::None;
|
||||
Box box{}; // the quad's, clip space
|
||||
uint32_t argb = 0; // its colour
|
||||
uint32_t rectCount = 0;
|
||||
Rect rects[6]{};
|
||||
Rect rects[8]{}; // a bar's 2, a black frame's 4, a backdrop's 8
|
||||
float posScale[4]{};
|
||||
};
|
||||
|
||||
@@ -482,13 +546,14 @@ namespace gpu::bars
|
||||
}
|
||||
|
||||
// The register checks for a backdrop, before any vertex is read:
|
||||
// Candidate's, but over a large t0 (a picture, not the white texture).
|
||||
// Candidate's, but over a large t0 (a picture, not the white texture),
|
||||
// at every screen shape (16:9 too: its overscan rows).
|
||||
inline bool BackdropCandidate(const Draw& d, const BackdropRule& rule = {})
|
||||
{
|
||||
const float* c = d.c;
|
||||
const bool yScale = d.frontEndY > 0.0f ? std::fabs(std::fabs(c[5]) - d.frontEndY) <= d.frontEndY * 1e-5f
|
||||
: d.frontEndY == 0.0f && d.scaleY >= 1.0f;
|
||||
return (d.scaleX < 1.0f || d.scaleY < 1.0f) && !d.occlusion && d.prim == 13 && d.count == 4 && d.frontEndShader && d.leftAlone &&
|
||||
return !d.occlusion && d.prim == 13 && d.count == 4 && d.frontEndShader && d.leftAlone &&
|
||||
c[3] == 0.0f && c[4] == 0.0f && c[6] == 0.0f && c[7] == 0.0f && yScale && (d.vte & 0xF) == 0xF &&
|
||||
(d.blend0 == 0x07060706u || d.blend0 == 0x00010001u) && d.t0Sampled && d.t0Width >= rule.minTexels && d.t0Height >= rule.minTexels;
|
||||
}
|
||||
@@ -526,6 +591,145 @@ namespace gpu::bars
|
||||
return p;
|
||||
}
|
||||
|
||||
// The Showcase's vignette (Showcase.fng: the "Rotate / Zoom Out / Zoom In"
|
||||
// car viewer of the car lot, the customize shop and the Blacklist's rival
|
||||
// cars; GitHub #19): one front-end quad over the 3D garage, centred, x
|
||||
// +-0.970 and y +-0.958 of the 16:9 band (ten of the front end's 640x480
|
||||
// units in from each edge), its 256x256 k_8_8_8_8 texture black, clear in
|
||||
// the middle (every texel there 00000000) and darkening steeply toward its
|
||||
// edges (alpha 0.1-0.8 at the edge itself, 1.0 toward the corners), alpha-
|
||||
// blended (vertex colour 7F7F7F, its alpha fading in from 04 to FE). On a
|
||||
// 16:9 screen the garage shows undimmed in a thin border all round it; a
|
||||
// real 360's own output has that border too (an HDMI capture of the car
|
||||
// lot's Showcase: the same step at x +-0.970), and a period TV's overscan
|
||||
// (2.5-5% a side) hid it. Under Vert+ the garage rendered above and below
|
||||
// the band showed undimmed as well (7% of a MacBook's height at the top and
|
||||
// at the bottom, 55 rows of a Deck's 800): a bright rectangular frame round
|
||||
// the dimmed picture. The quad is stretched to the screen's edges (its box
|
||||
// to +-kFillEdge: CoverFrame), on every screen shape, 16:9 too, as a period
|
||||
// TV showed it (the results screen's overscan rows are filled the same way:
|
||||
// OverscanRows). Its middle is clear, so only its dark edges move out:
|
||||
// 3.1% in x and 4.4% in y at 16:9, y by 1/sv more under Vert+, x by 1/sx
|
||||
// more under Hor+. Thresholds in band units. The nearest draws it must not
|
||||
// take: the intro's black frames (x +-0.970, y +-0.939/0.920: opaque black
|
||||
// over the 32x32 white texture, and the black frame rule's), the title's
|
||||
// key art and the results picture (x +-1.104), the vid cam's noise and dark
|
||||
// overlays (x +-0.70), a full-band picture (+-1.0), an inset picture
|
||||
// (opaque in the middle: the texel check).
|
||||
struct VignetteRule
|
||||
{
|
||||
float centreMax = 0.01f; // |the box's centre|, each axis
|
||||
float extentMin = 0.90f, extentMax = 0.99f; // half its width and half its height
|
||||
uint32_t minTexels = 128; // t0 at least this wide and tall
|
||||
float cornerTolerance = 1e-3f; // clip units, as Rule's
|
||||
};
|
||||
|
||||
// A vignette's shape: one axis-aligned rectangle, one colour on all four
|
||||
// vertices (any alpha: it fades in), centred in the band and covering
|
||||
// most of it in x and y while stopping short of its edges. Its box goes
|
||||
// to `box`. (Its texture is the plan's check: PlanVignette.)
|
||||
inline bool MatchVignette(const Vertex v[4], float scaleX, float scaleY, const VignetteRule& rule = {}, Box* box = nullptr)
|
||||
{
|
||||
if (!(scaleX > 0.0f && scaleY > 0.0f))
|
||||
return false;
|
||||
for (int i = 1; i < 4; i++)
|
||||
if (v[i].argb != v[0].argb)
|
||||
return false;
|
||||
Box b;
|
||||
if (!RectangleBox(v, rule.cornerTolerance, b))
|
||||
return false;
|
||||
const float midX = (b.x0 + b.x1) * 0.5f / scaleX, midY = (b.y0 + b.y1) * 0.5f / scaleY;
|
||||
const float halfX = (b.x1 - b.x0) * 0.5f / scaleX, halfY = (b.y1 - b.y0) * 0.5f / scaleY;
|
||||
if (!(std::fabs(midX) <= rule.centreMax && std::fabs(midY) <= rule.centreMax))
|
||||
return false;
|
||||
if (!(halfX >= rule.extentMin && halfX <= rule.extentMax && halfY >= rule.extentMin && halfY <= rule.extentMax))
|
||||
return false;
|
||||
if (box)
|
||||
*box = b;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The byte offset of texel (x, y) in a 32-bit 2D texture: Xenos tiling
|
||||
// (gpu/renderer.cpp's GLSL tiled2D, pitch in texels, a multiple of 32)
|
||||
// or linear rows of `pitch` texels.
|
||||
inline uint32_t TexelOffset32(uint32_t x, uint32_t y, uint32_t pitch, bool tiled)
|
||||
{
|
||||
if (!tiled)
|
||||
return (y * pitch + x) * 4u;
|
||||
const uint32_t outer = (((y >> 5) * (pitch >> 5)) + (x >> 5)) << 6;
|
||||
const uint32_t inner = (((y >> 1) & 7u) << 3) | (x & 7u);
|
||||
const uint32_t oi = (outer | inner) << 2;
|
||||
const uint32_t bank = (y >> 4) & 1u;
|
||||
const uint32_t pipe = ((x >> 3) & 3u) ^ (((y >> 3) & 1u) << 1);
|
||||
return ((y & 1u) << 4) | (pipe << 6) | (bank << 11) | (oi & 0xFu) | (((oi >> 4) & 1u) << 5) | (((oi >> 5) & 7u) << 8) |
|
||||
((oi >> 8) << 12);
|
||||
}
|
||||
|
||||
// The texels a vignette's middle must hold clear (00000000, whatever the
|
||||
// endian): the centre and halfway from it to each edge. An inset
|
||||
// picture, opaque there, is no vignette.
|
||||
constexpr uint32_t kClearSamples = 5;
|
||||
inline void ClearSample(uint32_t i, uint32_t width, uint32_t height, uint32_t& x, uint32_t& y)
|
||||
{
|
||||
const uint32_t xs[kClearSamples] = { width / 2, width / 4, width * 3 / 4, width / 2, width / 2 };
|
||||
const uint32_t ys[kClearSamples] = { height / 2, height / 2, height / 2, height / 4, height * 3 / 4 };
|
||||
x = xs[i % kClearSamples];
|
||||
y = ys[i % kClearSamples];
|
||||
}
|
||||
|
||||
// The register checks for a vignette, before any vertex is read:
|
||||
// Candidate's, but alpha-blended (07060706: it is translucent), over a
|
||||
// large 32-bit t0 (format 6, k_8_8_8_8), at every screen shape.
|
||||
inline bool VignetteCandidate(const Draw& d, const VignetteRule& rule = {})
|
||||
{
|
||||
const float* c = d.c;
|
||||
const bool yScale = d.frontEndY > 0.0f ? std::fabs(std::fabs(c[5]) - d.frontEndY) <= d.frontEndY * 1e-5f
|
||||
: d.frontEndY == 0.0f && d.scaleY >= 1.0f;
|
||||
return !d.occlusion && d.prim == 13 && d.count == 4 && d.frontEndShader && d.leftAlone && c[3] == 0.0f && c[4] == 0.0f &&
|
||||
c[6] == 0.0f && c[7] == 0.0f && yScale && (d.vte & 0xF) == 0xF && d.blend0 == 0x07060706u && d.t0Sampled && d.t0Format == 6 &&
|
||||
d.t0Width >= rule.minTexels && d.t0Height >= rule.minTexels;
|
||||
}
|
||||
|
||||
// The plan for a vignette: the draw's posScale stretched so its box
|
||||
// reaches past every screen edge (CoverFrame), no rectangles. read: as
|
||||
// PlanFill's; texel(x, y, word): t0's 32-bit texel (x, y), false if it
|
||||
// can't be read. Vertices first (as PlanBackdrop: one quad over a large
|
||||
// t0 gets this far), then the texels of its middle.
|
||||
template <class ReadWord, class ReadTexel>
|
||||
Plan PlanVignette(const Draw& d, ReadWord&& read, ReadTexel&& texel, const VignetteRule& rule = {})
|
||||
{
|
||||
Plan p;
|
||||
if (!VignetteCandidate(d, rule))
|
||||
return p;
|
||||
const float* c = d.c;
|
||||
Vertex v[4];
|
||||
for (uint32_t k = 0; k < 4; k++)
|
||||
{
|
||||
uint32_t w[4];
|
||||
for (uint32_t j = 0; j < 4; j++)
|
||||
if (!read(k, j, w[j]))
|
||||
return p;
|
||||
float q[3];
|
||||
std::memcpy(q, w, sizeof(q));
|
||||
v[k] = { q[0] * c[0] + q[1] * c[1] + q[2] * c[2] + c[3], q[0] * c[4] + q[1] * c[5] + q[2] * c[6] + c[7], w[3] };
|
||||
}
|
||||
Box box{};
|
||||
if (!MatchVignette(v, d.scaleX, d.scaleY, rule, &box))
|
||||
return p;
|
||||
for (uint32_t i = 0; i < kClearSamples; i++)
|
||||
{
|
||||
uint32_t x, y, word = 1;
|
||||
ClearSample(i, d.t0Width, d.t0Height, x, y);
|
||||
if (!texel(x, y, word) || word != 0)
|
||||
return p;
|
||||
}
|
||||
p.fill = Fill::Vignette;
|
||||
p.box = box;
|
||||
p.argb = v[0].argb;
|
||||
CoverFrame(box, d.posScale, p.posScale);
|
||||
return p;
|
||||
}
|
||||
|
||||
// The plan for a draw. read(k, j, word): word j of the draw's vertex k
|
||||
// (k 0..3 in draw order, through its indices; an ePoly's words 0..3 are
|
||||
// x, y, z and the colour), false past the vertex buffer. Register checks
|
||||
@@ -561,7 +765,7 @@ namespace gpu::bars
|
||||
else if (MatchBlackout(v, d.scaleX, d.scaleY, {}, &box))
|
||||
{
|
||||
p.fill = Fill::Blackout;
|
||||
p.rectCount = BlackoutRects(d.posScale, d.hostW, d.hostH, d.scissor, d.scaleX, d.scaleY, p.rects);
|
||||
p.rectCount = BlackoutRects(box, d.posScale, d.hostW, d.hostH, d.scissor, p.rects);
|
||||
}
|
||||
else
|
||||
return p;
|
||||
|
||||
+184
-97
@@ -7832,8 +7832,131 @@ void main()
|
||||
// stretched in y alone it would be distorted by up to 1/sv. The rest
|
||||
// of the front end is clipped to the band (below).
|
||||
// Edge bars (gpu/edge_bars.h): the copies of a police vid-cam side
|
||||
// bar or the intro's black frame that fill the screen past the 16:9
|
||||
// band, drawn right after it.
|
||||
// bar that fill the screen past the 16:9 band, and of a black frame
|
||||
// (the intro's cuts, the Blacklist bio's panel) that fill everything
|
||||
// outside it, at 16:9 too, drawn right after it; the black around a
|
||||
// full-band backdrop (the results screen), cleared right after it,
|
||||
// at 16:9 too; and the Showcase's vignette, stretched to the screen's
|
||||
// edges. NFSMW_BAR_FILL=0 turns all the edge fills off (an A/B
|
||||
// switch).
|
||||
// planEdgeFills: the plan for a front-end draw (its registers, what
|
||||
// the rules below did, a reader for its ePolys): PlanFill's bars and
|
||||
// black frames first, then PlanBackdrop's full-band picture, then
|
||||
// PlanVignette's overlay (every screen shape; a bar fills nothing at
|
||||
// 16:9).
|
||||
// logEdgeFill: what a plan does, once per kind, screen shape and
|
||||
// outcome, in every log (a tester's too).
|
||||
static const bool barFill = [] { const char* v = std::getenv("NFSMW_BAR_FILL"); return !(v && v[0] == '0'); }();
|
||||
auto planEdgeFills = [&](const float* c, float feY, float aspectX, float aspectY, bool feShader, bool leftAlone) {
|
||||
xenos::xe_gpu_texture_fetch_t t0;
|
||||
memcpy(&t0, &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0], sizeof(t0));
|
||||
const gpu::bars::Draw draw{ .prim = uint32_t(initiator.prim_type),
|
||||
.count = count,
|
||||
.c = c,
|
||||
.frontEndY = feY,
|
||||
.scaleX = aspectX,
|
||||
.scaleY = aspectY,
|
||||
.frontEndShader = feShader,
|
||||
.leftAlone = leftAlone,
|
||||
.occlusion = zpdCount,
|
||||
.vte = vte.value,
|
||||
.blend0 = s_regs[XE_GPU_REG_RB_BLENDCONTROL0] & 0x1FFF1FFF,
|
||||
.t0Sampled = (s_ps->info.texture2DMask & 1) != 0,
|
||||
.t0Width = uint32_t(t0.size_2d.width) + 1,
|
||||
.t0Height = uint32_t(t0.size_2d.height) + 1,
|
||||
.t0Format = uint32_t(t0.format),
|
||||
.posScale = { pc.posScale[0], pc.posScale[1], pc.posScale[2], pc.posScale[3] },
|
||||
.hostW = w * float(scaleX),
|
||||
.hostH = h * float(scaleY),
|
||||
.scissor = { scissor.offset.x, scissor.offset.y, scissor.offset.x + int32_t(scissor.extent.width),
|
||||
scissor.offset.y + int32_t(scissor.extent.height) } };
|
||||
const uint32_t* vf = &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0 + 95 * 2];
|
||||
const uint32_t base = vf[0] & 0x1FFFFFFC, endian = vf[1] & 3;
|
||||
const uint32_t words = (vf[1] >> 2) & 0xFFFFFF, first = s_regs[XE_GPU_REG_VGT_INDX_OFFSET];
|
||||
auto read = [&](uint32_t k, uint32_t j, uint32_t& word) {
|
||||
const uint32_t v = src.Get(k) + first; // an ePoly: 8 words
|
||||
if ((v + 1) * 8 > words)
|
||||
return false;
|
||||
word = GpuSwap(LoadPhysical(base + (v * 8 + j) * 4), endian);
|
||||
return true;
|
||||
};
|
||||
gpu::bars::Plan plan = gpu::bars::PlanFill(draw, read);
|
||||
// The results screen's backdrop (and any opaque picture of that
|
||||
// shape: the title's key art): what lies outside the picture,
|
||||
// past the band and in the band's overscan rows above and below
|
||||
// it, is cleared to black after it, where the world drawn behind
|
||||
// it showed (gpu/edge_bars.h).
|
||||
if (plan.fill == gpu::bars::Fill::None)
|
||||
plan = gpu::bars::PlanBackdrop(draw, read);
|
||||
// The Showcase's vignette (and any front-end overlay of its
|
||||
// shape, clear in the middle): stretched to the screen's edges,
|
||||
// where the garage showed undimmed round it (GitHub #19). Its
|
||||
// texels as the GPU reads them: 32-bit, tiled or linear rows.
|
||||
if (plan.fill == gpu::bars::Fill::None)
|
||||
{
|
||||
const uint32_t t0Base = (uint32_t(t0.base_address) << 12) & 0x1FFFFFFFu;
|
||||
const uint32_t t0Pitch = uint32_t(t0.pitch) << 5;
|
||||
auto texel = [&](uint32_t x, uint32_t y, uint32_t& word) {
|
||||
if (!t0Pitch || x >= draw.t0Width || y >= draw.t0Height || x >= t0Pitch)
|
||||
return false;
|
||||
word = GpuSwap(LoadPhysical(t0Base + gpu::bars::TexelOffset32(x, y, t0Pitch, t0.tiled != 0)), uint32_t(t0.endianness));
|
||||
return true;
|
||||
};
|
||||
plan = gpu::bars::PlanVignette(draw, read, texel);
|
||||
}
|
||||
return plan;
|
||||
};
|
||||
auto logEdgeFill = [&](const gpu::bars::Plan& barPlan, float aspectX, float aspectY) {
|
||||
if (barPlan.fill == gpu::bars::Fill::None)
|
||||
return;
|
||||
static const bool logFrontEnd = std::getenv("NFSMW_LOG_FRONTEND") != nullptr;
|
||||
// A match each frame skips building the line once its key is
|
||||
// logged.
|
||||
const uint32_t kind = uint32_t(barPlan.fill);
|
||||
const bool blackout = barPlan.fill == gpu::bars::Fill::Blackout;
|
||||
const bool backdrop = barPlan.fill == gpu::bars::Fill::Backdrop;
|
||||
const bool vignette = barPlan.fill == gpu::bars::Fill::Vignette;
|
||||
const gpu::bars::Box& box = barPlan.box;
|
||||
const uint64_t key = (0x36ull << 56) | (uint64_t(kind) << 40) | (uint64_t(barPlan.rectCount) << 36) |
|
||||
(uint64_t(std::lround(aspectX * 4096.0f)) << 18) | uint64_t(std::lround(aspectY * 4096.0f));
|
||||
static uint64_t loggedKey[6] = { ~0ull, ~0ull, ~0ull, ~0ull, ~0ull, ~0ull };
|
||||
if (loggedKey[kind] != key)
|
||||
{
|
||||
loggedKey[kind] = key;
|
||||
char fills[192];
|
||||
if (barPlan.rectCount)
|
||||
snprintf(fills, sizeof(fills), "fills %u rectangle(s) %s, first [%d,%d)x[%d,%d) (host px)", barPlan.rectCount,
|
||||
backdrop ? "outside it (past the 16:9 band, and the band's overscan rows)"
|
||||
: blackout ? "outside it (past the 16:9 band, and the band's border round it)" : "past the 16:9 band", barPlan.rects[0].x0,
|
||||
barPlan.rects[0].x1, barPlan.rects[0].y0, barPlan.rects[0].y1);
|
||||
else
|
||||
snprintf(fills, sizeof(fills), "%s", vignette ? "stretched to the screen's edges"
|
||||
: blackout || backdrop ? "fills nothing" : "reaches the screen edge: nothing to fill");
|
||||
LogOnce(key,
|
||||
"edge bars: frame %llu vs_%016llx ps_%016llx: %s (clip x %.3f..%.3f y %.3f..%.3f, colour %08X; view scale x %.4f y %.4f) %s",
|
||||
(unsigned long long)s_frame, (unsigned long long)s_vs->hash, (unsigned long long)s_ps->hash,
|
||||
vignette ? "the Showcase's vignette (clear in the middle)" : backdrop ? "a full-band backdrop (black outside it)"
|
||||
: blackout ? "a black frame" : kind == uint32_t(gpu::bars::Fill::RightBar) ? "the right vid-cam bar" : "the left vid-cam bar",
|
||||
box.x0, box.x1, box.y0, box.y1, barPlan.argb, aspectX, aspectY, fills);
|
||||
}
|
||||
// NFSMW_LOG_FRONTEND=1: the first frame of each run of frames
|
||||
// with a match, bars and black frames apart (one run per vid-cam
|
||||
// scene and per cut; any other is a draw this should not take).
|
||||
static uint64_t lastBarFrame[4] = { ~0ull - 1, ~0ull - 1, ~0ull - 1, ~0ull - 1 };
|
||||
uint64_t& last = lastBarFrame[vignette ? 3 : backdrop ? 2 : blackout ? 1 : 0];
|
||||
if (logFrontEnd && s_frame != last && s_frame != last + 1)
|
||||
{
|
||||
xenos::xe_gpu_texture_fetch_t t0;
|
||||
memcpy(&t0, &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0], sizeof(t0));
|
||||
fprintf(stderr, "[frontend] edge bars: matching from frame %llu: %s x %.3f..%.3f y %.3f..%.3f colour %08X blend %08X t0 %08X %ux%u, %u rect(s)\n",
|
||||
(unsigned long long)s_frame, vignette ? "vignette" : backdrop ? "backdrop" : blackout ? "black frame"
|
||||
: kind == uint32_t(gpu::bars::Fill::RightBar) ? "right bar" : "left bar",
|
||||
box.x0, box.x1, box.y0, box.y1, barPlan.argb, s_regs[XE_GPU_REG_RB_BLENDCONTROL0] & 0x1FFF1FFF,
|
||||
(uint32_t(t0.base_address) << 12) & 0x1FFFFFFFu, uint32_t(t0.size_2d.width) + 1, uint32_t(t0.size_2d.height) + 1,
|
||||
barPlan.rectCount);
|
||||
}
|
||||
last = s_frame;
|
||||
};
|
||||
gpu::bars::Plan barPlan;
|
||||
if (const float s = game::AspectScaleX(), sv = game::AspectScaleY(); s < 1.0f || sv < 1.0f)
|
||||
{
|
||||
@@ -8127,102 +8250,27 @@ void main()
|
||||
// past a 2.50:1 screen beside them. The intro's cut
|
||||
// between two shots (~95 s) is a few frames of one black
|
||||
// quad 0.970 x 0.939 of the 16:9 frame: the world shows
|
||||
// all round it. Such a quad (gpu::bars::PlanFill: one
|
||||
// black rectangle of the front-end shader the rules
|
||||
// above left alone; a bar a little past both band edges
|
||||
// in y, from inside the band to well past one side; a
|
||||
// black frame centred, short of the band's edges) is
|
||||
// drawn again after itself, stretched over the frame and
|
||||
// scissored to (its half of) what lies outside the band:
|
||||
// nothing inside the band changes.
|
||||
// NFSMW_BAR_FILL=0 turns it off (an A/B switch).
|
||||
static const bool barFill = [] { const char* v = std::getenv("NFSMW_BAR_FILL"); return !(v && v[0] == '0'); }();
|
||||
// all round it (and the Blacklist bio's panel, 0.976 x
|
||||
// 0.985, for as long as it is up). Such a quad
|
||||
// (gpu::bars::PlanFill: one black rectangle of the
|
||||
// front-end shader the rules above left alone; a bar a
|
||||
// little past both band edges in y, from inside the band
|
||||
// to well past one side; a black frame centred, short of
|
||||
// the band's edges) is drawn again after itself,
|
||||
// stretched over the frame and scissored to (its half of)
|
||||
// what lies outside the band (a bar: nothing inside the
|
||||
// band changes) or to everything outside itself (a black
|
||||
// frame). The Showcase's vignette
|
||||
// (gpu::bars::PlanVignette) is drawn stretched to the
|
||||
// screen's edges instead (its own posScale).
|
||||
// NFSMW_BAR_FILL=0 turns it off (an A/B switch: barFill).
|
||||
const uint32_t blend0 = s_regs[XE_GPU_REG_RB_BLENDCONTROL0] & 0x1FFF1FFF;
|
||||
if (barFill)
|
||||
{
|
||||
xenos::xe_gpu_texture_fetch_t t0;
|
||||
memcpy(&t0, &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0], sizeof(t0));
|
||||
const gpu::bars::Draw draw{ .prim = prim,
|
||||
.count = count,
|
||||
.c = c,
|
||||
.frontEndY = feY,
|
||||
.scaleX = s,
|
||||
.scaleY = sv,
|
||||
.frontEndShader = feShader,
|
||||
.leftAlone = kX == 1.0f && kY == 1.0f && kCover == 1.0f && !clipped,
|
||||
.occlusion = zpdCount,
|
||||
.vte = vte.value,
|
||||
.blend0 = blend0,
|
||||
.t0Sampled = (s_ps->info.texture2DMask & 1) != 0,
|
||||
.t0Width = uint32_t(t0.size_2d.width) + 1,
|
||||
.t0Height = uint32_t(t0.size_2d.height) + 1,
|
||||
.posScale = { pc.posScale[0], pc.posScale[1], pc.posScale[2], pc.posScale[3] },
|
||||
.hostW = w * float(scaleX),
|
||||
.hostH = h * float(scaleY),
|
||||
.scissor = { scissor.offset.x, scissor.offset.y, scissor.offset.x + int32_t(scissor.extent.width),
|
||||
scissor.offset.y + int32_t(scissor.extent.height) } };
|
||||
const uint32_t base = vf[0] & 0x1FFFFFFC, endian = vf[1] & 3;
|
||||
const uint32_t words = (vf[1] >> 2) & 0xFFFFFF, first = s_regs[XE_GPU_REG_VGT_INDX_OFFSET];
|
||||
auto read = [&](uint32_t k, uint32_t j, uint32_t& word) {
|
||||
const uint32_t v = src.Get(k) + first; // an ePoly: 8 words
|
||||
if ((v + 1) * 8 > words)
|
||||
return false;
|
||||
word = GpuSwap(LoadPhysical(base + (v * 8 + j) * 4), endian);
|
||||
return true;
|
||||
};
|
||||
barPlan = gpu::bars::PlanFill(draw, read);
|
||||
// The results screen's backdrop (and any opaque
|
||||
// picture of that shape: the title's key art): what
|
||||
// lies past the band and outside the picture is
|
||||
// cleared to black after it, where the world drawn
|
||||
// behind it showed (gpu/edge_bars.h).
|
||||
if (barPlan.fill == gpu::bars::Fill::None)
|
||||
barPlan = gpu::bars::PlanBackdrop(draw, read);
|
||||
}
|
||||
if (barPlan.fill != gpu::bars::Fill::None)
|
||||
{
|
||||
// Once per kind, screen shape and outcome, in every
|
||||
// log (a tester's too); a match each frame skips
|
||||
// building the line once its key is logged.
|
||||
const uint32_t kind = uint32_t(barPlan.fill);
|
||||
const bool blackout = barPlan.fill == gpu::bars::Fill::Blackout;
|
||||
const bool backdrop = barPlan.fill == gpu::bars::Fill::Backdrop;
|
||||
const gpu::bars::Box& box = barPlan.box;
|
||||
const uint64_t key = (0x36ull << 56) | (uint64_t(kind) << 40) | (uint64_t(barPlan.rectCount) << 36) |
|
||||
(uint64_t(std::lround(s * 4096.0f)) << 18) | uint64_t(std::lround(sv * 4096.0f));
|
||||
static uint64_t loggedKey[5] = { ~0ull, ~0ull, ~0ull, ~0ull, ~0ull };
|
||||
if (loggedKey[kind] != key)
|
||||
{
|
||||
loggedKey[kind] = key;
|
||||
char fills[96];
|
||||
if (barPlan.rectCount)
|
||||
snprintf(fills, sizeof(fills), "fills %u rectangle(s) past the 16:9 band, first [%d,%d)x[%d,%d) (host px)",
|
||||
barPlan.rectCount, barPlan.rects[0].x0, barPlan.rects[0].x1, barPlan.rects[0].y0, barPlan.rects[0].y1);
|
||||
else
|
||||
snprintf(fills, sizeof(fills), "%s", blackout || backdrop ? "fills nothing" : "reaches the screen edge: nothing to fill");
|
||||
LogOnce(key,
|
||||
"edge bars: frame %llu vs_%016llx ps_%016llx: %s (clip x %.3f..%.3f y %.3f..%.3f, colour %08X; view scale x %.4f y %.4f) %s",
|
||||
(unsigned long long)s_frame, (unsigned long long)s_vs->hash, (unsigned long long)s_ps->hash,
|
||||
backdrop ? "a full-band backdrop (black past the band)" : blackout ? "a black frame"
|
||||
: kind == uint32_t(gpu::bars::Fill::RightBar) ? "the right vid-cam bar" : "the left vid-cam bar",
|
||||
box.x0, box.x1, box.y0, box.y1, barPlan.argb, s, sv, fills);
|
||||
}
|
||||
// NFSMW_LOG_FRONTEND=1: the first frame of each
|
||||
// run of frames with a match, bars and black frames
|
||||
// apart (one run per vid-cam scene and per cut; any
|
||||
// other is a draw this should not take).
|
||||
static uint64_t lastBarFrame[3] = { ~0ull - 1, ~0ull - 1, ~0ull - 1 };
|
||||
uint64_t& last = lastBarFrame[backdrop ? 2 : blackout ? 1 : 0];
|
||||
if (logFrontEnd && s_frame != last && s_frame != last + 1)
|
||||
{
|
||||
xenos::xe_gpu_texture_fetch_t t0;
|
||||
memcpy(&t0, &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0], sizeof(t0));
|
||||
fprintf(stderr, "[frontend] edge bars: matching from frame %llu: %s x %.3f..%.3f y %.3f..%.3f colour %08X blend %08X t0 %08X %ux%u, %u rect(s)\n",
|
||||
(unsigned long long)s_frame, backdrop ? "backdrop" : blackout ? "black frame" : kind == uint32_t(gpu::bars::Fill::RightBar) ? "right bar" : "left bar",
|
||||
box.x0, box.x1, box.y0, box.y1, barPlan.argb, blend0, (uint32_t(t0.base_address) << 12) & 0x1FFFFFFFu,
|
||||
uint32_t(t0.size_2d.width) + 1, uint32_t(t0.size_2d.height) + 1, barPlan.rectCount);
|
||||
}
|
||||
last = s_frame;
|
||||
barPlan = planEdgeFills(c, feY, s, sv, feShader, kX == 1.0f && kY == 1.0f && kCover == 1.0f && !clipped);
|
||||
logEdgeFill(barPlan, s, sv);
|
||||
if (barPlan.fill == gpu::bars::Fill::Vignette)
|
||||
memcpy(pc.posScale, barPlan.posScale, sizeof(pc.posScale));
|
||||
}
|
||||
// The edge candidates (above) that still stop short of
|
||||
// the screen's edge after the rules, each distinct line
|
||||
@@ -8273,7 +8321,8 @@ void main()
|
||||
uint32_t& perT0Edge = perKey[(uint64_t(t0) << 32) | (kind << 3) | edgeCode];
|
||||
if (perT0Edge >= 4)
|
||||
continue;
|
||||
const std::string action = barPlan.rectCount
|
||||
const std::string action = barPlan.fill == gpu::bars::Fill::Vignette ? std::string("vignette stretched to the screen's edges")
|
||||
: barPlan.rectCount
|
||||
? std::string(barPlan.fill == gpu::bars::Fill::Backdrop ? "backdrop fill"
|
||||
: barPlan.fill == gpu::bars::Fill::Blackout ? "black frame fill" : "bar fill")
|
||||
: clipped ? std::format("clipped to rows {}..{}", scissor.offset.y, scissor.offset.y + int32_t(scissor.extent.height))
|
||||
@@ -8361,6 +8410,42 @@ void main()
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (barFill)
|
||||
{
|
||||
// 16:9 (or a 16:9 picture letterboxed on another shape): none of
|
||||
// the rules above has anything to do, and no bar needs a fill,
|
||||
// but a black frame leaves a thin border of what is behind it all
|
||||
// round it: filled with its copies (gpu::bars::BlackoutRects); a
|
||||
// full-band backdrop (the results screen) leaves the race showing
|
||||
// in the band's overscan rows above and below it: those are
|
||||
// cleared to black (gpu::bars::OverscanRows); and the Showcase's
|
||||
// vignette leaves a thin border of the garage undimmed all round
|
||||
// it: it is stretched to the screen's edges
|
||||
// (gpu::bars::PlanVignette).
|
||||
// A front-end draw as above (the front-end matrix in c0..c3,
|
||||
// ePolys at fetch constant 95, read by the vertex shader); nothing
|
||||
// is measured, and only one quad gets as far as reading its
|
||||
// vertices: over a small t0 its first colour only (a black frame
|
||||
// needs black, gpu::bars::Candidate), over a large t0
|
||||
// (gpu::bars::BackdropCandidate, VignetteCandidate) all four.
|
||||
if (const float feX = game::FrontEndScaleX(); feX > 0.0f)
|
||||
{
|
||||
float c[16];
|
||||
memcpy(c, &s_regs[XE_GPU_REG_SHADER_CONSTANT_000_X], sizeof(c));
|
||||
const uint32_t* vf = &s_regs[XE_GPU_REG_SHADER_CONSTANT_FETCH_00_0 + 95 * 2];
|
||||
if (std::fabs(c[0] - feX) <= feX * 1e-5f && c[1] == 0.0f && c[2] == 0.0f &&
|
||||
c[12] == 0.0f && c[13] == 0.0f && c[14] == 0.0f && c[15] == 1.0f && ((vf[0] & 3) == 3))
|
||||
{
|
||||
const auto& vi = s_vs->info;
|
||||
const bool feShader = ((vi.vertexFetchConstants[95 / 32] >> (95 % 32)) & 1) &&
|
||||
(vi.constRelative || (vi.constUsed[0] & 0xFull) == 0xFull);
|
||||
barPlan = planEdgeFills(c, game::FrontEndScaleY(), s, sv, feShader, true);
|
||||
logEdgeFill(barPlan, s, sv);
|
||||
if (barPlan.fill == gpu::bars::Fill::Vignette)
|
||||
memcpy(pc.posScale, barPlan.posScale, sizeof(pc.posScale));
|
||||
}
|
||||
}
|
||||
}
|
||||
// NFSMW_LOG_DRAWS=<seconds>: every draw of one frame (target, viewport,
|
||||
// scissor, textures, shaders), consecutive duplicates folded.
|
||||
if (s_logDrawsNow)
|
||||
@@ -8373,6 +8458,8 @@ void main()
|
||||
if (barPlan.rectCount)
|
||||
line += std::format(" +edge {} fill x{}", barPlan.fill == gpu::bars::Fill::Backdrop ? "backdrop"
|
||||
: barPlan.fill == gpu::bars::Fill::Blackout ? "black frame" : "bar", barPlan.rectCount);
|
||||
else if (barPlan.fill == gpu::bars::Fill::Vignette)
|
||||
line += " +vignette stretched";
|
||||
static std::string last;
|
||||
static uint32_t repeats = 0;
|
||||
if (line == last)
|
||||
@@ -8600,7 +8687,7 @@ void main()
|
||||
ca[attachments].clearValue.color = { { 0.0f, 0.0f, 0.0f, 1.0f } };
|
||||
attachments++;
|
||||
}
|
||||
VkClearRect cr[6]{};
|
||||
VkClearRect cr[std::extent_v<decltype(gpu::bars::Plan::rects)>]{};
|
||||
for (uint32_t i = 0; i < barPlan.rectCount; i++)
|
||||
{
|
||||
const gpu::bars::Rect& r = barPlan.rects[i];
|
||||
|
||||
+37
-22
@@ -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,32 +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;
|
||||
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
-3
@@ -8,9 +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, C = right stick click.
|
||||
// - Keyboard: each Xbox 360 control has two keys (Settings > Controls;
|
||||
// the [keys] table of settings.toml; hid/key_map.h lists them).
|
||||
// Defaults: arrows = D-pad, A/D and Left/Right = steer, Up/Down = left
|
||||
// stick too, W/S = gas/brake (RT/LT), Space = A, Backspace = B,
|
||||
// Left Shift = X, Left Ctrl = Y, Q/E = LB/RB, Enter = START, Esc = BACK,
|
||||
// O/L/K/; = right stick, Z/C = the stick clicks. A key may drive several.
|
||||
// - NFSMW_INPUT_SCRIPT="12:START,15.5:A,30-90/4:DOWN,100~160:RT": taps
|
||||
// (150 ms) at seconds since launch, taps every `step` seconds over a
|
||||
// range, or inputs held over a range; RT/LT (triggers) and LL/LR/LU/LD
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The keyboard as an Xbox 360 controller (Settings > Controls): the 24
|
||||
// controls a key can drive, in the page's order, each with the two settings
|
||||
// that hold its keys (user/settings.h, the [keys] table of settings.toml),
|
||||
// and how a set of held keys becomes buttons, triggers and stick axes.
|
||||
// Header only and SDL-free: hid/input.cpp samples the keys and feeds the
|
||||
// result to the pad; ui/settings_menu.cpp draws one row per entry;
|
||||
// tests/key_map_test.cpp checks the rules.
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include <user/settings.h>
|
||||
|
||||
namespace hid::keymap
|
||||
{
|
||||
enum class Control : uint8_t
|
||||
{
|
||||
RightTrigger, LeftTrigger,
|
||||
LeftStickLeft, LeftStickRight, LeftStickUp, LeftStickDown,
|
||||
A, B, X, Y, LB, RB,
|
||||
DpadUp, DpadDown, DpadLeft, DpadRight,
|
||||
Start, Back,
|
||||
RightStickUp, RightStickDown, RightStickLeft, RightStickRight,
|
||||
LeftStickClick, RightStickClick,
|
||||
Count
|
||||
};
|
||||
constexpr size_t kControls = size_t(Control::Count);
|
||||
|
||||
enum class Role : uint8_t { Button, LeftTrigger, RightTrigger, AxisNeg, AxisPos };
|
||||
enum class Axis : uint8_t { LX, LY, RX, RY, Count };
|
||||
|
||||
// XInput's button bits, as the game reads them (xbox.h's
|
||||
// XAMINPUT_GAMEPAD_*; input.cpp asserts they agree). Literals here so
|
||||
// this header needs no kernel header.
|
||||
namespace button
|
||||
{
|
||||
constexpr uint16_t DpadUp = 0x0001, DpadDown = 0x0002, DpadLeft = 0x0004, DpadRight = 0x0008;
|
||||
constexpr uint16_t Start = 0x0010, Back = 0x0020, LeftThumb = 0x0040, RightThumb = 0x0080;
|
||||
constexpr uint16_t LeftShoulder = 0x0100, RightShoulder = 0x0200;
|
||||
constexpr uint16_t A = 0x1000, B = 0x2000, X = 0x4000, Y = 0x8000;
|
||||
}
|
||||
|
||||
struct Entry
|
||||
{
|
||||
Control control;
|
||||
settings::Id id; // the option holding its first key
|
||||
settings::Id id2; // ...and its second (always id + 1)
|
||||
Role role;
|
||||
uint16_t button = 0; // Role::Button
|
||||
Axis axis = Axis::LX; // Role::AxisNeg, AxisPos
|
||||
};
|
||||
|
||||
constexpr Entry kEntries[kControls] = {
|
||||
{ Control::RightTrigger, settings::Id::KeyRightTrigger, settings::Id::KeyRightTrigger2, Role::RightTrigger },
|
||||
{ Control::LeftTrigger, settings::Id::KeyLeftTrigger, settings::Id::KeyLeftTrigger2, Role::LeftTrigger },
|
||||
{ Control::LeftStickLeft, settings::Id::KeyLeftStickLeft, settings::Id::KeyLeftStickLeft2, Role::AxisNeg, 0, Axis::LX },
|
||||
{ Control::LeftStickRight, settings::Id::KeyLeftStickRight, settings::Id::KeyLeftStickRight2, Role::AxisPos, 0, Axis::LX },
|
||||
{ Control::LeftStickUp, settings::Id::KeyLeftStickUp, settings::Id::KeyLeftStickUp2, Role::AxisPos, 0, Axis::LY },
|
||||
{ Control::LeftStickDown, settings::Id::KeyLeftStickDown, settings::Id::KeyLeftStickDown2, Role::AxisNeg, 0, Axis::LY },
|
||||
{ Control::A, settings::Id::KeyA, settings::Id::KeyA2, Role::Button, button::A },
|
||||
{ Control::B, settings::Id::KeyB, settings::Id::KeyB2, Role::Button, button::B },
|
||||
{ Control::X, settings::Id::KeyX, settings::Id::KeyX2, Role::Button, button::X },
|
||||
{ Control::Y, settings::Id::KeyY, settings::Id::KeyY2, Role::Button, button::Y },
|
||||
{ Control::LB, settings::Id::KeyLB, settings::Id::KeyLB2, Role::Button, button::LeftShoulder },
|
||||
{ Control::RB, settings::Id::KeyRB, settings::Id::KeyRB2, Role::Button, button::RightShoulder },
|
||||
{ Control::DpadUp, settings::Id::KeyDpadUp, settings::Id::KeyDpadUp2, Role::Button, button::DpadUp },
|
||||
{ Control::DpadDown, settings::Id::KeyDpadDown, settings::Id::KeyDpadDown2, Role::Button, button::DpadDown },
|
||||
{ Control::DpadLeft, settings::Id::KeyDpadLeft, settings::Id::KeyDpadLeft2, Role::Button, button::DpadLeft },
|
||||
{ Control::DpadRight, settings::Id::KeyDpadRight, settings::Id::KeyDpadRight2, Role::Button, button::DpadRight },
|
||||
{ Control::Start, settings::Id::KeyStart, settings::Id::KeyStart2, Role::Button, button::Start },
|
||||
{ Control::Back, settings::Id::KeyBack, settings::Id::KeyBack2, Role::Button, button::Back },
|
||||
{ Control::RightStickUp, settings::Id::KeyRightStickUp, settings::Id::KeyRightStickUp2, Role::AxisPos, 0, Axis::RY },
|
||||
{ Control::RightStickDown, settings::Id::KeyRightStickDown, settings::Id::KeyRightStickDown2, Role::AxisNeg, 0, Axis::RY },
|
||||
{ Control::RightStickLeft, settings::Id::KeyRightStickLeft, settings::Id::KeyRightStickLeft2, Role::AxisNeg, 0, Axis::RX },
|
||||
{ Control::RightStickRight, settings::Id::KeyRightStickRight, settings::Id::KeyRightStickRight2, Role::AxisPos, 0, Axis::RX },
|
||||
{ Control::LeftStickClick, settings::Id::KeyLeftStickClick, settings::Id::KeyLeftStickClick2, Role::Button, button::LeftThumb },
|
||||
{ Control::RightStickClick, settings::Id::KeyRightStickClick, settings::Id::KeyRightStickClick2, Role::Button, button::RightThumb },
|
||||
};
|
||||
|
||||
consteval bool EntriesInOrder()
|
||||
{
|
||||
for (size_t i = 0; i < kControls; i++)
|
||||
if (size_t(kEntries[i].control) != i || size_t(kEntries[i].id) != size_t(settings::Id::KeyRightTrigger) + 2 * i ||
|
||||
size_t(kEntries[i].id2) != size_t(kEntries[i].id) + 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
static_assert(EntriesInOrder(), "one Entry per Control, in Control order, over the Key options in pairs");
|
||||
|
||||
// The control either of a Key option's keys belongs to.
|
||||
constexpr const Entry& EntryOf(settings::Id id)
|
||||
{
|
||||
return kEntries[(size_t(id) - size_t(settings::Id::KeyRightTrigger)) / 2];
|
||||
}
|
||||
|
||||
// What the held keys amount to.
|
||||
struct Resolved
|
||||
{
|
||||
uint16_t buttons = 0;
|
||||
bool leftTrigger = false, rightTrigger = false; // a trigger key is down: that trigger at full
|
||||
bool axisSet[size_t(Axis::Count)] = {}; // either key of the axis is down: the keyboard has the axis
|
||||
float axis[size_t(Axis::Count)] = {}; // +1, 0 (both keys) or -1 while set; up is positive, as XInput has it
|
||||
};
|
||||
|
||||
// pressed[i]: either key bound to Control(i) is down. Buttons OR in; an axis
|
||||
// is its positive key minus its negative one, and belongs to the
|
||||
// keyboard while either is held (both held: centred).
|
||||
constexpr Resolved Resolve(std::span<const bool, kControls> pressed)
|
||||
{
|
||||
Resolved r;
|
||||
for (size_t i = 0; i < kControls; i++)
|
||||
{
|
||||
if (!pressed[i])
|
||||
continue;
|
||||
const Entry& e = kEntries[i];
|
||||
switch (e.role)
|
||||
{
|
||||
case Role::Button:
|
||||
r.buttons |= e.button;
|
||||
break;
|
||||
case Role::LeftTrigger:
|
||||
r.leftTrigger = true;
|
||||
break;
|
||||
case Role::RightTrigger:
|
||||
r.rightTrigger = true;
|
||||
break;
|
||||
case Role::AxisNeg:
|
||||
r.axisSet[size_t(e.axis)] = true;
|
||||
r.axis[size_t(e.axis)] -= 1.0f;
|
||||
break;
|
||||
case Role::AxisPos:
|
||||
r.axisSet[size_t(e.axis)] = true;
|
||||
r.axis[size_t(e.axis)] += 1.0f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
+46
-2
@@ -24,6 +24,7 @@
|
||||
#include <install/locate.h>
|
||||
#include <install/placement.h>
|
||||
#include <install/sha256.h>
|
||||
#include <platform/quit_signals.h>
|
||||
#include <report/report.h>
|
||||
#include <ui/installer_screen.h>
|
||||
#include <ui/ui.h>
|
||||
@@ -172,6 +173,7 @@ namespace
|
||||
uint32_t entry;
|
||||
uint32_t stackSize;
|
||||
};
|
||||
std::atomic<bool> s_guestReturned{ false }; // the game's entry point returned
|
||||
|
||||
void* GuestMain(void* arg)
|
||||
{
|
||||
@@ -187,6 +189,7 @@ namespace
|
||||
fprintf(stderr, "[runtime] calling entry %08X\n", params->entry);
|
||||
uint32_t result = GuestThread::Start({ params->entry, 0, 0 });
|
||||
fprintf(stderr, "[runtime] entry point returned %08X\n", result);
|
||||
s_guestReturned = true;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -360,6 +363,18 @@ int main(int argc, char** argv)
|
||||
sa.sa_flags = SA_SIGINFO;
|
||||
for (int sig : { SIGSEGV, SIGBUS, SIGILL, SIGTRAP, SIGFPE, SIGABRT })
|
||||
sigaction(sig, &sa, nullptr);
|
||||
// SIGTERM, SIGINT and SIGHUP quit the game as its window's close button
|
||||
// does (platform/quit_signals.h): before SDL starts, which leaves them to
|
||||
// us.
|
||||
quitsignals::Install();
|
||||
// From here on a return from main() is a clean end too (an unknown
|
||||
// option, a cancelled install, a XEX that can't be read): the session's
|
||||
// crash marker goes (report.h). The quit paths' std::_Exit runs no
|
||||
// destructors: they clear it themselves.
|
||||
struct ClearMarkerOnReturn
|
||||
{
|
||||
~ClearMarkerOnReturn() { report::ClearCrashMarker(); }
|
||||
} clearMarkerOnReturn;
|
||||
|
||||
// Anything else starting with '-' is a mistake, not a XEX path.
|
||||
if (argc > 1 && argv[1][0] == '-')
|
||||
@@ -393,9 +408,10 @@ int main(int argc, char** argv)
|
||||
// problem began), the "What's new" note below, and this version recorded
|
||||
// as the last (user/version_check.h).
|
||||
version::Change versionChange = version::Change::Same;
|
||||
std::string lastVersion; // the last session's ("": not known)
|
||||
if (settingsLoaded)
|
||||
{
|
||||
std::string last = settings::LastVersion();
|
||||
std::string last = lastVersion = settings::LastVersion();
|
||||
versionChange = version::Classify(last, settings::FileFound(), report::Version());
|
||||
fprintf(stderr, "[version] %s\n", version::Describe(versionChange, last, report::Version()).c_str());
|
||||
if (versionChange != version::Change::Same)
|
||||
@@ -417,7 +433,14 @@ int main(int argc, char** argv)
|
||||
fprintf(stderr, "[memory] %s\n", notice.c_str());
|
||||
ui::Toast(notice, 12.0);
|
||||
}
|
||||
report::NoticePreviousCrash();
|
||||
// A crash marker left by a session of this version or a later one is a
|
||||
// session that ended without quitting; v0.1.5 and older left theirs after
|
||||
// every session. iOS ends apps without a word (none quits there).
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
report::NoticePreviousCrash(false);
|
||||
#else
|
||||
report::NoticePreviousCrash(!lastVersion.empty() && version::Compare(lastVersion, report::Version()) >= 0);
|
||||
#endif
|
||||
|
||||
// NFSMW_LIFELINE_PID (scripts/steamos/speedbreaker.sh): the launcher's PID. The
|
||||
// game runs in a container, not as the launcher's child, so Steam
|
||||
@@ -431,6 +454,7 @@ int main(int argc, char** argv)
|
||||
if (settings::Dirty())
|
||||
settings::Save();
|
||||
gpu::renderer::Shutdown();
|
||||
report::ClearCrashMarker();
|
||||
report::FlushLog();
|
||||
std::_Exit(0);
|
||||
}).detach();
|
||||
@@ -541,6 +565,21 @@ int main(int argc, char** argv)
|
||||
|
||||
if (!windowed)
|
||||
{
|
||||
// No window (NFSMW_HEADLESS=1): the game runs until its entry point
|
||||
// returns, or until a requested termination, which ends it as the
|
||||
// window's quit path below does.
|
||||
while (!s_guestReturned.load() && !quitsignals::Requested())
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
if (int sig = quitsignals::Requested())
|
||||
{
|
||||
fprintf(stderr, "[runtime] %s: quitting\n", quitsignals::Name(sig));
|
||||
if (settings::Dirty())
|
||||
settings::Save();
|
||||
gpu::renderer::Shutdown();
|
||||
report::ClearCrashMarker();
|
||||
report::FlushLog();
|
||||
std::_Exit(0);
|
||||
}
|
||||
pthread_join(thread, nullptr);
|
||||
report::FlushLog();
|
||||
return 0;
|
||||
@@ -558,11 +597,16 @@ int main(int argc, char** argv)
|
||||
hid::NoteGameStarting();
|
||||
while (video::RunFrame())
|
||||
;
|
||||
if (int sig = quitsignals::Requested())
|
||||
fprintf(stderr, "[runtime] %s: quitting, as the window's close button does\n", quitsignals::Name(sig));
|
||||
fprintf(stderr, "[runtime] window closed\n");
|
||||
if (settings::Dirty())
|
||||
settings::Save();
|
||||
gpu::renderer::Shutdown();
|
||||
video::Shutdown();
|
||||
// A clean end: no crash marker left (a crash in the shutdown above still
|
||||
// reported one).
|
||||
report::ClearCrashMarker();
|
||||
// The session log's last lines are still in its pipe: let them out
|
||||
// before _Exit takes the thread that copies them.
|
||||
report::FlushLog();
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ../macos_window.h.
|
||||
//
|
||||
// macOS only (AppKit); built for iOS too, as every platform/apple/*.mm is,
|
||||
// where it is empty. Manual reference counting like the rest of the runtime.
|
||||
#include <TargetConditionals.h>
|
||||
|
||||
#if TARGET_OS_OSX
|
||||
#include <platform/macos_window.h>
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
namespace platform::macos
|
||||
{
|
||||
namespace
|
||||
{
|
||||
NSWindow* s_window = nil; // the game's (SDL's, not retained here)
|
||||
bool s_inTransition = false; // between a fullscreen transition's "will" and its "did" (or "did fail")
|
||||
NSMutableArray* s_observers = nil;
|
||||
|
||||
NSWindow* CocoaWindow(SDL_Window* window)
|
||||
{
|
||||
if (!window)
|
||||
return nil;
|
||||
return (__bridge NSWindow*)SDL_GetPointerProperty(SDL_GetWindowProperties(window), SDL_PROP_WINDOW_COCOA_WINDOW_POINTER,
|
||||
nullptr);
|
||||
}
|
||||
|
||||
// No title, a transparent title bar, the picture under it.
|
||||
void ApplyLook(NSWindow* window)
|
||||
{
|
||||
window.titleVisibility = NSWindowTitleHidden;
|
||||
window.titlebarAppearsTransparent = YES;
|
||||
window.styleMask |= NSWindowStyleMaskFullSizeContentView;
|
||||
}
|
||||
|
||||
void Observe(NSWindow* window, NSString* name, void (^block)(NSNotification*))
|
||||
{
|
||||
id token = [NSNotificationCenter.defaultCenter addObserverForName:name object:window queue:nil usingBlock:block];
|
||||
[s_observers addObject:token];
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigureWindow(SDL_Window* window, bool windowed)
|
||||
{
|
||||
NSWindow* w = CocoaWindow(window);
|
||||
if (!w)
|
||||
return;
|
||||
if (s_window != w)
|
||||
{
|
||||
ForgetWindow(nullptr);
|
||||
s_window = w;
|
||||
s_observers = [[NSMutableArray alloc] init];
|
||||
// Whether a fullscreen transition is under way (AppKit says
|
||||
// nothing else): the look is never changed in the middle of one.
|
||||
for (NSString* name in @[ NSWindowWillEnterFullScreenNotification, NSWindowWillExitFullScreenNotification ])
|
||||
Observe(w, name, ^(NSNotification*) { s_inTransition = true; });
|
||||
for (NSString* name in @[ NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification,
|
||||
@"NSWindowDidFailToEnterFullScreenNotification", @"NSWindowDidFailToExitFullScreenNotification" ])
|
||||
Observe(w, name, ^(NSNotification*) { s_inTransition = false; });
|
||||
// SDL takes the full-size content view off as a fullscreen
|
||||
// transition starts (its windowWillEnterFullScreen), but its
|
||||
// timing against AppKit's own steps varies, and the bit changing
|
||||
// after AppKit has set its fullscreen frame crashed AppKit. So it
|
||||
// goes now, as AppKit announces the transition. (In testing SDL
|
||||
// had always taken it off already; this is the safety net.)
|
||||
Observe(w, NSWindowWillEnterFullScreenNotification, ^(NSNotification* n) {
|
||||
NSWindow* nw = n.object;
|
||||
if (nw.styleMask & NSWindowStyleMaskFullSizeContentView)
|
||||
nw.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
|
||||
});
|
||||
}
|
||||
if (windowed)
|
||||
ApplyLook(w);
|
||||
}
|
||||
|
||||
bool RestoreAfterFullscreen(SDL_Window* window)
|
||||
{
|
||||
NSWindow* w = CocoaWindow(window);
|
||||
if (!w || s_inTransition || (w.styleMask & NSWindowStyleMaskFullScreen))
|
||||
return false;
|
||||
// AppKit has put back the frame the window had before fullscreen;
|
||||
// -setStyleMask: would keep the content (one title bar shorter
|
||||
// without the bit) and shrink the frame to it, so the frame is set
|
||||
// back: the content is the whole frame again, as before fullscreen.
|
||||
const NSRect frame = w.frame;
|
||||
ApplyLook(w);
|
||||
[w setFrame:frame display:YES];
|
||||
return true;
|
||||
}
|
||||
|
||||
void ShowWindowButtons(SDL_Window* window, bool shown)
|
||||
{
|
||||
NSWindow* w = CocoaWindow(window);
|
||||
if (!w)
|
||||
return;
|
||||
const NSWindowButton buttons[] = { NSWindowCloseButton, NSWindowMiniaturizeButton, NSWindowZoomButton };
|
||||
for (NSWindowButton b : buttons)
|
||||
{
|
||||
NSButton* button = [w standardWindowButton:b];
|
||||
if (button && button.hidden == shown)
|
||||
button.hidden = !shown;
|
||||
}
|
||||
}
|
||||
|
||||
float TitleStripHeight(SDL_Window* window)
|
||||
{
|
||||
NSWindow* w = CocoaWindow(window);
|
||||
if (!w || (w.styleMask & NSWindowStyleMaskFullScreen) || !(w.styleMask & NSWindowStyleMaskFullSizeContentView))
|
||||
return 0.0f;
|
||||
// The layout rect is the part of the window no title bar covers.
|
||||
const CGFloat strip = NSHeight(w.frame) - NSMaxY(w.contentLayoutRect);
|
||||
return strip > 0 ? float(strip) : 0.0f;
|
||||
}
|
||||
|
||||
void ForgetWindow(SDL_Window*)
|
||||
{
|
||||
for (id token in s_observers)
|
||||
[NSNotificationCenter.defaultCenter removeObserver:token];
|
||||
[s_observers release];
|
||||
s_observers = nil;
|
||||
s_window = nil;
|
||||
s_inTransition = false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,50 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// macOS: the game's window with its picture under a transparent title bar
|
||||
// (apple/macos_window.mm; GitHub pull request #23 by aleszczynskig). The
|
||||
// title isn't shown, the title bar is drawn over the top of the picture
|
||||
// (NSWindowStyleMaskFullSizeContentView), and the close, minimise and zoom
|
||||
// buttons sit over it only while the mouse is in use there (ui.cpp shows and
|
||||
// hides them with the pointer). Fullscreen is macOS's own, unchanged. Main
|
||||
// thread only (AppKit).
|
||||
#pragma once
|
||||
|
||||
struct SDL_Window;
|
||||
|
||||
namespace platform::macos
|
||||
{
|
||||
// Once, after SDL_CreateWindow: watches the window's fullscreen
|
||||
// transitions (for RestoreAfterFullscreen) and, when `windowed` (not
|
||||
// made fullscreen), puts the title bar over the picture now. The content
|
||||
// keeps its size (-setStyleMask: keeps the content rect), so the picture
|
||||
// is the size the window was made at, the saved size, and the frame
|
||||
// loses the title bar's height. A window made fullscreen gets the look
|
||||
// when it leaves fullscreen: not while its Space is opening.
|
||||
void ConfigureWindow(SDL_Window* window, bool windowed);
|
||||
|
||||
// The look again after the window left fullscreen: SDL resets the
|
||||
// window's style around every fullscreen Space (SDL 3.4's Cocoa
|
||||
// windowWillEnterFullScreen and windowDidExitFullScreen), which takes
|
||||
// the full-size content view off. Call it from the game's event loop on
|
||||
// SDL_EVENT_WINDOW_LEAVE_FULLSCREEN, not from an event watch: those run
|
||||
// inside SDL's windowDidExitFullScreen, where putting it back left the
|
||||
// window one title bar shorter after every round trip (AppKit has just
|
||||
// restored the frame without it, and -setStyleMask: keeps the smaller
|
||||
// content) and the next entry into fullscreen sometimes crashed in
|
||||
// AppKit (SDL's reset then landing in the middle of the transition).
|
||||
// Here it keeps the window's frame. Does nothing (false) while a
|
||||
// fullscreen transition is under way (a toggle queued behind another):
|
||||
// the next SDL_EVENT_WINDOW_LEAVE_FULLSCREEN brings it.
|
||||
bool RestoreAfterFullscreen(SDL_Window* window);
|
||||
|
||||
// Shows or hides the close, minimise and zoom buttons.
|
||||
void ShowWindowButtons(SDL_Window* window, bool shown);
|
||||
|
||||
// The height in points of the title bar's strip over the picture (0
|
||||
// when there is none: fullscreen, or before the look is applied), for
|
||||
// laying the UI out below it.
|
||||
float TitleStripHeight(SDL_Window* window);
|
||||
|
||||
// Before SDL_DestroyWindow: stops watching the window.
|
||||
void ForgetWindow(SDL_Window* window);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// A requested termination ends the game the way its window's close button
|
||||
// does: main.cpp's quit path (settings saved, the renderer, UI and window
|
||||
// shut down, the session's crash marker cleared, the log flushed, exit code 0).
|
||||
// - SIGTERM: a shutdown or logout (systemd), `kill`, and the way a launcher
|
||||
// such as Steam asks a game to end (Steam's own reaper only forks, waits
|
||||
// and reaps: the stop comes from the Steam client; SIGKILL, which can't
|
||||
// be caught, is a stop that doesn't wait);
|
||||
// - SIGINT: Ctrl+C in the terminal the game was started from;
|
||||
// - SIGHUP: that terminal closed.
|
||||
// Until v0.1.6 SDL turned SIGTERM and SIGINT into its quit event (SDL puts its
|
||||
// handler only where a signal's action is still the default) once the window
|
||||
// was up, and SIGHUP, or SIGTERM before the window, ended the game on the
|
||||
// spot: no settings saved, and the crash marker left as by a crash
|
||||
// (report/crash_marker.h).
|
||||
//
|
||||
// The handler only records the first request (async-signal-safe); the main
|
||||
// thread's frame loop (video::RunFrame, which also runs the installer and the
|
||||
// startup error page) reads it and leaves; with no window (NFSMW_HEADLESS=1)
|
||||
// main() waits for it. Another request while quitting changes nothing, as
|
||||
// SDL's didn't. A signal the game was started with set to be ignored
|
||||
// (nohup's SIGHUP) stays ignored. SIGKILL can't be caught: a session it ends
|
||||
// keeps its crash marker, and the next launch logs that it ended without
|
||||
// quitting.
|
||||
//
|
||||
// Header only (tests/clean_exit_test.cpp).
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <csignal>
|
||||
|
||||
namespace quitsignals
|
||||
{
|
||||
namespace detail
|
||||
{
|
||||
inline std::atomic<int> g_requested{ 0 };
|
||||
static_assert(std::atomic<int>::is_always_lock_free, "the handler needs a lock-free atomic");
|
||||
|
||||
inline void Handle(int sig)
|
||||
{
|
||||
int none = 0;
|
||||
g_requested.compare_exchange_strong(none, sig, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// The signals a player or the system ends the game with.
|
||||
inline constexpr int kSignals[] = { SIGTERM, SIGINT, SIGHUP };
|
||||
|
||||
// Main thread, before SDL starts (video::Initialize), so SDL leaves these
|
||||
// signals to us.
|
||||
inline void Install()
|
||||
{
|
||||
for (int sig : kSignals)
|
||||
{
|
||||
struct sigaction old{};
|
||||
sigaction(sig, nullptr, &old);
|
||||
if (!(old.sa_flags & SA_SIGINFO) && old.sa_handler == SIG_IGN)
|
||||
continue;
|
||||
struct sigaction sa{};
|
||||
sa.sa_handler = detail::Handle;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = SA_RESTART;
|
||||
sigaction(sig, &sa, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
// The signal that asked the game to quit, 0 if none has. Any thread.
|
||||
inline int Requested()
|
||||
{
|
||||
return detail::g_requested.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// For the log line.
|
||||
inline const char* Name(int sig)
|
||||
{
|
||||
switch (sig)
|
||||
{
|
||||
case SIGTERM: return "SIGTERM";
|
||||
case SIGINT: return "SIGINT";
|
||||
case SIGHUP: return "SIGHUP";
|
||||
default: return "a signal";
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-22
@@ -4,7 +4,8 @@
|
||||
// watch declined the fault) runs on the crashing thread in a signal handler,
|
||||
// so everything here that it calls is async-signal-safe: the report file is
|
||||
// opened at startup (logs/.crash-pending-<session>, empty unless the session
|
||||
// crashes) and renamed to crash-<session>.txt when written; its header, the
|
||||
// crashes: crash_marker.h, removed when the session ends cleanly) and renamed
|
||||
// to crash-<session>.txt when written; its header, the
|
||||
// system summary and the symbol table are ready beforehand; text goes out
|
||||
// with write(2). The one call outside POSIX's list is dladdr (and the
|
||||
// backtrace() it follows), which glibc and macOS implement without
|
||||
@@ -20,6 +21,7 @@
|
||||
#include <stdafx.h>
|
||||
#include "report.h"
|
||||
#include "internal.h"
|
||||
#include "crash_marker.h"
|
||||
|
||||
#include <gpu/command_processor.h>
|
||||
#include <ui/ui.h>
|
||||
@@ -158,7 +160,7 @@ namespace report
|
||||
}
|
||||
#endif
|
||||
|
||||
int s_reportFd = -1;
|
||||
std::atomic<int> s_reportFd{ -1 }; // -1 once the session has ended cleanly (ClearCrashMarker)
|
||||
char s_pendingPath[1024];
|
||||
char s_finalPath[1024];
|
||||
char s_header[2048];
|
||||
@@ -411,7 +413,7 @@ namespace report
|
||||
{
|
||||
memcpy(s_pendingPath, pending.c_str(), pending.size() + 1);
|
||||
memcpy(s_finalPath, finalPath.c_str(), finalPath.size() + 1);
|
||||
s_reportFd = open(s_pendingPath, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
||||
s_reportFd = marker::Arm(s_pendingPath);
|
||||
}
|
||||
if (s_reportFd < 0)
|
||||
fprintf(stderr, "[crash] can't prepare a crash report file in %s: %s\n", dir.c_str(), strerror(errno));
|
||||
@@ -503,18 +505,18 @@ namespace report
|
||||
frame(frames[i]);
|
||||
detail::WriteDirect(text, b.size);
|
||||
|
||||
if (s_reportFd >= 0)
|
||||
if (const int fd = s_reportFd.load(); fd >= 0)
|
||||
{
|
||||
detail::WriteAll(s_reportFd, s_header, s_headerSize);
|
||||
detail::WriteAll(s_reportFd, text + 1, b.size - 1); // (without the leading blank line)
|
||||
detail::WriteAll(fd, s_header, s_headerSize);
|
||||
detail::WriteAll(fd, text + 1, b.size - 1); // (without the leading blank line)
|
||||
static const char recent[] = "\nThe log's last lines:\n";
|
||||
detail::WriteAll(s_reportFd, recent, sizeof(recent) - 1);
|
||||
detail::WriteRecentOutput(s_reportFd, 300);
|
||||
detail::WriteAll(fd, recent, sizeof(recent) - 1);
|
||||
detail::WriteRecentOutput(fd, 300);
|
||||
static const char system[] = "\nSystem (at startup):\n";
|
||||
detail::WriteAll(s_reportFd, system, sizeof(system) - 1);
|
||||
detail::WriteAll(fd, system, sizeof(system) - 1);
|
||||
const char* summary = detail::LoggedSystemSummary();
|
||||
detail::WriteAll(s_reportFd, summary, strlen(summary));
|
||||
fsync(s_reportFd);
|
||||
detail::WriteAll(fd, summary, strlen(summary));
|
||||
fsync(fd);
|
||||
bool renamed = rename(s_pendingPath, s_finalPath) == 0;
|
||||
b.size = 0;
|
||||
b.Str("[crash] crash report: ").Str(renamed ? s_finalPath : s_pendingPath).Char('\n');
|
||||
@@ -522,23 +524,34 @@ namespace report
|
||||
}
|
||||
}
|
||||
|
||||
void NoticePreviousCrash()
|
||||
void ClearCrashMarker()
|
||||
{
|
||||
// A crash being reported is how the session ends: its report stays.
|
||||
if (s_crashing.load())
|
||||
return;
|
||||
if (const int fd = s_reportFd.exchange(-1); fd >= 0)
|
||||
marker::Clear(fd, s_pendingPath);
|
||||
}
|
||||
|
||||
void NoticePreviousCrash(bool lastClearsMarker)
|
||||
{
|
||||
std::filesystem::path dir = LogDirectory();
|
||||
std::string current = std::string(".crash-pending-") + SessionName();
|
||||
std::error_code ec;
|
||||
// Pending files of earlier sessions: empty (no crash: removed), or a
|
||||
// Markers of earlier sessions (crash_marker.h): empty (removed), or a
|
||||
// report the handler couldn't rename.
|
||||
for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
|
||||
for (const marker::Leftover& left : marker::Find(dir, SessionName()))
|
||||
{
|
||||
std::string name = it->path().filename().string();
|
||||
if (!name.starts_with(".crash-pending-") || name == current)
|
||||
std::error_code fileError;
|
||||
if (left.ended == marker::Ended::Crashed)
|
||||
{
|
||||
std::filesystem::rename(left.path, dir / ("crash-" + left.session + ".txt"), fileError);
|
||||
continue;
|
||||
std::error_code sizeError;
|
||||
if (std::filesystem::file_size(it->path(), sizeError) == 0 || sizeError)
|
||||
std::filesystem::remove(it->path(), sizeError);
|
||||
else
|
||||
std::filesystem::rename(it->path(), dir / ("crash-" + name.substr(15) + ".txt"), sizeError);
|
||||
}
|
||||
std::filesystem::remove(left.path, fileError);
|
||||
// A version before v0.1.6 left one after every session.
|
||||
if (lastClearsMarker)
|
||||
fprintf(stderr, "[crash] an earlier session (%s) ended without quitting: it was stopped from outside (SIGKILL: a forced "
|
||||
"quit, the power going) or crashed too hard to write a report; there is no report to send\n", left.session.c_str());
|
||||
}
|
||||
detail::KeepNewest(dir.string(), "crash-", ".txt", 5);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// A session's crash marker, logs/.crash-pending-<session> (crash.cpp): made
|
||||
// empty at startup and kept open for the crash handler, which writes the
|
||||
// crash report into it and renames it crash-<session>.txt. A session that
|
||||
// ends the way it is meant to (the window's close button, Quit Game, Cmd+Q,
|
||||
// a requested termination: platform/quit_signals.h) removes it, so a marker
|
||||
// left in the logs folder is a session that hasn't ended or didn't end
|
||||
// cleanly: empty, it was stopped from outside (SIGKILL, the power went, the
|
||||
// system ended it) or crashed too hard to report; written, the handler's
|
||||
// rename didn't happen. The next launch reports what it finds (crash.cpp's
|
||||
// NoticePreviousCrash). Until v0.1.6 every session left its empty marker,
|
||||
// even after Quit Game, until the next launch removed it, so a marker after
|
||||
// the game had gone (a Steam Frame session its player closed from Steam,
|
||||
// 2026-10-15) said nothing about how it ended.
|
||||
//
|
||||
// POSIX file calls only. Header only (tests/clean_exit_test.cpp).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace report::marker
|
||||
{
|
||||
inline constexpr const char kPrefix[] = ".crash-pending-";
|
||||
|
||||
inline std::filesystem::path PathFor(const std::filesystem::path& dir, const std::string& session)
|
||||
{
|
||||
return dir / (kPrefix + session);
|
||||
}
|
||||
|
||||
// Startup: the session's empty marker, open for the crash handler (-1:
|
||||
// it couldn't be made).
|
||||
inline int Arm(const char* path)
|
||||
{
|
||||
return open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
||||
}
|
||||
|
||||
// The session ended cleanly: the marker closed and removed.
|
||||
inline void Clear(int fd, const char* path)
|
||||
{
|
||||
if (fd >= 0)
|
||||
close(fd);
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
// An earlier session's marker, as a launch finds it.
|
||||
enum class Ended
|
||||
{
|
||||
WithoutQuitting, // empty: stopped from outside, or no report could be written
|
||||
Crashed, // the handler's report (it couldn't rename it)
|
||||
};
|
||||
struct Leftover
|
||||
{
|
||||
std::filesystem::path path;
|
||||
std::string session;
|
||||
Ended ended;
|
||||
};
|
||||
|
||||
// The markers in `dir` other than the `current` session's.
|
||||
inline std::vector<Leftover> Find(const std::filesystem::path& dir, const std::string& current)
|
||||
{
|
||||
std::vector<Leftover> found;
|
||||
std::error_code ec;
|
||||
for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
|
||||
{
|
||||
const std::string name = it->path().filename().string();
|
||||
if (!name.starts_with(kPrefix) || name == kPrefix + current)
|
||||
continue;
|
||||
std::error_code sizeError;
|
||||
const uintmax_t size = std::filesystem::file_size(it->path(), sizeError);
|
||||
found.push_back({ it->path(), name.substr(sizeof(kPrefix) - 1),
|
||||
(size == 0 || sizeError) ? Ended::WithoutQuitting : Ended::Crashed });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -98,9 +98,17 @@ namespace report
|
||||
// The crash handler (a signal handler; async-signal-safe): logs the
|
||||
// crash and writes the report. The caller exits afterwards.
|
||||
void WriteCrashReport(int sig, siginfo_t* info, void* context);
|
||||
// The session is ending cleanly (the quit path, any return from main()):
|
||||
// its crash marker is removed (crash_marker.h), so one the next launch
|
||||
// finds is a session that didn't. Last, just before the exit: a crash
|
||||
// after it is only logged. Not while a crash is being reported.
|
||||
void ClearCrashMarker();
|
||||
// Main thread, once toasts can show: tells the player, once, about a
|
||||
// crash report from an earlier session.
|
||||
void NoticePreviousCrash();
|
||||
// crash report from an earlier session, and logs an earlier session that
|
||||
// ended without quitting. `lastClearsMarker`: the session before this
|
||||
// one ran a version that clears its marker at a clean end (this one or
|
||||
// later: v0.1.5 and older left it after every session).
|
||||
void NoticePreviousCrash(bool lastClearsMarker);
|
||||
|
||||
// --- Threads and hangs (watchdog.cpp) ---
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "report.h"
|
||||
#include "internal.h"
|
||||
|
||||
#include <apu/audio.h>
|
||||
#include <cpu/guest_thread.h>
|
||||
#include <cpu/guest_time.h>
|
||||
#include <gpu/command_processor.h>
|
||||
@@ -234,6 +235,9 @@ namespace report
|
||||
text += std::format("Suspended (the app inactive): {} times, {:.1f} s in all before the last resume{}.\n", suspensions,
|
||||
double(guesttime::PausedNs()) / 1e9, guesttime::Suspended() ? "; suspended now" : "");
|
||||
text += "\nCommand processor:\n" + gpu::DescribeState();
|
||||
// The game's sound runs on the render callback, and some of its
|
||||
// waits on its sound commands (GitHub issue #20).
|
||||
text += "\nAudio:\n" + apu::DescribeState();
|
||||
text += "\nThreads, where each one is (host stacks, innermost first; recompiled guest functions are "
|
||||
"sub_<guest address>):\n";
|
||||
for (const ThreadSample& s : samples)
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
# AMD FidelityFX Super Resolution 1 (vendored)
|
||||
|
||||
`ffx_a.h` and `ffx_fsr1.h` from https://github.com/GPUOpen-Effects/FidelityFX-FSR at commit
|
||||
a21ffb8f6c13233ba336352bdff293894c706575 (2021-11-15; its `ffx-fsr/` folder), unmodified, MIT
|
||||
license (`LICENSE.txt`, the repository's `license.txt` as it is; also
|
||||
`LICENSES/FidelityFX-FSR-MIT.txt`).
|
||||
|
||||
SHA-256:
|
||||
|
||||
f60e2722fcd13989523b9164d776ab382b3692791767f3bf8bb19967f763f3fb ffx_a.h
|
||||
93c3922362ea7fc99cbcc698ca30c98de4f8c246d1fbb0b09e015ddef38ce3a5 ffx_fsr1.h
|
||||
db089274ce766da70f5b7d791029c3486f9f9e27c8c79c652689603d3192e802 LICENSE.txt
|
||||
|
||||
Used by Settings > Graphics > Upscaling's FSR 1 (`runtime/video/fsr1.cpp`): their 32-bit GLSL
|
||||
EASU and RCAS functions are compiled into the presenter's shaders (the build embeds both files'
|
||||
text, `generated/fsr1_headers.inc`), and their CPU code computes the shaders' constants.
|
||||
Vendored
+2656
File diff suppressed because it is too large.
Load diff
Vendored
+1199
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,57 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// When the mouse cursor shows over the game (ui.cpp, every frame). The game
|
||||
// takes no mouse input, so while it plays the cursor hides once the mouse has
|
||||
// been still for kHideAfterNs, and shows again the moment it moves, clicks or
|
||||
// scrolls. The settings menu and the installer's screens (any modal page)
|
||||
// take the mouse: while one is open it always shows. The cursor hides only
|
||||
// over the game's window (SDL_HideCursor: on macOS the window's cursor
|
||||
// rectangle, on X11 and Wayland the window's cursor), so in a window it still
|
||||
// shows over the desktop, and nothing is captured or confined (no relative
|
||||
// mode). Touch moves no cursor: the mouse SDL makes of a finger (and of a
|
||||
// pen) doesn't count as movement, so a tap on the Steam Deck's screen
|
||||
// doesn't bring a cursor back. Not used on iOS, where the system hides an
|
||||
// iPad's pointer by itself.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace ui
|
||||
{
|
||||
struct CursorIdle
|
||||
{
|
||||
static constexpr uint64_t kHideAfterNs = 2'000'000'000;
|
||||
|
||||
// When the mouse last moved (SDL's clock, ns). The first frame counts:
|
||||
// the cursor shows for kHideAfterNs after launch wherever it is.
|
||||
uint64_t movedNs = 0;
|
||||
bool started = false;
|
||||
|
||||
// A mouse event (SDL's): `motion`, or a button or the wheel; whether
|
||||
// SDL made it of a finger or a pen (SDL_TOUCH_MOUSEID, SDL_PEN_MOUSEID);
|
||||
// a motion's distance. True: a real mouse moved, clicked or scrolled.
|
||||
static bool Activity(bool motion, bool touchOrPen, float dx, float dy)
|
||||
{
|
||||
return !touchOrPen && (!motion || dx != 0.0f || dy != 0.0f);
|
||||
}
|
||||
|
||||
// A real mouse moved, clicked or scrolled at `t`.
|
||||
void Moved(uint64_t t)
|
||||
{
|
||||
if (!started || t > movedNs)
|
||||
movedNs = t;
|
||||
started = true;
|
||||
}
|
||||
|
||||
// Whether the cursor shows at `now`: a menu or modal page has the
|
||||
// mouse, or it moved within kHideAfterNs.
|
||||
bool Shows(uint64_t now, bool menu)
|
||||
{
|
||||
if (!started)
|
||||
{
|
||||
movedNs = now;
|
||||
started = true;
|
||||
}
|
||||
return menu || now < movedNs + kHideAfterNs;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Quit Game (settings_menu.cpp): where it is offered, and the rules
|
||||
// of its confirmation, apart from ImGui so tests/quit_game_test.cpp can run
|
||||
// them. The button is the last of the menu's footer (Reset page, Close, Quit
|
||||
// Game), on every page: the D-pad's (or the arrow keys') Down past the last
|
||||
// row lands on the footer's first button (Reset page, or Close while the page
|
||||
// has nothing to reset), and Right goes on to Quit Game. The mouse clicks it.
|
||||
// - Offered where an app may quit itself: macOS, Linux and SteamOS (x86-64
|
||||
// and arm64), Windows. Not on iPhone or iPad, where the player closes an
|
||||
// app from the app switcher and Apple expects apps not to quit
|
||||
// themselves: there the button and its prompt are compiled out
|
||||
// (SB_QUIT_GAME 0), as is the startup error page's Quit.
|
||||
// - Two steps: Quit Game asks "Quit SpeedBreaker? Progress since your last
|
||||
// save will be lost." with Quit and Cancel, and only Quit quits. The
|
||||
// focus starts on Cancel, so the same button pressed again (A, Enter)
|
||||
// cancels, and a second click lands outside the prompt, which ignores
|
||||
// it. Quit takes a press only once the prompt has been up a frame: never
|
||||
// the one that opened it.
|
||||
// - B, Escape and Cancel cancel; so does the menu closing under it (F1,
|
||||
// Start or Back alone) or opening again.
|
||||
// - Quitting is an SDL_EVENT_QUIT (Cmd+Q's event), which the presenter
|
||||
// takes as it takes the window's close button, so the game leaves the one
|
||||
// way it always does (main.cpp: settings saved, the renderer, UI,
|
||||
// controllers and window shut down, the log flushed, exit code 0). Once
|
||||
// quitting, nothing in the prompt acts again.
|
||||
#pragma once
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include <TargetConditionals.h> // TARGET_OS_IPHONE: __APPLE__ alone means macOS or iOS
|
||||
#endif
|
||||
|
||||
// iPhone and iPad (and Apple's other device systems): no Quit Game.
|
||||
#if defined(__APPLE__) && TARGET_OS_IPHONE
|
||||
#define SB_QUIT_GAME 0
|
||||
#else
|
||||
#define SB_QUIT_GAME 1
|
||||
#endif
|
||||
|
||||
namespace ui::quit
|
||||
{
|
||||
enum class System { Mac, IPhoneIPad, Linux, Windows, Other };
|
||||
|
||||
constexpr System kSystem =
|
||||
#if defined(__APPLE__) && TARGET_OS_IPHONE
|
||||
System::IPhoneIPad;
|
||||
#elif defined(__APPLE__)
|
||||
System::Mac;
|
||||
#elif defined(_WIN32)
|
||||
System::Windows;
|
||||
#elif defined(__linux__)
|
||||
System::Linux;
|
||||
#else
|
||||
System::Other;
|
||||
#endif
|
||||
|
||||
// Whether the app may quit itself there (Quit Game, the startup error
|
||||
// page's Quit).
|
||||
constexpr bool OfferedOn(System system)
|
||||
{
|
||||
return system != System::IPhoneIPad;
|
||||
}
|
||||
|
||||
constexpr bool kOffered = SB_QUIT_GAME != 0;
|
||||
static_assert(kOffered == OfferedOn(kSystem), "SB_QUIT_GAME and kSystem disagree");
|
||||
|
||||
enum class Button { Quit, Cancel };
|
||||
|
||||
// What a step did: the menu logs it, and quits on Quit.
|
||||
enum class Outcome { None, Asking, Cancelled, Quit };
|
||||
|
||||
class Prompt
|
||||
{
|
||||
public:
|
||||
bool Asking() const { return m_step == Step::Asking; }
|
||||
bool Quitting() const { return m_step == Step::Quitting; }
|
||||
// Where the focus starts when the prompt opens.
|
||||
static constexpr Button kDefaultFocus = Button::Cancel;
|
||||
|
||||
// Quit Game was pressed: ask.
|
||||
Outcome Choose()
|
||||
{
|
||||
if (m_step != Step::Idle)
|
||||
return Outcome::None;
|
||||
m_step = Step::Asking;
|
||||
m_framesShown = 0;
|
||||
m_focusPending = true;
|
||||
return Outcome::Asking;
|
||||
}
|
||||
|
||||
// Each frame the prompt is drawn, before its buttons.
|
||||
void Shown()
|
||||
{
|
||||
if (m_step == Step::Asking && m_framesShown < kFramesBeforeQuit)
|
||||
m_framesShown++;
|
||||
}
|
||||
|
||||
// True once, on the prompt's first frame: focus kDefaultFocus.
|
||||
bool TakeFocus()
|
||||
{
|
||||
const bool focus = m_focusPending && m_step == Step::Asking;
|
||||
m_focusPending = false;
|
||||
return focus;
|
||||
}
|
||||
|
||||
// One of the prompt's buttons was pressed.
|
||||
Outcome Press(Button button)
|
||||
{
|
||||
if (m_step != Step::Asking)
|
||||
return Outcome::None;
|
||||
if (button == Button::Cancel)
|
||||
return Cancel();
|
||||
if (m_framesShown < kFramesBeforeQuit)
|
||||
return Outcome::None; // the prompt's first frame: not a press of Quit's
|
||||
m_step = Step::Quitting;
|
||||
return Outcome::Quit;
|
||||
}
|
||||
|
||||
// B or Escape.
|
||||
Outcome Back()
|
||||
{
|
||||
return m_step == Step::Asking ? Cancel() : Outcome::None;
|
||||
}
|
||||
|
||||
// The prompt went away unanswered (the menu closed, or opened again).
|
||||
Outcome Dismiss()
|
||||
{
|
||||
return m_step == Step::Asking ? Cancel() : Outcome::None;
|
||||
}
|
||||
|
||||
// The quit couldn't be sent (SDL's event queue refused it): the
|
||||
// game carries on, and Quit Game can be chosen again.
|
||||
void QuitFailed()
|
||||
{
|
||||
if (m_step == Step::Quitting)
|
||||
m_step = Step::Idle;
|
||||
}
|
||||
|
||||
private:
|
||||
enum class Step { Idle, Asking, Quitting };
|
||||
static constexpr int kFramesBeforeQuit = 2; // the frame it opened, and one more
|
||||
|
||||
Outcome Cancel()
|
||||
{
|
||||
m_step = Step::Idle;
|
||||
m_focusPending = false;
|
||||
return Outcome::Cancelled;
|
||||
}
|
||||
|
||||
Step m_step = Step::Idle;
|
||||
int m_framesShown = 0;
|
||||
bool m_focusPending = false;
|
||||
};
|
||||
}
|
||||
+452
-12
@@ -7,17 +7,26 @@
|
||||
// pages), and touch as the mouse (on iOS a drag scrolls the rows: ui.cpp).
|
||||
// Advanced also has Save Bug Report (report/bug_report.cpp), Check for
|
||||
// Updates (update_dialog.cpp), the build's identity (version and commit) and
|
||||
// the legal notice.
|
||||
// the legal notice. The footer's last button is Quit Game, which asks first
|
||||
// (quit_game.h; not on iPhone or iPad).
|
||||
#include <stdafx.h>
|
||||
#include "ui.h"
|
||||
#include "quit_game.h"
|
||||
|
||||
#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>
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
namespace ui
|
||||
{
|
||||
@@ -33,9 +42,30 @@ 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);
|
||||
|
||||
#if SB_QUIT_GAME
|
||||
constexpr const char* kQuitPopup = "quit";
|
||||
quit::Prompt s_quit;
|
||||
#endif
|
||||
|
||||
bool Pressed(ImGuiKey key)
|
||||
{
|
||||
return ImGui::IsKeyPressed(key, true);
|
||||
@@ -51,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)
|
||||
{
|
||||
@@ -66,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);
|
||||
@@ -176,6 +283,10 @@ namespace ui
|
||||
marker("ENV", ImGui::GetColorU32(ImGuiCol_TextDisabled));
|
||||
if (settings::RestartPending(o.id))
|
||||
marker("RESTART", ImGui::GetColorU32(ImGuiCol_PlotLines));
|
||||
// The row is the saved choice; the system (the green button)
|
||||
// changed the window this session: say what it is now.
|
||||
if (o.id == Id::Fullscreen && !locked && video::FullscreenWindowDiffers())
|
||||
marker(video::WindowFullscreen() ? "NOW FULLSCREEN" : "NOW WINDOWED", ImGui::GetColorU32(ImGuiCol_TextDisabled));
|
||||
}
|
||||
|
||||
int delta = 0;
|
||||
@@ -235,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())
|
||||
@@ -288,6 +503,84 @@ namespace ui
|
||||
OpenUpdateDialog();
|
||||
}
|
||||
|
||||
bool QuitPromptOpen()
|
||||
{
|
||||
#if SB_QUIT_GAME
|
||||
return ImGui::IsPopupOpen(kQuitPopup);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if SB_QUIT_GAME
|
||||
// Quit Game's prompt (quit_game.h), at the settings window's top
|
||||
// level like the other dialogs. True while it is open: B is its, not
|
||||
// the menu's.
|
||||
bool DrawQuitPrompt()
|
||||
{
|
||||
if (!ImGui::IsPopupOpen(kQuitPopup))
|
||||
{
|
||||
if (s_quit.Dismiss() == quit::Outcome::Cancelled)
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (the prompt closed)\n");
|
||||
return false;
|
||||
}
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
if (!ImGui::BeginPopupModal(kQuitPopup, nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar))
|
||||
{
|
||||
if (s_quit.Dismiss() == quit::Outcome::Cancelled)
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (the prompt closed)\n");
|
||||
return false;
|
||||
}
|
||||
s_quit.Shown();
|
||||
ImGui::TextUnformatted("Quit SpeedBreaker? Progress since your last save will be lost.");
|
||||
ImGui::Spacing();
|
||||
const bool quitPressed = ImGui::Button("Quit");
|
||||
ImGui::SameLine();
|
||||
if (s_quit.TakeFocus())
|
||||
ImGui::SetKeyboardFocusHere(); // Cancel (quit::Prompt::kDefaultFocus)
|
||||
const bool cancelPressed = ImGui::Button("Cancel");
|
||||
ImGui::SetItemDefaultFocus();
|
||||
quit::Outcome outcome = quit::Outcome::None;
|
||||
const char* how = "";
|
||||
if (cancelPressed)
|
||||
{
|
||||
outcome = s_quit.Press(quit::Button::Cancel);
|
||||
how = "Cancel";
|
||||
}
|
||||
else if (quitPressed)
|
||||
outcome = s_quit.Press(quit::Button::Quit);
|
||||
else if (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false))
|
||||
{
|
||||
outcome = s_quit.Back();
|
||||
how = "B or Escape";
|
||||
}
|
||||
if (outcome == quit::Outcome::Cancelled)
|
||||
{
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (%s)\n", how);
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
else if (outcome == quit::Outcome::Quit)
|
||||
{
|
||||
// The way out a window close takes: RunFrame returns false on
|
||||
// its next poll and main() shuts the game down.
|
||||
fprintf(stderr, "[ui] Quit Game: quitting (as closing the window does)\n");
|
||||
ImGui::CloseCurrentPopup();
|
||||
SDL_Event e{};
|
||||
e.type = SDL_EVENT_QUIT;
|
||||
e.quit.timestamp = SDL_GetTicksNS();
|
||||
if (!SDL_PushEvent(&e))
|
||||
{
|
||||
fprintf(stderr, "[ui] Quit Game: couldn't queue the quit: %s\n", SDL_GetError());
|
||||
Toast("Couldn't quit. Close the game's window instead.");
|
||||
s_quit.QuitFailed();
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
void HelpLine()
|
||||
{
|
||||
if (s_describedUpdate)
|
||||
@@ -334,12 +627,43 @@ 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).");
|
||||
return;
|
||||
}
|
||||
// The window's own fullscreen button (or the system) changed it for
|
||||
// this session; the setting still decides how the game starts.
|
||||
if (o.id == Id::Fullscreen && video::FullscreenWindowDiffers() && !settings::EnvOverride(o.id))
|
||||
{
|
||||
ImGui::TextDisabled("The window is %s for now. The game starts %s, as set here.",
|
||||
video::WindowFullscreen() ? "fullscreen" : "windowed", settings::GetBool(Id::Fullscreen) ? "fullscreen" : "windowed");
|
||||
return;
|
||||
}
|
||||
// The experiment's finer scale wins over Screen Resolution's.
|
||||
if (float scale = video::PresentScaleOverride(); o.id == Id::PresentScale && scale > 0.0f)
|
||||
ImGui::TextDisabled("NFSMW_PRESENT_SCALE sets %.2f for this run.", double(scale));
|
||||
@@ -396,21 +720,46 @@ namespace ui
|
||||
#endif
|
||||
|
||||
// Pages: LB/RB (Q/E) or the tabs; not under a dialog, which keeps them.
|
||||
const bool dialog = ImGui::IsPopupOpen("reset") || UpdateDialogOpen();
|
||||
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);
|
||||
@@ -446,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);
|
||||
@@ -459,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);
|
||||
@@ -514,9 +873,22 @@ namespace ui
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close"))
|
||||
CloseSettings();
|
||||
#if SB_QUIT_GAME
|
||||
// Last, after Close: Down from the rows lands on the footer's first
|
||||
// button, so Quit Game takes a Right more, and then asks.
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Quit Game") && s_quit.Choose() == quit::Outcome::Asking)
|
||||
{
|
||||
fprintf(stderr, "[ui] Quit Game: asking\n");
|
||||
ImGui::OpenPopup(kQuitPopup);
|
||||
}
|
||||
#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);
|
||||
@@ -546,19 +918,87 @@ namespace ui
|
||||
}
|
||||
if (DrawUpdateDialog())
|
||||
popup = true;
|
||||
#if SB_QUIT_GAME
|
||||
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)
|
||||
ImGui::ClosePopupToLevel(0, false);
|
||||
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
|
||||
if (s_quit.Dismiss() == quit::Outcome::Cancelled)
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (the menu closed)\n");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
// OK, A, B, Start or Esc closes it, and the window's close button too.
|
||||
#include <stdafx.h>
|
||||
#include "ui.h"
|
||||
#include "quit_game.h"
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
@@ -15,12 +16,7 @@ namespace ui
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr bool kCanQuit =
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
false;
|
||||
#else
|
||||
true;
|
||||
#endif
|
||||
constexpr bool kCanQuit = quit::kOffered; // not on iPhone or iPad
|
||||
|
||||
struct ErrorPage
|
||||
{
|
||||
|
||||
+165
-7
@@ -1,6 +1,7 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ui.h.
|
||||
#include <stdafx.h>
|
||||
#include "ui.h"
|
||||
#include "cursor_idle.h"
|
||||
|
||||
#include <kernel/xam.h>
|
||||
#include <report/report.h>
|
||||
@@ -14,6 +15,9 @@
|
||||
#include <imgui_internal.h> // DragToScroll: the window under a touch, its scroll and the active item
|
||||
#include <platform/ios_ui.h>
|
||||
#endif
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
#include <platform/macos_window.h>
|
||||
#endif
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
@@ -469,6 +473,93 @@ namespace ui
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
// The mouse cursor over the game (cursor_idle.h). Main thread.
|
||||
CursorIdle s_cursorIdle;
|
||||
bool s_cursorShown = true; // as SDL starts
|
||||
int s_cursorChanges = 0; // logged, the first few
|
||||
|
||||
// A real mouse's movement, click or scroll: not the mouse SDL makes
|
||||
// of a finger or a pen, nor a motion event that moved nothing.
|
||||
bool MouseActivity(const SDL_Event& e)
|
||||
{
|
||||
auto touchOrPen = [](SDL_MouseID which) { return which == SDL_TOUCH_MOUSEID || which == SDL_PEN_MOUSEID; };
|
||||
switch (e.type)
|
||||
{
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
return CursorIdle::Activity(true, touchOrPen(e.motion.which), e.motion.xrel, e.motion.yrel);
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
return CursorIdle::Activity(false, touchOrPen(e.button.which), 0.0f, 0.0f);
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
return CursorIdle::Activity(false, touchOrPen(e.wheel.which), 0.0f, 0.0f);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
bool s_buttonsShown = true; // as AppKit makes the window
|
||||
int s_buttonChanges = 0; // logged, the first few
|
||||
|
||||
// The window's close, minimise and zoom buttons, over the picture in
|
||||
// a window (platform/macos_window.h), come and go with the pointer:
|
||||
// shown while it shows over the window, hidden when it hides (the
|
||||
// mouse still for 2 s), leaves the window, or the app isn't in front
|
||||
// (SDL then gives no window the mouse). In fullscreen they stay:
|
||||
// macOS shows them there only with the menu bar.
|
||||
void UpdateWindowButtons(bool pointerShown)
|
||||
{
|
||||
if (!s_window)
|
||||
return;
|
||||
const bool fullscreen = SDL_GetWindowFlags(s_window) & SDL_WINDOW_FULLSCREEN;
|
||||
const bool shown = fullscreen || (pointerShown && SDL_GetMouseFocus() == s_window);
|
||||
if (shown == s_buttonsShown)
|
||||
return;
|
||||
s_buttonsShown = shown;
|
||||
platform::macos::ShowWindowButtons(s_window, shown);
|
||||
if (++s_buttonChanges <= 6)
|
||||
fprintf(stderr, "[ui] window buttons %s\n",
|
||||
shown ? (fullscreen ? "shown (fullscreen)" : "shown (the pointer is over the window)")
|
||||
: "hidden (the pointer hid or left the window)");
|
||||
}
|
||||
#endif
|
||||
|
||||
// Shows or hides the cursor for now (each frame, and as the menu
|
||||
// opens or closes). While a menu or modal page has the mouse, ImGui's
|
||||
// backend shows it and picks its shape; otherwise it must keep its
|
||||
// hands off, or it shows it again every frame the performance
|
||||
// overlay or a toast is drawn.
|
||||
void UpdateCursor()
|
||||
{
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
const bool menu = s_ready && s_capturing.load(std::memory_order_relaxed);
|
||||
if (s_ready)
|
||||
{
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (menu)
|
||||
io.ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange;
|
||||
else
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
|
||||
}
|
||||
const bool show = s_cursorIdle.Shows(SDL_GetTicksNS(), menu);
|
||||
#if defined(__APPLE__)
|
||||
UpdateWindowButtons(show); // every time: the pointer can leave without hiding
|
||||
#endif
|
||||
if (show == s_cursorShown)
|
||||
return;
|
||||
s_cursorShown = show;
|
||||
show ? SDL_ShowCursor() : SDL_HideCursor();
|
||||
if (++s_cursorChanges <= 6)
|
||||
fprintf(stderr, "[ui] mouse cursor %s\n", show ? (menu ? "shown (a menu has the mouse)" : "shown (the mouse moved)")
|
||||
: "hidden (still for 2 s over the game)");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
bool Initialize(SDL_Window* window, const VulkanTarget& target)
|
||||
{
|
||||
if (s_ready)
|
||||
@@ -555,6 +646,14 @@ namespace ui
|
||||
|
||||
void ProcessEvent(const SDL_Event& e)
|
||||
{
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
if (MouseActivity(e))
|
||||
{
|
||||
// Shown at once, not at the next frame's check.
|
||||
s_cursorIdle.Moved(e.common.timestamp);
|
||||
UpdateCursor();
|
||||
}
|
||||
#endif
|
||||
if (!s_ready)
|
||||
return;
|
||||
bool forward = s_capturing.load(std::memory_order_relaxed);
|
||||
@@ -572,8 +671,14 @@ namespace ui
|
||||
s_settingsOpen ? CloseSettings() : OpenSettings();
|
||||
return;
|
||||
}
|
||||
if (s_settingsOpen && SettingsMenuTakesKey(e.key))
|
||||
return; // a Controls row took it: ImGui doesn't act on it too
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_KEY_UP:
|
||||
if (s_settingsOpen && SettingsMenuTakesKey(e.key))
|
||||
return; // the release of a key a Controls row took
|
||||
break;
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_UP:
|
||||
{
|
||||
@@ -647,6 +752,7 @@ namespace ui
|
||||
void Tick()
|
||||
{
|
||||
s_pollAt = SDL_GetTicksNS();
|
||||
UpdateCursor();
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
// Every frame: SDL's text input or the Files picker can have the
|
||||
// first responder for a while (ios_ui.h). Cheap when nothing changed.
|
||||
@@ -656,15 +762,24 @@ 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'
|
||||
// own clock, so a capture lands between two keys (the
|
||||
// NFSMW_CHECK_PRESENT_SEC clock starts at the first frame, a second
|
||||
// or more later).
|
||||
// or more later). MouseMove moves the pointer to the window's middle
|
||||
// and 40 points either side in turn, for the cursor's hiding:
|
||||
// SDL_WarpMouseInWindow moves the real pointer (SDL reports a warp as
|
||||
// a motion of no distance, which isn't the mouse moving), then the
|
||||
// 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;
|
||||
struct Key { double time; SDL_Keycode key; };
|
||||
kShot = SDLK_EXTENDED_MASK | 0x7F03, kMouseMove = SDLK_EXTENDED_MASK | 0x7F04;
|
||||
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;
|
||||
@@ -678,11 +793,20 @@ namespace ui
|
||||
if (colon == std::string::npos)
|
||||
continue;
|
||||
std::string name = item.substr(colon + 1);
|
||||
Press press = Press::Tap;
|
||||
for (auto [prefix, kind] : { std::pair{ "Hold ", Press::Hold }, std::pair{ "Repeat ", Press::Repeat },
|
||||
std::pair{ "Release ", Press::Release } })
|
||||
if (name.starts_with(prefix))
|
||||
{
|
||||
press = kind;
|
||||
name = name.substr(strlen(prefix));
|
||||
}
|
||||
SDL_Keycode key = name == "FocusLost" ? kFocusLost : name == "FocusGained" ? kFocusGained
|
||||
: name == "Shot" ? kShot
|
||||
: name == "MouseMove" ? kMouseMove
|
||||
: SDL_GetKeyFromName(name.c_str());
|
||||
if (key != SDLK_UNKNOWN)
|
||||
parsed.push_back({ std::atof(item.c_str()), key });
|
||||
parsed.push_back({ std::atof(item.c_str()), key, press });
|
||||
else
|
||||
fprintf(stderr, "[ui] NFSMW_UI_KEYS: unknown key %s\n", item.c_str() + colon + 1);
|
||||
}
|
||||
@@ -717,6 +841,28 @@ namespace ui
|
||||
shotDue = keys[next++].time;
|
||||
return;
|
||||
}
|
||||
if (keys[next].key == kMouseMove)
|
||||
{
|
||||
static int moves = 0;
|
||||
int w = 0, h = 0;
|
||||
SDL_GetWindowSize(s_window, &w, &h);
|
||||
const float x = float(w) * 0.5f + float((moves++ % 3) - 1) * 40.0f, y = float(h) * 0.5f;
|
||||
float fromX = x, fromY = y;
|
||||
SDL_GetMouseState(&fromX, &fromY);
|
||||
SDL_WarpMouseInWindow(s_window, x, y);
|
||||
SDL_Event e{};
|
||||
e.type = SDL_EVENT_MOUSE_MOTION;
|
||||
e.motion.timestamp = SDL_GetTicksNS();
|
||||
e.motion.windowID = SDL_GetWindowID(s_window);
|
||||
e.motion.which = 1; // a mouse (not SDL_TOUCH_MOUSEID or SDL_PEN_MOUSEID)
|
||||
e.motion.x = x;
|
||||
e.motion.y = y;
|
||||
e.motion.xrel = x - fromX;
|
||||
e.motion.yrel = y - fromY;
|
||||
SDL_PushEvent(&e);
|
||||
fprintf(stderr, "[ui] NFSMW_UI_KEYS: mouse to %.0f,%.0f\n", x, y);
|
||||
continue;
|
||||
}
|
||||
if (keys[next].key == kFocusLost || keys[next].key == kFocusGained)
|
||||
{
|
||||
SDL_Event e{};
|
||||
@@ -727,8 +873,11 @@ namespace ui
|
||||
fprintf(stderr, "[ui] NFSMW_UI_KEYS: window focus %s\n", keys[next].key == kFocusLost ? "lost" : "gained");
|
||||
continue;
|
||||
}
|
||||
const Press press = keys[next].press;
|
||||
for (bool down : { true, false })
|
||||
{
|
||||
if ((down && press == Press::Release) || (!down && (press == Press::Hold || press == Press::Repeat)))
|
||||
continue;
|
||||
SDL_Event e{};
|
||||
e.type = down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP;
|
||||
e.key.timestamp = SDL_GetTicksNS();
|
||||
@@ -736,6 +885,7 @@ namespace ui
|
||||
e.key.key = keys[next].key;
|
||||
e.key.scancode = SDL_GetScancodeFromKey(keys[next].key, nullptr);
|
||||
e.key.down = down;
|
||||
e.key.repeat = press == Press::Repeat;
|
||||
SDL_PushEvent(&e);
|
||||
}
|
||||
}
|
||||
@@ -806,6 +956,7 @@ namespace ui
|
||||
s_frameBuilt = false;
|
||||
if (!s_ready)
|
||||
return false;
|
||||
UpdateCursor(); // a menu opened or closed by this frame's events
|
||||
// A touch press held back for a three-finger tap goes to ImGui once
|
||||
// a second finger can no longer land in time (a finger resting on a
|
||||
// row, say), without waiting for another touch event. Judged here,
|
||||
@@ -853,14 +1004,20 @@ namespace ui
|
||||
UpdateScale();
|
||||
ImGui::NewFrame();
|
||||
// Panels and toasts lay out in the work area: the window's safe area,
|
||||
// clear of an iPhone's Dynamic Island and home indicator (elsewhere
|
||||
// the whole window, as before).
|
||||
// clear of an iPhone's Dynamic Island and home indicator, and below a
|
||||
// Mac window's title bar where that lies over the picture (the
|
||||
// window's buttons are there: platform/macos_window.h); elsewhere the
|
||||
// whole window, as before.
|
||||
// (The window's, in its points: kept inside the display, which is
|
||||
// the swapchain's shape while FitUiDisplay overrules the window.)
|
||||
if (SDL_Rect safe; s_window && SDL_GetWindowSafeArea(s_window, &safe) && safe.w > 0 && safe.h > 0)
|
||||
{
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
const float x0 = std::clamp(float(safe.x), 0.0f, io.DisplaySize.x), y0 = std::clamp(float(safe.y), 0.0f, io.DisplaySize.y);
|
||||
float top = float(safe.y);
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
top = std::max(top, platform::macos::TitleStripHeight(s_window));
|
||||
#endif
|
||||
const float x0 = std::clamp(float(safe.x), 0.0f, io.DisplaySize.x), y0 = std::clamp(top, 0.0f, io.DisplaySize.y);
|
||||
const float x1 = std::clamp(float(safe.x + safe.w), x0, io.DisplaySize.x), y1 = std::clamp(float(safe.y + safe.h), y0, io.DisplaySize.y);
|
||||
if (x1 - x0 >= 1.0f && y1 - y0 >= 1.0f)
|
||||
{
|
||||
@@ -948,6 +1105,7 @@ namespace ui
|
||||
s_settingsOpen = false;
|
||||
UpdateCapture();
|
||||
ForgetUpdateDialog();
|
||||
ForgetQuitPrompt();
|
||||
report::NoteMenuClosed();
|
||||
XamNotifySystemUi(false);
|
||||
if (settings::Dirty() && !settings::Save())
|
||||
|
||||
@@ -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
|
||||
@@ -147,6 +153,9 @@ namespace ui
|
||||
// The menu closed or reopened: the dialog is gone (a check still running
|
||||
// then ends in a toast).
|
||||
void ForgetUpdateDialog();
|
||||
// The menu closed: Quit Game's prompt (settings_menu.cpp, quit_game.h),
|
||||
// if it was asking, is cancelled.
|
||||
void ForgetQuitPrompt();
|
||||
|
||||
// Shared look (ui.cpp).
|
||||
SDL_Window* GetWindow();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+148
-7
@@ -27,6 +27,8 @@
|
||||
#include <TargetConditionals.h> // TARGET_OS_IOS: __APPLE__ alone means macOS or iOS
|
||||
#endif
|
||||
|
||||
#include "key_names.h" // the Key options: the keys and their names in the file
|
||||
|
||||
// toml++ (header-only), as vendored by XenonRecomp. The fallback path keeps
|
||||
// this file building before runtime/CMakeLists.txt adds the include
|
||||
// directory (the runtime globs every .cpp under runtime/).
|
||||
@@ -45,9 +47,23 @@ namespace settings
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// FSR 1 isn't offered on iPhone and iPad: its EASU pass costs an
|
||||
// Apple GPU 2-3x the bicubic pass, and a phone's 1x picture is
|
||||
// enlarged about 2x every frame. Without the entry, "fsr1" in
|
||||
// settings.toml or NFSMW_SCALING reads as unknown (bicubic), so the
|
||||
// presenter never makes an FSR 1 shader or pipeline there (it makes
|
||||
// them only for a frame whose pass is FSR 1: video/upscale.h).
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
constexpr bool kFsr1Offered = false;
|
||||
#else
|
||||
constexpr bool kFsr1Offered = true;
|
||||
#endif
|
||||
constexpr EnumEntry kScalingEntries[] = {
|
||||
{ int32_t(ScalingMode::Bicubic), "bicubic", "Bicubic" },
|
||||
{ int32_t(ScalingMode::Linear), "linear", "Bilinear" },
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
{ int32_t(ScalingMode::Fsr1), "fsr1", "FSR 1" },
|
||||
#endif
|
||||
};
|
||||
|
||||
// Frame Rate: the game's frames per second (60, or 30 on a GPU that
|
||||
@@ -104,6 +120,31 @@ namespace settings
|
||||
{ kAnisotropyGame, "game", "Original" },
|
||||
};
|
||||
|
||||
// A Controls row's key: the key (user/key_names.h, by its name
|
||||
// there) that drives one Xbox 360 control. Two per control, the
|
||||
// second named <key>_2 with the same label. No environment
|
||||
// variable: 48 of them would clutter the file's comments for nobody.
|
||||
constexpr Option KeyOption(Id id, const char* key, const char* label, const char* description, const char* token)
|
||||
{
|
||||
return { .id = id, .category = Category::Controls, .type = Type::Key, .apply = Apply::Live,
|
||||
.table = "keys", .key = key, .label = label, .description = description, .env = nullptr,
|
||||
.defaultValue = double(keynames::Code(token)) };
|
||||
}
|
||||
|
||||
constexpr Option KeyOption2(Id id, const char* key, const char* label, const char* token)
|
||||
{
|
||||
return KeyOption(id, key, label, "A second key for the same control, or none.", token);
|
||||
}
|
||||
|
||||
// A stored or offered value as a key code: kUnknown unless it's a
|
||||
// whole number the key table knows (none included).
|
||||
constexpr uint16_t KeyCode(double v)
|
||||
{
|
||||
if (!(v >= 0 && v < double(keynames::kUnknown)) || v != uint16_t(v))
|
||||
return keynames::kUnknown;
|
||||
return keynames::Known(uint16_t(v)) ? uint16_t(v) : keynames::kUnknown;
|
||||
}
|
||||
|
||||
// Defaults are what the runtime did before settings existed, so an
|
||||
// empty settings.toml changes nothing.
|
||||
constexpr Option kOptions[] = {
|
||||
@@ -130,15 +171,28 @@ 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",
|
||||
.description = "Fill other screen shapes: a wider view on an ultrawide, a taller one on a Steam Deck or a 4:3 iPad, the HUD at 16:9. Or 16:9 with bars.",
|
||||
.env = "NFSMW_ASPECT", .defaultValue = double(AspectMode::Auto), .entries = kAspectEntries },
|
||||
// Brightness: a power curve on the game's picture, -5..+5 (video/display_tone.h).
|
||||
{ .id = Id::Brightness, .category = Category::Display, .type = Type::Int, .apply = Apply::Live,
|
||||
.table = "video", .key = "brightness", .label = "Brightness",
|
||||
.description = "Lighter or darker shadows and mid-tones, a gentle step at a time, as a TV's brightness. Black and white stay put. 0 is the original picture.",
|
||||
.env = "NFSMW_BRIGHTNESS", .defaultValue = 0, .min = -5, .max = 5, .step = 1 },
|
||||
{ .id = Id::DisplayGamma, .category = Category::Display, .type = Type::Bool, .apply = Apply::Live,
|
||||
.table = "video", .key = "xbox_display_gamma", .label = "Xbox 360 Display Gamma",
|
||||
.description = "The gamma ramp the game itself sets for a real Xbox 360's TV output: a little deeper shadows and brighter mid-tones, as on the console. Off: the flatter picture of SpeedBreaker 0.1.3 and earlier.",
|
||||
.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",
|
||||
@@ -186,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",
|
||||
@@ -223,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;
|
||||
@@ -420,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 {};
|
||||
}
|
||||
@@ -444,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 "";
|
||||
}
|
||||
@@ -467,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 {};
|
||||
}
|
||||
@@ -495,6 +614,8 @@ namespace settings
|
||||
return n;
|
||||
case Type::Enum:
|
||||
return EnumFromNumber(o, *n);
|
||||
case Type::Key:
|
||||
return {}; // names only
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -560,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;
|
||||
@@ -709,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 "";
|
||||
}
|
||||
@@ -718,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++)
|
||||
{
|
||||
@@ -733,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;
|
||||
@@ -923,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 "";
|
||||
}
|
||||
@@ -1060,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;
|
||||
}
|
||||
@@ -1076,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;
|
||||
@@ -1090,6 +1228,9 @@ namespace settings
|
||||
int64_t n = std::llround(value);
|
||||
if (unit == "%")
|
||||
return std::format("{}%", n);
|
||||
// A range around zero (Brightness) shows its sign: -2, 0, +2.
|
||||
if (o.min < 0 && n > 0)
|
||||
return unit.empty() ? std::format("+{}", n) : std::format("+{} {}", n, unit);
|
||||
return unit.empty() ? std::format("{}", n) : std::format("{} {}", n, unit);
|
||||
}
|
||||
if (unit == "%")
|
||||
|
||||
+41
-5
@@ -14,7 +14,8 @@
|
||||
// that runs before main() calls Load(), so it would miss the file (it
|
||||
// gets the environment and defaults, with a warning in the log).
|
||||
// - An environment variable (NFSMW_AA=0, ...) wins over the file for that
|
||||
// run and is never written back; EnvOverride() tells the menu.
|
||||
// run and is never written back; EnvOverride() tells the menu. Key
|
||||
// options have none.
|
||||
// - A Restart option keeps its launch value for the whole run. Its
|
||||
// consumers read it once (the window's size), so a change mid-run would
|
||||
// reach only part of them. The menu edits the stored value and shows
|
||||
@@ -46,6 +47,8 @@ namespace settings
|
||||
AntiAliasing,
|
||||
Scaling,
|
||||
AspectRatio,
|
||||
Brightness, // the game's picture (video/display_tone.h)
|
||||
DisplayGamma, // the 360's display gamma ramp (video/display_gamma.h)
|
||||
Sharpening,
|
||||
Anisotropy,
|
||||
Mipmaps,
|
||||
@@ -60,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,
|
||||
@@ -67,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;
|
||||
@@ -109,7 +145,7 @@ namespace settings
|
||||
const char* key; // key in that table, e.g. "anti_aliasing"
|
||||
const char* label; // menu label
|
||||
const char* description; // one-line menu help
|
||||
const char* env; // the environment variable that overrides it
|
||||
const char* env; // the environment variable that overrides it; nullptr: none (Key options)
|
||||
int envField = 0; // 0: the whole variable; n: its n-th 'x'-separated field (NFSMW_WINDOW=WxH)
|
||||
double defaultValue; // Bool: 0 or 1; Enum: an entry's value
|
||||
// Int and Float: the range and the menu's step. Enum: the range a
|
||||
|
||||
@@ -56,4 +56,29 @@ namespace video::band
|
||||
{
|
||||
return std::clamp(i, s.a, s.b - 1);
|
||||
}
|
||||
|
||||
// FSR 1 (video/fsr1.cpp's GLSL). Its passes work on output pixels: the
|
||||
// picture's pixel i of outSize along an axis has its centre at this
|
||||
// coordinate of the srcSize-texel frame image, and its span is the one
|
||||
// holding it (SpanOf). EASU keeps each of its taps (texels floor(t -
|
||||
// 0.5) - 1 to + 2) in that span with ClampTexel.
|
||||
inline float SourceOf(int32_t i, int32_t outSize, int32_t srcSize)
|
||||
{
|
||||
return (float(i) + 0.5f) * float(srcSize) / float(outSize);
|
||||
}
|
||||
|
||||
// Which span holds t: 0 [0, lo), 1 [lo, hi), 2 [hi, size).
|
||||
inline int SpanIndex(float t, int32_t lo, int32_t hi)
|
||||
{
|
||||
return t < float(lo) ? 0 : t < float(hi) ? 1 : 2;
|
||||
}
|
||||
|
||||
// RCAS's neighbour j (i +- 1) of output pixel i: j, or i itself where
|
||||
// j's centre lies in another span (its span's edge pixel, as ClampTexel
|
||||
// keeps a tap), so the sharpening never reads across the band's edge.
|
||||
inline int32_t RcasNeighbour(int32_t i, int32_t j, int32_t lo, int32_t hi, int32_t outSize, int32_t srcSize)
|
||||
{
|
||||
j = std::clamp(j, 0, outSize - 1);
|
||||
return SpanIndex(SourceOf(j, outSize, srcSize), lo, hi) == SpanIndex(SourceOf(i, outSize, srcSize), lo, hi) ? j : i;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See display_gamma.h.
|
||||
#include "display_gamma.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace video::display_gamma
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// Command processor thread only.
|
||||
Decoder s_decoder;
|
||||
bool s_pwlLogged = false;
|
||||
|
||||
// The last complete table, for the presenter.
|
||||
std::mutex s_mutex;
|
||||
Table s_published;
|
||||
std::atomic<uint64_t> s_generation{ 0 };
|
||||
|
||||
// Since the process started (static initialisation), for the log.
|
||||
const auto s_launch = std::chrono::steady_clock::now();
|
||||
double Seconds()
|
||||
{
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - s_launch).count();
|
||||
}
|
||||
|
||||
// "8->5.7 16->12.5 ...": what a few framebuffer values become, in
|
||||
// 8-bit levels (r=g=b), or per channel when they differ.
|
||||
std::string Describe(const Table& t)
|
||||
{
|
||||
std::string out;
|
||||
bool grey = true;
|
||||
for (const auto& e : t.entry)
|
||||
grey = grey && e[0] == e[1] && e[1] == e[2];
|
||||
char buf[64];
|
||||
for (uint32_t k : { 8u, 16u, 32u, 64u, 96u, 128u, 160u, 192u, 224u, 255u })
|
||||
{
|
||||
const auto& e = t.entry[k];
|
||||
if (grey)
|
||||
snprintf(buf, sizeof(buf), " %u->%.1f", k, e[0] * 255.0 / 1023.0);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), " %u->%.1f/%.1f/%.1f", k, e[0] * 255.0 / 1023.0, e[1] * 255.0 / 1023.0,
|
||||
e[2] * 255.0 / 1023.0);
|
||||
out += buf;
|
||||
}
|
||||
return std::string(grey ? "r=g=b:" : "r/g/b:") + out + (t == Table::Identity() ? " (identity)" : "");
|
||||
}
|
||||
|
||||
void Publish(const Table& t)
|
||||
{
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
if (s_generation.load(std::memory_order_relaxed) != 0 && t == s_published)
|
||||
return; // the same ramp written again
|
||||
s_published = t;
|
||||
}
|
||||
uint64_t n = s_generation.fetch_add(1, std::memory_order_acq_rel) + 1;
|
||||
fprintf(stderr, "[video] display gamma ramp #%llu from the game at %.1f s: %s\n", (unsigned long long)n, Seconds(),
|
||||
Describe(t).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void OnRegisterWrite(uint32_t index, uint32_t value)
|
||||
{
|
||||
if (s_decoder.Write(index, value))
|
||||
{
|
||||
if (s_decoder.TableMode())
|
||||
Publish(s_decoder.table());
|
||||
}
|
||||
if (s_decoder.PwlWrites() && !s_pwlLogged)
|
||||
{
|
||||
s_pwlLogged = true;
|
||||
fprintf(stderr, "[video] display gamma: the game writes a PWL ramp (DC_LUT_PWL_DATA); only the 256-entry table is applied\n");
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t Generation()
|
||||
{
|
||||
return s_generation.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
uint64_t Latest(Table& out)
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
out = s_published;
|
||||
return s_generation.load(std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The display gamma ramp a game programs into the Xbox 360's display
|
||||
// controller: a 256-entry table (10 bits per channel) that the 360 applies to
|
||||
// every pixel at scan-out, after the GPU is done with the frame. Direct3D
|
||||
// writes it through the GPU's DC_LUT_* registers (PM4 type-0 register
|
||||
// writes), from the ramp the game passes to SetGammaRamp, adjusted for the
|
||||
// display by the kernel's VdGetCurrentDisplayGamma (gpu/imports_video.cpp).
|
||||
// Most Wanted and Carbon pass a power curve from their own brightness value
|
||||
// (Most Wanted's 360 menus never change it from the one it sets at boot).
|
||||
//
|
||||
// The command processor feeds every write of those registers to a Decoder
|
||||
// (video/display_gamma.cpp keeps the one in use); a complete table is handed
|
||||
// to the presenter, which applies it while the "Xbox 360 Display Gamma"
|
||||
// setting is on, as it is by default (video/display_tone.h).
|
||||
//
|
||||
// Register protocol after Xenia (src/xenia/gpu/register_table.inc and
|
||||
// CommandProcessor::WriteRegister, BSD license), which follows the M56
|
||||
// (ATI/AMD R5xx) display controller's documentation:
|
||||
// DC_LUT_RW_MODE (0x1921) bit 0: 0 = the 256-entry table, 1 = PWL
|
||||
// DC_LUT_RW_INDEX (0x1922) the entry the next access goes to (bits 0:7);
|
||||
// a write also restarts DC_LUT_SEQ_COLOR at red
|
||||
// DC_LUT_SEQ_COLOR (0x1923) one 10-bit component per write (bits 6:15):
|
||||
// red, green, blue, then the next entry
|
||||
// DC_LUT_PWL_DATA (0x1924) a PWL segment (base 6:15, delta 22:31); three
|
||||
// writes (R, G, B) an entry, 128 entries
|
||||
// DC_LUT_30_COLOR (0x1925) a whole entry: blue 0:9, green 10:19, red
|
||||
// 20:29; then the index moves on by one
|
||||
// DC_LUT_WRITE_EN_MASK (0x1927) which components a write changes: bit 0
|
||||
// blue, 1 green, 2 red
|
||||
// DC_LUTA_CONTROL (0x1930) 0: the table; other values select PWL
|
||||
// Direct3D writes RW_MODE 0, RW_INDEX 0, WRITE_EN_MASK 7, then for each entry
|
||||
// DC_LUT_30_COLOR and RW_INDEX = the next index (a COND_WRITE once the color
|
||||
// register reads back), ending with RW_INDEX = 0x100.
|
||||
//
|
||||
// Header only, no runtime dependencies (tests/display_tone_test.cpp).
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <bitset>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::display_gamma
|
||||
{
|
||||
constexpr uint32_t kRegRwMode = 0x1921;
|
||||
constexpr uint32_t kRegRwIndex = 0x1922;
|
||||
constexpr uint32_t kRegSeqColor = 0x1923;
|
||||
constexpr uint32_t kRegPwlData = 0x1924;
|
||||
constexpr uint32_t kRegColor30 = 0x1925;
|
||||
constexpr uint32_t kRegWriteEnMask = 0x1927;
|
||||
constexpr uint32_t kRegLutaControl = 0x1930;
|
||||
|
||||
// The registers the Decoder follows (the command processor's cheap test).
|
||||
constexpr bool IsLutRegister(uint32_t index)
|
||||
{
|
||||
return index - kRegRwMode <= kRegLutaControl - kRegRwMode;
|
||||
}
|
||||
|
||||
// 256 entries of 10-bit red, green, blue: what the display shows for a
|
||||
// framebuffer value k is entry[k] / 1023.
|
||||
struct Table
|
||||
{
|
||||
std::array<std::array<uint16_t, 3>, 256> entry{};
|
||||
|
||||
bool operator==(const Table&) const = default;
|
||||
|
||||
// The ramp that changes nothing: k * 1023 / 255, rounded.
|
||||
static constexpr Table Identity()
|
||||
{
|
||||
Table t;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
t.entry[k] = { uint16_t((k * 1023 + 127) / 255), uint16_t((k * 1023 + 127) / 255), uint16_t((k * 1023 + 127) / 255) };
|
||||
return t;
|
||||
}
|
||||
};
|
||||
|
||||
class Decoder
|
||||
{
|
||||
public:
|
||||
// One register write, in command-stream order. True when it completed
|
||||
// the table: every entry written since the last completion (Direct3D
|
||||
// rewrites all 256 each time). Writes outside the LUT registers are
|
||||
// ignored.
|
||||
bool Write(uint32_t index, uint32_t value)
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case kRegRwMode:
|
||||
mode_ = value & 1;
|
||||
return false;
|
||||
case kRegRwIndex:
|
||||
index_ = value & 0xFF;
|
||||
component_ = 0;
|
||||
return false;
|
||||
case kRegWriteEnMask:
|
||||
mask_ = value & 7;
|
||||
return false;
|
||||
case kRegLutaControl:
|
||||
lutaControl_ = value;
|
||||
return false;
|
||||
case kRegColor30:
|
||||
{
|
||||
if (mode_ != 0)
|
||||
return false; // the PWL table is written through DC_LUT_PWL_DATA
|
||||
uint32_t k = index_;
|
||||
if (mask_ & 4) Set(k, 0, (value >> 20) & 0x3FF);
|
||||
if (mask_ & 2) Set(k, 1, (value >> 10) & 0x3FF);
|
||||
if (mask_ & 1) Set(k, 2, value & 0x3FF);
|
||||
if (mask_)
|
||||
written_.set(k);
|
||||
component_ = 0;
|
||||
index_ = (index_ + 1) & 0xFF;
|
||||
return Completed();
|
||||
}
|
||||
case kRegSeqColor:
|
||||
{
|
||||
if (mode_ != 0)
|
||||
return false;
|
||||
uint32_t k = index_;
|
||||
// Components in red, green, blue order; the mask's bits are
|
||||
// blue, green, red. Bits 0:5 are hardwired to zero.
|
||||
bool enabled = (mask_ >> (2 - component_)) & 1;
|
||||
if (enabled)
|
||||
{
|
||||
Set(k, component_, (value >> 6) & 0x3FF);
|
||||
seqWritten_[component_] = true;
|
||||
}
|
||||
if (++component_ == 3)
|
||||
{
|
||||
component_ = 0;
|
||||
if (seqWritten_[0] || seqWritten_[1] || seqWritten_[2])
|
||||
written_.set(k);
|
||||
seqWritten_ = {};
|
||||
index_ = (index_ + 1) & 0xFF;
|
||||
return Completed();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case kRegPwlData:
|
||||
pwlWrites_++;
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const Table& table() const { return table_; }
|
||||
// The table is what the display uses (not the PWL ramp).
|
||||
bool TableMode() const { return (lutaControl_ & 0xF) == 0; }
|
||||
// DC_LUT_PWL_DATA writes seen (a game that uses the PWL ramp: not
|
||||
// applied, logged).
|
||||
uint32_t PwlWrites() const { return pwlWrites_; }
|
||||
// Entries written since the last completed table.
|
||||
size_t Pending() const { return written_.count(); }
|
||||
|
||||
private:
|
||||
void Set(uint32_t k, uint32_t c, uint32_t v) { table_.entry[k][c] = uint16_t(v); }
|
||||
|
||||
bool Completed()
|
||||
{
|
||||
if (!written_.all())
|
||||
return false;
|
||||
written_.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
Table table_ = Table::Identity();
|
||||
std::bitset<256> written_;
|
||||
std::array<bool, 3> seqWritten_{};
|
||||
uint32_t mode_ = 0, index_ = 0, component_ = 0, mask_ = 7, lutaControl_ = 0, pwlWrites_ = 0;
|
||||
};
|
||||
|
||||
// The runtime's ramp (video/display_gamma.cpp). Command processor thread:
|
||||
// every write of a LUT register (IsLutRegister), in stream order; a
|
||||
// completed table that differs from the last is published and logged.
|
||||
void OnRegisterWrite(uint32_t index, uint32_t value);
|
||||
// Any thread: bumped by each published table; 0 while the game hasn't
|
||||
// written one.
|
||||
uint64_t Generation();
|
||||
// The last published table and its generation (0: none, `out` is then
|
||||
// the identity).
|
||||
uint64_t Latest(Table& out);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The picture's tone curve in the presenter's last pass (video/presenter.cpp):
|
||||
// Settings > Display > Brightness, and "Xbox 360 Display Gamma" (on by
|
||||
// default: the ramp the game programs into the 360's display output,
|
||||
// video/display_gamma.h). Both only touch the game's picture, never the menus
|
||||
// drawn over it, and with Brightness 0 and the ramp off nothing is applied at
|
||||
// all: the presenter draws with the same pipeline as before (Neutral()).
|
||||
//
|
||||
// Brightness is a power curve on the picture's values, as a console's
|
||||
// brightness option (and the game's own) works: black stays black, white
|
||||
// stays white, the shadows and mid-tones move. Each step multiplies the
|
||||
// exponent by kStepRatio: mid-grey moves about 6 levels of 255 a step, and
|
||||
// the ends (-5, +5) reach about as far as the games' own brightness range
|
||||
// does from its default (their ramp's exponent runs 1.06 .. 0.57 around
|
||||
// 0.815: x1.30 .. x0.70; here x1.40 .. x0.71).
|
||||
//
|
||||
// The 360's ramp, when on, comes first (it is the console's output, before
|
||||
// the TV), then Brightness.
|
||||
//
|
||||
// The lookup table is 256 entries per channel, for framebuffer values k/255;
|
||||
// the shader samples it with linear filtering at (v * 255 + 0.5) / 256, so a
|
||||
// value between two entries (a scaled pixel) is interpolated.
|
||||
//
|
||||
// Header only, no runtime dependencies (tests/display_tone_test.cpp).
|
||||
#pragma once
|
||||
#include "display_gamma.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::tone
|
||||
{
|
||||
constexpr int32_t kBrightnessMin = -5;
|
||||
constexpr int32_t kBrightnessMax = 5;
|
||||
constexpr int32_t kBrightnessDefault = 0;
|
||||
// The exponent's factor per step (brighter: smaller exponent).
|
||||
constexpr double kStepRatio = 0.935;
|
||||
|
||||
// The power applied to the picture's values at a Brightness step
|
||||
// (clamped to the range): 1 at 0.
|
||||
inline double BrightnessExponent(int32_t step)
|
||||
{
|
||||
step = std::clamp(step, kBrightnessMin, kBrightnessMax);
|
||||
return step == 0 ? 1.0 : std::pow(kStepRatio, double(step));
|
||||
}
|
||||
|
||||
// Brightness alone on one value in 0..1.
|
||||
inline double ApplyBrightness(double v, int32_t step)
|
||||
{
|
||||
v = std::clamp(v, 0.0, 1.0);
|
||||
double e = BrightnessExponent(step);
|
||||
return e == 1.0 ? v : std::pow(v, e);
|
||||
}
|
||||
|
||||
// Nothing to apply: the presenter keeps its plain pipeline.
|
||||
inline bool Neutral(int32_t brightness, bool useRamp)
|
||||
{
|
||||
return std::clamp(brightness, kBrightnessMin, kBrightnessMax) == 0 && !useRamp;
|
||||
}
|
||||
|
||||
// out[k][c]: what framebuffer value k/255 of channel c (red, green, blue)
|
||||
// becomes, 0..1.
|
||||
struct Lut
|
||||
{
|
||||
std::array<std::array<float, 3>, 256> entry{};
|
||||
};
|
||||
|
||||
// The table for a Brightness step and, when `ramp` is given, the 360's
|
||||
// display ramp before it.
|
||||
inline Lut BuildLut(int32_t brightness, const display_gamma::Table* ramp)
|
||||
{
|
||||
Lut lut;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
{
|
||||
double v = ramp ? ramp->entry[k][c] / 1023.0 : k / 255.0;
|
||||
lut.entry[k][c] = float(ApplyBrightness(v, brightness));
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
// IEEE half (the LUT image's R16G16B16A16_SFLOAT) from a float in 0..1,
|
||||
// rounded to nearest even. Values outside 0..1 are clamped.
|
||||
inline uint16_t ToHalf(float f)
|
||||
{
|
||||
f = std::clamp(f, 0.0f, 1.0f);
|
||||
if (f == 0.0f)
|
||||
return 0;
|
||||
int e = 0;
|
||||
float m = std::frexp(f, &e); // f = m * 2^e, m in [0.5, 1)
|
||||
// Half: value = (1 + frac/1024) * 2^(exp - 15), exp 1..30 (normal),
|
||||
// or frac/1024 * 2^-14 (subnormal, exp 0).
|
||||
int exp = e - 1 + 15;
|
||||
if (exp <= 0)
|
||||
{
|
||||
// Subnormal: frac = f * 2^24.
|
||||
double frac = std::nearbyint(double(f) * 16777216.0);
|
||||
return uint16_t(frac);
|
||||
}
|
||||
double frac = (double(m) * 2.0 - 1.0) * 1024.0;
|
||||
double r = std::nearbyint(frac); // ties to even (default rounding mode)
|
||||
if (r >= 1024.0)
|
||||
{
|
||||
r = 0.0;
|
||||
exp++;
|
||||
}
|
||||
return uint16_t((exp << 10) | int(r));
|
||||
}
|
||||
|
||||
inline float FromHalf(uint16_t h)
|
||||
{
|
||||
int exp = (h >> 10) & 0x1F, frac = h & 0x3FF;
|
||||
if (exp == 0)
|
||||
return std::ldexp(float(frac), -24);
|
||||
return std::ldexp(1.0f + frac / 1024.0f, exp - 15);
|
||||
}
|
||||
|
||||
// The LUT image's texels: 256 x RGBA16F (alpha 1).
|
||||
inline std::array<uint16_t, 256 * 4> ToHalfTexels(const Lut& lut)
|
||||
{
|
||||
std::array<uint16_t, 256 * 4> texels{};
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
{
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
texels[k * 4 + c] = ToHalf(lut.entry[k][c]);
|
||||
texels[k * 4 + 3] = ToHalf(1.0f);
|
||||
}
|
||||
return texels;
|
||||
}
|
||||
|
||||
// What the shader does with the table (kToneGlsl in presenter.cpp), for
|
||||
// one channel value v in 0..1: linear between the two entries around
|
||||
// v * 255.
|
||||
inline double Sample(const Lut& lut, uint32_t channel, double v)
|
||||
{
|
||||
double x = std::clamp(v, 0.0, 1.0) * 255.0;
|
||||
uint32_t i = std::min(uint32_t(x), 254u);
|
||||
double t = x - i;
|
||||
return lut.entry[i][channel] * (1.0 - t) + lut.entry[i + 1][channel] * t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See fsr1.h.
|
||||
#include "fsr1.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
// AMD's headers (thirdparty/fsr1, MIT), as text for the shaders: k_ffx_a and
|
||||
// k_ffx_fsr1 (runtime/CMakeLists.txt).
|
||||
#include <fsr1_headers.inc>
|
||||
|
||||
// The same headers' CPU code, for the constants (it defines short type
|
||||
// names and many helpers this file doesn't use).
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wunused-function"
|
||||
#pragma GCC diagnostic ignored "-Wunused-parameter"
|
||||
#define A_CPU 1
|
||||
#include <thirdparty/fsr1/ffx_a.h>
|
||||
#include <thirdparty/fsr1/ffx_fsr1.h>
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
namespace video::fsr1
|
||||
{
|
||||
namespace
|
||||
{
|
||||
std::string Text(const unsigned char* bytes)
|
||||
{
|
||||
return std::string(reinterpret_cast<const char*>(bytes));
|
||||
}
|
||||
|
||||
// Both shaders: AMD's 32-bit GLSL path (no extensions needed).
|
||||
constexpr const char* kPrelude = R"(#version 460
|
||||
#define A_GPU 1
|
||||
#define A_GLSL 1
|
||||
)";
|
||||
|
||||
// EASU, one 8x8 group per 64 pixels of the picture. Its 12 taps come
|
||||
// from 4 gathers (FsrEasuRF..BF at the corners between texels);
|
||||
// under Hor+ and Vert+ (bandOn) each of a gather's 2x2 texels is
|
||||
// fetched instead and kept in this pixel's span of the band's three
|
||||
// along each axis (band_filter.h's SpanOf and ClampTexel), so a strip
|
||||
// pixel reads only the strip and a band pixel only the band.
|
||||
// band: the band's columns x..y and rows z..w in texels (video::BandOf).
|
||||
constexpr const char* kEasuHead = R"(
|
||||
layout(local_size_x = 8, local_size_y = 8) in;
|
||||
layout(set = 0, binding = 0) uniform sampler2D src;
|
||||
layout(set = 0, binding = 1, rgba8) uniform writeonly image2D dst;
|
||||
layout(push_constant) uniform P { uvec4 con0, con1, con2, con3; ivec4 band; ivec2 outSize; ivec2 srcSize; uint bandOn; } p;
|
||||
|
||||
// This pixel's span: its first and last texels along each axis.
|
||||
ivec2 spanLo = ivec2(0), spanHi = ivec2(0);
|
||||
|
||||
// band_filter.h's SpanOf: [0, lo), [lo, hi) or [hi, size) holding t, as its first and end texels.
|
||||
ivec2 spanOf(float t, int lo, int hi, int size)
|
||||
{
|
||||
return t < float(lo) ? ivec2(0, lo) : t < float(hi) ? ivec2(lo, hi) : ivec2(hi, size);
|
||||
}
|
||||
|
||||
vec3 texelIn(ivec2 t)
|
||||
{
|
||||
return texelFetch(src, clamp(t, spanLo, spanHi), 0).rgb;
|
||||
}
|
||||
|
||||
// What a gather at q reads, each texel kept in the span, in a gather's
|
||||
// order: (x0, y1), (x1, y1), (x1, y0), (x0, y0).
|
||||
vec4 quadIn(vec2 q, int c)
|
||||
{
|
||||
ivec2 t = ivec2(floor(q * vec2(p.srcSize) - 0.5));
|
||||
return vec4(texelIn(t + ivec2(0, 1))[c], texelIn(t + ivec2(1, 1))[c], texelIn(t + ivec2(1, 0))[c], texelIn(t)[c]);
|
||||
}
|
||||
|
||||
AF4 FsrEasuRF(AF2 q) { return p.bandOn == 0u ? textureGather(src, q, 0) : quadIn(q, 0); }
|
||||
AF4 FsrEasuGF(AF2 q) { return p.bandOn == 0u ? textureGather(src, q, 1) : quadIn(q, 1); }
|
||||
AF4 FsrEasuBF(AF2 q) { return p.bandOn == 0u ? textureGather(src, q, 2) : quadIn(q, 2); }
|
||||
#define FSR_EASU_F 1
|
||||
)";
|
||||
|
||||
constexpr const char* kEasuMain = R"(
|
||||
void main()
|
||||
{
|
||||
uvec2 ip = gl_GlobalInvocationID.xy;
|
||||
if (ip.x >= uint(p.outSize.x) || ip.y >= uint(p.outSize.y))
|
||||
return;
|
||||
if (p.bandOn != 0u)
|
||||
{
|
||||
// Where this pixel's centre falls in the frame image (texels).
|
||||
vec2 pos = (vec2(ip) + 0.5) * vec2(p.srcSize) / vec2(p.outSize);
|
||||
ivec2 sx = spanOf(pos.x, p.band.x, p.band.y, p.srcSize.x), sy = spanOf(pos.y, p.band.z, p.band.w, p.srcSize.y);
|
||||
spanLo = ivec2(sx.x, sy.x);
|
||||
spanHi = ivec2(sx.y, sy.y) - 1;
|
||||
}
|
||||
AF3 c;
|
||||
FsrEasuF(c, ip, p.con0, p.con1, p.con2, p.con3);
|
||||
imageStore(dst, ivec2(ip), vec4(c, 1.0));
|
||||
}
|
||||
)";
|
||||
|
||||
// RCAS, drawn over the picture's place in the swapchain image (the
|
||||
// viewport at origin, outSize): src is EASU's image. Under Hor+ and
|
||||
// Vert+ a neighbour on the other side of the band's edge (its centre
|
||||
// maps into another span than this pixel's, by EASU's rule) reads
|
||||
// this pixel instead: the span's edge, as ClampTexel keeps a tap.
|
||||
// con.x 0 (Sharpening 0): EASU's picture as it is (RCAS's own
|
||||
// arithmetic would still scale it by its approximate reciprocal).
|
||||
constexpr const char* kRcasHead = R"(
|
||||
layout(location = 0) in vec2 uv;
|
||||
layout(location = 0) out vec4 color;
|
||||
layout(set = 0, binding = 0) uniform sampler2D src;
|
||||
layout(push_constant) uniform P { uvec4 con; ivec4 band; ivec2 origin; ivec2 outSize; ivec2 srcSize; uint bandOn; } p;
|
||||
|
||||
ivec2 centre = ivec2(0), centreSpan = ivec2(0);
|
||||
|
||||
// Which of the band's three spans along an axis holds output pixel i's centre: 0, 1 or 2.
|
||||
int spanIndex(int i, int lo, int hi, int outSize, int srcSize)
|
||||
{
|
||||
float t = (float(i) + 0.5) * float(srcSize) / float(outSize);
|
||||
return t < float(lo) ? 0 : t < float(hi) ? 1 : 2;
|
||||
}
|
||||
|
||||
AF4 FsrRcasLoadF(ASU2 q)
|
||||
{
|
||||
ivec2 t = clamp(q, ivec2(0), p.outSize - 1);
|
||||
if (p.bandOn != 0u)
|
||||
{
|
||||
if (spanIndex(t.x, p.band.x, p.band.y, p.outSize.x, p.srcSize.x) != centreSpan.x)
|
||||
t.x = centre.x;
|
||||
if (spanIndex(t.y, p.band.z, p.band.w, p.outSize.y, p.srcSize.y) != centreSpan.y)
|
||||
t.y = centre.y;
|
||||
}
|
||||
return texelFetch(src, t, 0);
|
||||
}
|
||||
void FsrRcasInputF(inout AF1 r, inout AF1 g, inout AF1 b) {}
|
||||
#define FSR_RCAS_F 1
|
||||
)";
|
||||
|
||||
constexpr const char* kRcasMain = R"(
|
||||
void main()
|
||||
{
|
||||
centre = clamp(ivec2(gl_FragCoord.xy) - p.origin, ivec2(0), p.outSize - 1);
|
||||
if (p.bandOn != 0u)
|
||||
centreSpan = ivec2(spanIndex(centre.x, p.band.x, p.band.y, p.outSize.x, p.srcSize.x),
|
||||
spanIndex(centre.y, p.band.z, p.band.w, p.outSize.y, p.srcSize.y));
|
||||
if (p.con.x == 0u)
|
||||
{
|
||||
color = vec4(texelFetch(src, centre, 0).rgb, 1.0);
|
||||
return;
|
||||
}
|
||||
AF1 r, g, b;
|
||||
FsrRcasF(r, g, b, uvec2(centre), p.con);
|
||||
color = vec4(r, g, b, 1.0);
|
||||
}
|
||||
)";
|
||||
}
|
||||
|
||||
std::array<uint32_t, 16> EasuConstants(uint32_t srcW, uint32_t srcH, uint32_t dstW, uint32_t dstH)
|
||||
{
|
||||
std::array<uint32_t, 16> con{};
|
||||
FsrEasuCon(&con[0], &con[4], &con[8], &con[12], AF1(srcW), AF1(srcH), AF1(srcW), AF1(srcH), AF1(dstW), AF1(dstH));
|
||||
return con;
|
||||
}
|
||||
|
||||
std::array<uint32_t, 4> RcasConstants(float stops)
|
||||
{
|
||||
std::array<uint32_t, 4> con{};
|
||||
if (!std::isfinite(stops))
|
||||
return con; // off: con.x 0 (the shader then copies EASU's picture)
|
||||
FsrRcasCon(con.data(), AF1(stops));
|
||||
return con;
|
||||
}
|
||||
|
||||
std::string EasuGlsl()
|
||||
{
|
||||
return kPrelude + Text(k_ffx_a) + kEasuHead + Text(k_ffx_fsr1) + kEasuMain;
|
||||
}
|
||||
|
||||
std::string RcasGlsl()
|
||||
{
|
||||
return kPrelude + Text(k_ffx_a) + kRcasHead + Text(k_ffx_fsr1) + kRcasMain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Graphics > Upscaling's FSR 1: AMD FidelityFX Super Resolution 1
|
||||
// (runtime/thirdparty/fsr1, MIT, unmodified), as two passes after the
|
||||
// anti-aliasing (video/presenter.cpp):
|
||||
// - EASU (compute), the frame image enlarged into an image the size of
|
||||
// the picture on the screen: AMD's FsrEasuF (32-bit), whose 12 taps
|
||||
// come from 4 gathers, or under Hor+ and Vert+ from fetches kept in the
|
||||
// pixel's span of the 16:9 band's three (video/band_filter.h);
|
||||
// - RCAS (fragment, into the swapchain image, where bicubic's pass draws),
|
||||
// AMD's FsrRcasF: sharpening of that image, its 4 neighbours each kept
|
||||
// in the pixel's span too; then Brightness and the 360's display gamma
|
||||
// (the tone variant, as for the other scale shaders), then the UI.
|
||||
// Their constants come from AMD's own CPU code (FsrEasuCon, FsrRcasCon).
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace video::fsr1
|
||||
{
|
||||
// EASU's push constants: FsrEasuCon's con0..con3 for a srcW x srcH
|
||||
// image enlarged to dstW x dstH.
|
||||
std::array<uint32_t, 16> EasuConstants(uint32_t srcW, uint32_t srcH, uint32_t dstW, uint32_t dstH);
|
||||
// RCAS's: FsrRcasCon's con for a strength in stops below its maximum
|
||||
// (video::upscale::RcasStops; infinity gives 0: no sharpening).
|
||||
std::array<uint32_t, 4> RcasConstants(float stops);
|
||||
|
||||
// The shaders' GLSL: AMD's two headers between ours (the RCAS one has a
|
||||
// single main(), so the tone variant can wrap it).
|
||||
std::string EasuGlsl();
|
||||
std::string RcasGlsl();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Display > Fullscreen and the window's own fullscreen state.
|
||||
//
|
||||
// The setting is the player's choice and the only thing that decides how the
|
||||
// game starts. Changing it in the menu (or Reset page) also puts the window
|
||||
// in that state now. The system can change the window too: the green button
|
||||
// or Window > Enter Full Screen (Ctrl+Cmd+F) on a Mac, a window manager's
|
||||
// fullscreen key on a Linux desktop, or a macOS fullscreen Space that fails
|
||||
// to open at launch (SDL then reports the window leaving fullscreen). Those
|
||||
// change the window for this session only and never the setting: until
|
||||
// v0.1.3 every such change was written into the setting, so the next launch
|
||||
// was fullscreen or not depending on how the window happened to be when the
|
||||
// game last saved (a quit from a green-button fullscreen started fullscreen;
|
||||
// a failed Space at launch turned the setting off).
|
||||
//
|
||||
// The menu shows the saved choice; when the window differs from it, the row
|
||||
// says what the window is now ("NOW FULLSCREEN" / "NOW WINDOWED") and its
|
||||
// help line which way the game starts (ui/settings_menu.cpp).
|
||||
//
|
||||
// Main thread. Header only (tests/fullscreen_policy_test.cpp).
|
||||
#pragma once
|
||||
#include <optional>
|
||||
|
||||
namespace video
|
||||
{
|
||||
class FullscreenPolicy
|
||||
{
|
||||
public:
|
||||
// Who changed the window, for its log line.
|
||||
enum class Change
|
||||
{
|
||||
Requested, // the game asked for it (launch, the setting): nothing to do
|
||||
System, // the system did: the window only, the setting stays
|
||||
};
|
||||
|
||||
// At launch: whether to create the window fullscreen. `setting` is
|
||||
// the effective value (NFSMW_FULLSCREEN or settings.toml); `locked`:
|
||||
// always fullscreen (SteamOS Game Mode, iOS).
|
||||
bool Launch(bool setting, bool locked)
|
||||
{
|
||||
m_locked = locked;
|
||||
m_setting = setting;
|
||||
m_window = m_requested = locked || setting;
|
||||
return m_window;
|
||||
}
|
||||
|
||||
// The setting's effective value now (checked whenever the settings
|
||||
// change: the menu, Reset page). When it changed, the window is to be
|
||||
// put in that state: the value to ask SDL for, unless the window is
|
||||
// there already or locked.
|
||||
std::optional<bool> SettingIs(bool setting)
|
||||
{
|
||||
if (m_locked || setting == m_setting)
|
||||
return std::nullopt;
|
||||
m_setting = setting;
|
||||
if (setting == m_window)
|
||||
return std::nullopt;
|
||||
m_window = m_requested = setting;
|
||||
return setting;
|
||||
}
|
||||
|
||||
// SDL reported the window entering (true) or leaving fullscreen. The
|
||||
// setting is never touched: a change the game didn't ask for is the
|
||||
// system's, for this session only.
|
||||
Change WindowIs(bool fullscreen)
|
||||
{
|
||||
m_window = fullscreen;
|
||||
if (fullscreen == m_requested)
|
||||
return Change::Requested;
|
||||
m_requested = fullscreen;
|
||||
return Change::System;
|
||||
}
|
||||
|
||||
bool Window() const { return m_window; } // the window's state
|
||||
bool Setting() const { return m_setting; } // the choice as last applied
|
||||
// The window isn't as the setting says (a system change this session).
|
||||
bool WindowDiffers() const { return !m_locked && m_window != m_setting; }
|
||||
|
||||
private:
|
||||
bool m_locked = false;
|
||||
bool m_setting = false;
|
||||
bool m_window = false;
|
||||
bool m_requested = false; // what the game last asked for (or the system last did)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// When a new frame is shown at a set time after its guest vblank (macOS:
|
||||
// VK_GOOGLE_display_timing's desiredPresentTime, video/presenter.cpp), and
|
||||
// the watchdog that turns that off for the session when such frames don't
|
||||
// reach the screen.
|
||||
//
|
||||
// It helps where it was measured to: a MacBook Pro's 120 Hz ProMotion panel
|
||||
// in fullscreen, which shows each frame when its GPU work is done (on a 240 Hz
|
||||
// timing grid), so frames shown when ready came 8-29 ms apart while driving,
|
||||
// and 99.9% of them 16.67 ms apart when shown at a time. On a 60 Hz display
|
||||
// the guest vblank locks to the refresh grid instead (VblankLock), and frames
|
||||
// are shown when ready, as in v0.1.3: in a macOS 15 VM (Apple's paravirtual
|
||||
// display, 60 Hz) frames shown at a time left the fullscreen picture black
|
||||
// while the game ran at 60 fps, and spoiled the present-wait times the lock
|
||||
// fits (rms 1.3-4.3 ms against its 0.5 ms limit, where v0.1.3 had 0.3-0.4),
|
||||
// so it never locked. There Metal didn't keep the time asked for: from ~10 s
|
||||
// in, 97% of the frames were reported shown 9 ms (median) BEFORE it, right
|
||||
// after their present, while the screen stayed black (no time-base mix-up:
|
||||
// the steady clock and Metal's host time agreed there to the microsecond).
|
||||
// On the ProMotion panel no frame of 46,000 was reported more than 1.2 ms
|
||||
// before its time. So, with no NFSMW_PRESENT_AT (Auto):
|
||||
// - only on a display whose mode is a 120 Hz one (117-123 Hz: twice the
|
||||
// lock's 60 Hz range), and only while the vblank isn't locked to it (a
|
||||
// ProMotion panel has no refresh grid to lock to; should the lock take
|
||||
// it, frames are shown when ready until it lets go);
|
||||
// - never once the watchdog has tripped.
|
||||
// NFSMW_PRESENT_AT=<ms> > 0 shows them at a time on any display (On, still
|
||||
// watched); NFSMW_PRESENT_AT=0 never (Off).
|
||||
//
|
||||
// The watchdog reads the driver's report on each frame shown at a time
|
||||
// (VK_GOOGLE_display_timing's past presentation timing: Metal's presentedTime,
|
||||
// 0 when the drawable was never shown) and trips when, of the last kWindow
|
||||
// such frames reported, kBadOfWindow or more were shown more than kEarlyNs
|
||||
// before their time (the time not kept: the VM's black screen), never shown,
|
||||
// or shown more than kLateNs after it; or when kUnreported frames shown at a
|
||||
// time in a row have been presented without any report coming back. One
|
||||
// frame shown late now and then (its GPU work not done in time) is normal:
|
||||
// on the ProMotion panel up to 21 ms. In the VM the first seconds' frames were
|
||||
// each reported ~100 ms after their present (88 ms after their time), and
|
||||
// then, from ~10 s, the display froze on its last picture for the rest of the
|
||||
// run, frames reported early (above); the freeze outlasted the trip, so the
|
||||
// trip must come before it.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace video
|
||||
{
|
||||
class PresentSchedule
|
||||
{
|
||||
public:
|
||||
enum class Mode
|
||||
{
|
||||
Auto, // NFSMW_PRESENT_AT unset
|
||||
On, // NFSMW_PRESENT_AT=<ms> > 0
|
||||
Off, // NFSMW_PRESENT_AT=0
|
||||
};
|
||||
static constexpr double kMinFastHz = 117.0, kMaxFastHz = 123.0;
|
||||
static constexpr uint32_t kWindow = 120; // 2 s of frames
|
||||
static constexpr uint32_t kBadOfWindow = 60;
|
||||
static constexpr int64_t kEarlyNs = 2'000'000;
|
||||
static constexpr int64_t kLateNs = 50'000'000;
|
||||
static constexpr uint32_t kUnreported = 180; // 3 s of frames
|
||||
|
||||
explicit PresentSchedule(Mode mode = Mode::Auto) : m_mode(mode) {}
|
||||
|
||||
Mode GetMode() const { return m_mode; }
|
||||
// The display's mode, at each swapchain and display change (0: unknown).
|
||||
void SetDisplayHz(double hz) { m_fastDisplay = hz >= kMinFastHz && hz <= kMaxFastHz; }
|
||||
bool FastDisplay() const { return m_fastDisplay; }
|
||||
|
||||
// A new frame, just before its present: show it at its vblank's
|
||||
// time? `locked`: the guest vblank is locked to the display.
|
||||
bool Wanted(bool locked) const
|
||||
{
|
||||
if (m_tripped || m_mode == Mode::Off)
|
||||
return false;
|
||||
return m_mode == Mode::On || (m_fastDisplay && !locked);
|
||||
}
|
||||
|
||||
// A frame shown at a time was presented (its present wait returned).
|
||||
// True: this trips the watchdog (no report for kUnreported of them).
|
||||
bool Presented()
|
||||
{
|
||||
if (m_tripped)
|
||||
return false;
|
||||
if (++m_unreported < kUnreported)
|
||||
return false;
|
||||
m_tripped = true;
|
||||
m_reason = Reason::Unreported;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The driver's report on a frame shown at a time: whether it was
|
||||
// shown, and how long after its time (ns; below 0: before). True:
|
||||
// this trips the watchdog.
|
||||
bool Reported(bool shown, int64_t lateNs)
|
||||
{
|
||||
m_unreported = 0;
|
||||
if (m_tripped)
|
||||
return false;
|
||||
const bool bad = !shown || lateNs < -kEarlyNs || lateNs > kLateNs;
|
||||
m_early += shown && lateNs < -kEarlyNs;
|
||||
if (m_count == kWindow)
|
||||
m_bad -= m_window[m_next];
|
||||
else
|
||||
m_count++;
|
||||
m_window[m_next] = bad;
|
||||
m_bad += bad;
|
||||
m_next = (m_next + 1) % kWindow;
|
||||
m_notShown += !shown;
|
||||
if (m_bad < kBadOfWindow)
|
||||
return false;
|
||||
m_tripped = true;
|
||||
m_reason = Reason::NotKept;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Tripped() const { return m_tripped; }
|
||||
enum class Reason
|
||||
{
|
||||
None,
|
||||
NotKept, // kBadOfWindow of the last kWindow shown early, never, or far late
|
||||
Unreported, // kUnreported presented without a report
|
||||
};
|
||||
Reason GetReason() const { return m_reason; }
|
||||
uint32_t BadInWindow() const { return m_bad; }
|
||||
uint32_t ReportedInWindow() const { return m_count; }
|
||||
uint64_t NotShown() const { return m_notShown; }
|
||||
uint64_t Early() const { return m_early; }
|
||||
|
||||
private:
|
||||
Mode m_mode;
|
||||
bool m_fastDisplay = false;
|
||||
bool m_tripped = false;
|
||||
Reason m_reason = Reason::None;
|
||||
bool m_window[kWindow] = {};
|
||||
uint32_t m_next = 0, m_count = 0, m_bad = 0;
|
||||
uint32_t m_unreported = 0;
|
||||
uint64_t m_notShown = 0, m_early = 0;
|
||||
};
|
||||
}
|
||||
+1065
-80
File diff suppressed because it is too large.
Load diff
@@ -153,6 +153,12 @@ namespace video
|
||||
// there.
|
||||
bool FullscreenLocked();
|
||||
|
||||
// Main thread: whether the window is fullscreen now, and whether that
|
||||
// differs from Settings > Display > Fullscreen because the system changed
|
||||
// it this session (the green button; video/fullscreen_policy.h).
|
||||
bool WindowFullscreen();
|
||||
bool FullscreenWindowDiffers();
|
||||
|
||||
// SteamOS Game Mode (gamescope; NFSMW_GAME_MODE=1|0 says otherwise), as
|
||||
// detected when the window was made. No desktop is on screen there, so a
|
||||
// system file dialog can't be seen either.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The swapchain the present waiter's thread may use, against the main thread
|
||||
// destroying it and creating another (video/presenter.cpp).
|
||||
//
|
||||
// The waiter (PresentWaiter) waits for each present to reach the screen
|
||||
// (vkWaitForPresentKHR) and, on a Mac showing frames at a time, reads the
|
||||
// driver's reports on the presents shown (vkGetPastPresentationTimingGOOGLE,
|
||||
// for present_schedule.h's watchdog). Both take the swapchain. The main
|
||||
// thread destroys it and makes another whenever the surface changes: a
|
||||
// fullscreen toggle, a resize, V-Sync. In v0.1.4 and v0.1.5 the wait looked
|
||||
// at the swapchain under a mutex, but the reports were read after it,
|
||||
// outside it: when the main thread destroyed the swapchain in between,
|
||||
// MoltenVK read a freed swapchain and the game aborted ("mutex lock failed"
|
||||
// in vkGetPastPresentationTimingGOOGLE: a MacBook's 120 Hz panel, the
|
||||
// default settings, fullscreen toggled again and again). The reports had
|
||||
// been read that way only for NFSMW_VBLANK_LOG until v0.1.4's watchdog read
|
||||
// them every frame.
|
||||
//
|
||||
// Every use of the swapchain off the main thread goes through Use(), which
|
||||
// runs it only while the swapchain it is for is still the one published, and
|
||||
// holds Retire() off until it returns. Retire() (before the swapchain is
|
||||
// destroyed) returns once no use is under way, and none starts after it.
|
||||
// Uses name the swapchain by its generation, not its handle: a new swapchain
|
||||
// may get a destroyed one's handle (MoltenVK's and Mesa's are pointers), and
|
||||
// a present to the old one must not be waited for on the new one.
|
||||
//
|
||||
// Header only (tests/swapchain_use_test.cpp).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
namespace video
|
||||
{
|
||||
template <typename Handle>
|
||||
class SwapchainUse
|
||||
{
|
||||
public:
|
||||
// Main thread, once `swapchain` is made: uses may take it from now
|
||||
// on. Its generation (never 0), which they name it by.
|
||||
uint64_t Publish(Handle swapchain)
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_swapchain = swapchain;
|
||||
m_generation = ++m_lastGeneration;
|
||||
return m_generation;
|
||||
}
|
||||
|
||||
// Main thread, before the published swapchain is destroyed: on
|
||||
// return no use of it is under way, and none will start.
|
||||
void Retire()
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_swapchain = Handle{};
|
||||
m_generation = 0;
|
||||
}
|
||||
|
||||
// Any thread: use(swapchain) if `generation` is still the published
|
||||
// one, with Retire() held off until it returns. False: that
|
||||
// swapchain is gone (or about to be), and `use` didn't run.
|
||||
template <typename F>
|
||||
bool Use(uint64_t generation, F&& use)
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
if (generation == 0 || generation != m_generation)
|
||||
return false;
|
||||
use(m_swapchain);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
std::mutex m_mutex;
|
||||
Handle m_swapchain{};
|
||||
uint64_t m_generation = 0; // the published one's; 0: none
|
||||
uint64_t m_lastGeneration = 0;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Which pass draws the game's picture into the window (video/presenter.cpp),
|
||||
// and Settings > Graphics > Sharpening as FSR 1's RCAS strength. Pure
|
||||
// functions, for tests/upscale_test.cpp.
|
||||
//
|
||||
// Settings > Graphics > Upscaling: Bicubic (Catmull-Rom, sharpened and
|
||||
// clamped to the 2x2 texels around each sample), Bilinear, or FSR 1 (AMD
|
||||
// FidelityFX Super Resolution 1: EASU, an edge-adaptive Lanczos-like
|
||||
// upscale, then RCAS, a contrast-adaptive sharpening pass; video/fsr1.h).
|
||||
// FSR 1 is made for enlarging (AMD: 1x to 4x the pixels): it is used only
|
||||
// when the picture on the screen is larger than the frame image, along one
|
||||
// axis or both, and smaller along neither (1x Internal Resolution on a
|
||||
// 1080p, 1440p or MacBook screen; the Steam Deck's 720 rows stretched to
|
||||
// 800). A frame shown at its own size, or shrunk (2x on a 1080p screen),
|
||||
// takes the bicubic pass exactly as before FSR 1 existed.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::upscale
|
||||
{
|
||||
// Settings > Graphics > Upscaling's values (settings::ScalingMode).
|
||||
enum class Mode : int32_t { Bicubic = 0, Linear = 1, Fsr1 = 2 };
|
||||
|
||||
enum class Pass : uint8_t { Linear, Bicubic, Fsr1 };
|
||||
|
||||
inline const char* PassName(Pass pass)
|
||||
{
|
||||
return pass == Pass::Fsr1 ? "FSR 1" : pass == Pass::Bicubic ? "bicubic" : "bilinear";
|
||||
}
|
||||
|
||||
// The picture enlarged: dstW x dstH (its place on the screen) is larger
|
||||
// than the srcW x srcH image along one axis and smaller along neither.
|
||||
inline bool Enlarged(uint32_t srcW, uint32_t srcH, int32_t dstW, int32_t dstH)
|
||||
{
|
||||
if (!srcW || !srcH || dstW <= 0 || dstH <= 0)
|
||||
return false;
|
||||
const bool notSmaller = uint32_t(dstW) >= srcW && uint32_t(dstH) >= srcH;
|
||||
return notSmaller && (uint32_t(dstW) > srcW || uint32_t(dstH) > srcH);
|
||||
}
|
||||
|
||||
// The pass for this frame. An unknown mode is bicubic, the default.
|
||||
inline Pass Choose(int32_t mode, uint32_t srcW, uint32_t srcH, int32_t dstW, int32_t dstH)
|
||||
{
|
||||
switch (Mode(mode))
|
||||
{
|
||||
case Mode::Linear:
|
||||
return Pass::Linear;
|
||||
case Mode::Fsr1:
|
||||
return Enlarged(srcW, srcH, dstW, dstH) ? Pass::Fsr1 : Pass::Bicubic;
|
||||
default:
|
||||
return Pass::Bicubic;
|
||||
}
|
||||
}
|
||||
|
||||
// Sharpening (0..1, default 0.3) for RCAS: the strength AMD's FsrRcasCon
|
||||
// takes as stops below its maximum (0: the most it sharpens, each stop
|
||||
// half as much), here as the linear factor it becomes (2^-stops), which
|
||||
// multiplies RCAS's sharpening lobe: Sharpening 1 is RCAS's maximum,
|
||||
// 0.5 half of it, and 0 turns RCAS off (EASU's picture as it is), as 0
|
||||
// turns bicubic's sharpening off.
|
||||
inline float RcasAmount(float sharpening)
|
||||
{
|
||||
if (!(sharpening > 0.0f)) // 0, negative or NaN
|
||||
return 0.0f;
|
||||
return std::min(sharpening, 1.0f);
|
||||
}
|
||||
|
||||
// The same as FsrRcasCon's stops (infinity: off).
|
||||
inline float RcasStops(float sharpening)
|
||||
{
|
||||
const float amount = RcasAmount(sharpening);
|
||||
return amount > 0.0f ? -std::log2(amount) : INFINITY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See vblank_timer.h.
|
||||
#include "vblank_timer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <string_view>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
// macOS only: iOS keeps the ordinary thread (its vblank timing hasn't been measured).
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
#define SB_REALTIME_VBLANK 1
|
||||
#include <mach/mach.h>
|
||||
#include <mach/mach_time.h>
|
||||
#include <mach/thread_policy.h>
|
||||
#include <pthread.h>
|
||||
#endif
|
||||
|
||||
namespace video
|
||||
{
|
||||
namespace
|
||||
{
|
||||
#ifdef SB_REALTIME_VBLANK
|
||||
uint32_t ToMach(int64_t ns)
|
||||
{
|
||||
static const mach_timebase_info_data_t timebase = [] {
|
||||
mach_timebase_info_data_t tb{};
|
||||
mach_timebase_info(&tb);
|
||||
return tb;
|
||||
}();
|
||||
return uint32_t(double(std::max<int64_t>(ns, 0)) * double(timebase.denom) / double(timebase.numer));
|
||||
}
|
||||
#endif
|
||||
|
||||
int64_t Percentile(std::vector<int64_t> v, size_t permille)
|
||||
{
|
||||
if (v.empty())
|
||||
return 0;
|
||||
size_t k = std::min(v.size() - 1, v.size() * permille / 1000);
|
||||
std::nth_element(v.begin(), v.begin() + ptrdiff_t(k), v.end());
|
||||
return v[k];
|
||||
}
|
||||
}
|
||||
|
||||
VblankTimer ChosenVblankTimer()
|
||||
{
|
||||
const char* v = std::getenv("NFSMW_VBLANK_TIMER");
|
||||
return v && std::string_view(v) == "sleep" ? VblankTimer::Ordinary : VblankTimer::RealTime;
|
||||
}
|
||||
|
||||
bool PrepareVblankThread(VblankTimer timer, int64_t periodNs)
|
||||
{
|
||||
#ifdef SB_REALTIME_VBLANK
|
||||
if (timer != VblankTimer::RealTime)
|
||||
return false;
|
||||
// Woken at its time, ahead of the game's own threads: it runs the
|
||||
// guest's vblank handler (microseconds) and sleeps again.
|
||||
thread_time_constraint_policy_data_t policy{};
|
||||
policy.period = ToMach(periodNs);
|
||||
policy.computation = ToMach(1'000'000);
|
||||
policy.constraint = ToMach(2'000'000);
|
||||
policy.preemptible = 1;
|
||||
return thread_policy_set(pthread_mach_thread_np(pthread_self()), THREAD_TIME_CONSTRAINT_POLICY,
|
||||
reinterpret_cast<thread_policy_t>(&policy), THREAD_TIME_CONSTRAINT_POLICY_COUNT) == KERN_SUCCESS;
|
||||
#else
|
||||
(void)timer;
|
||||
(void)periodNs;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
const char* VblankTimerName(VblankTimer timer, bool realtime)
|
||||
{
|
||||
#ifdef SB_REALTIME_VBLANK
|
||||
if (timer == VblankTimer::RealTime)
|
||||
return realtime ? "real-time" : "ordinary (real-time refused)";
|
||||
return "ordinary (NFSMW_VBLANK_TIMER=sleep)";
|
||||
#else
|
||||
(void)timer;
|
||||
(void)realtime;
|
||||
return "ordinary";
|
||||
#endif
|
||||
}
|
||||
|
||||
void VblankTiming::Add(int64_t lateNs, int64_t handlerNs)
|
||||
{
|
||||
if (m_late.size() < kCount)
|
||||
{
|
||||
m_late.push_back(lateNs);
|
||||
m_handler.push_back(handlerNs);
|
||||
return;
|
||||
}
|
||||
m_late[m_next] = lateNs;
|
||||
m_handler[m_next] = handlerNs;
|
||||
m_next = (m_next + 1) % kCount;
|
||||
}
|
||||
|
||||
VblankTiming::Summary VblankTiming::Summarize() const
|
||||
{
|
||||
Summary s;
|
||||
s.count = uint32_t(m_late.size());
|
||||
if (m_late.empty())
|
||||
return s;
|
||||
s.lateMedianNs = double(Percentile(m_late, 500));
|
||||
s.late99Ns = double(Percentile(m_late, 990));
|
||||
s.lateMaxNs = double(*std::max_element(m_late.begin(), m_late.end()));
|
||||
s.handler99Ns = double(Percentile(m_handler, 990));
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The guest vblank's timer (gpu/command_processor.cpp's Vsync thread): how
|
||||
// that thread is scheduled, and how late the vblanks fire.
|
||||
//
|
||||
// macOS: the thread slept with std::this_thread::sleep_until as an ordinary
|
||||
// thread, a timer macOS lets fire late (coalescing): on a MacBook Pro its
|
||||
// vblanks came 3.2 ms late at the median and up to 9 ms (8.5-25 ms apart
|
||||
// instead of 16.667), idle or not, and the game's frames left for the display
|
||||
// that unevenly. At the user-interactive class it was the same (3.3 ms), and
|
||||
// mach_wait_until didn't help either: what does is real-time scheduling, the
|
||||
// time-constraint policy Core Audio's threads run under, which the thread now
|
||||
// asks for (0.01 ms late at the median, 0.02 in 99%). NFSMW_VBLANK_TIMER=sleep
|
||||
// keeps the ordinary thread (an A/B switch). Elsewhere nothing changes: on the
|
||||
// Steam Machine (Linux's high-resolution timers) the vblanks were already
|
||||
// within 0.06 ms of their time.
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace video
|
||||
{
|
||||
enum class VblankTimer
|
||||
{
|
||||
Ordinary, // the thread as created
|
||||
RealTime, // macOS: the time-constraint policy; elsewhere (iOS too) Ordinary
|
||||
};
|
||||
|
||||
// NFSMW_VBLANK_TIMER=sleep: Ordinary; otherwise RealTime.
|
||||
VblankTimer ChosenVblankTimer();
|
||||
// The calling thread, once, before its first sleep: its scheduling for
|
||||
// vblanks `periodNs` apart. True: real-time scheduling was granted.
|
||||
bool PrepareVblankThread(VblankTimer timer, int64_t periodNs);
|
||||
// For the log: "real-time", "ordinary (NFSMW_VBLANK_TIMER=sleep)",
|
||||
// "ordinary (real-time refused)" or, off macOS, "ordinary".
|
||||
const char* VblankTimerName(VblankTimer timer, bool realtime);
|
||||
|
||||
// The last minute of vblanks: how late each fired after its time (the
|
||||
// timer's wake-up, or a guest interrupt handler before it that ran past
|
||||
// it) and how long the guest's interrupt handler ran. One thread.
|
||||
class VblankTiming
|
||||
{
|
||||
public:
|
||||
static constexpr size_t kCount = 3600;
|
||||
|
||||
void Add(int64_t lateNs, int64_t handlerNs);
|
||||
struct Summary
|
||||
{
|
||||
uint32_t count = 0;
|
||||
double lateMedianNs = 0, late99Ns = 0, lateMaxNs = 0;
|
||||
double handler99Ns = 0;
|
||||
};
|
||||
Summary Summarize() const;
|
||||
|
||||
private:
|
||||
std::vector<int64_t> m_late, m_handler;
|
||||
size_t m_next = 0;
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# SpeedBreaker. GPL-3.0-or-later (see COPYING).
|
||||
#
|
||||
# make_app.py [build dir] [output dir] [--dmg] [--min-macos=<version>]:
|
||||
# make_app.py [build dir] [output dir] [--dmg] [--min-macos=<version>] [--test]:
|
||||
# SpeedBreaker.app for macOS (Apple silicon) from a build (default
|
||||
# build/main), in build/app:
|
||||
# Contents/MacOS/SpeedBreaker the game
|
||||
@@ -53,6 +53,11 @@
|
||||
# <output>/symbols/SpeedBreaker-<version>-<commit>-macos.dSYM, for us: never
|
||||
# published, they map a player's report back to the exact build.
|
||||
#
|
||||
# --test: an app to try a build with, named so it can't be taken for a
|
||||
# release: "SpeedBreaker (test).app", its displayed name likewise, and its
|
||||
# bundle identifier with ".test" (not with --dmg: a test app is never a
|
||||
# download). Its game, saves and settings are the release's (user/paths.h).
|
||||
#
|
||||
# --dmg: also <output>/SpeedBreaker-macos.dmg (the app and a shortcut to
|
||||
# Applications, to drag it onto) with its .sha256, the release download, and
|
||||
# a copy named by the version (SpeedBreaker-<version>-macos.dmg, a hard link).
|
||||
@@ -75,13 +80,22 @@ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)
|
||||
ARGS = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
OPTIONS = [a for a in sys.argv[1:] if a.startswith('--')]
|
||||
DMG = '--dmg' in OPTIONS
|
||||
TEST = '--test' in OPTIONS
|
||||
MIN_MACOS = next((a.split('=', 1)[1] for a in OPTIONS if a.startswith('--min-macos=')), None)
|
||||
DMG_TOOL = os.environ.get('DMG_TOOL', '') # '' (hdiutil, else diskutil), 'hdiutil' or 'diskutil'
|
||||
if DMG_TOOL not in ('', 'hdiutil', 'diskutil'):
|
||||
raise SystemExit(f'make_app.py: DMG_TOOL={DMG_TOOL}: use hdiutil or diskutil, or leave it unset')
|
||||
for a in OPTIONS:
|
||||
if a != '--dmg' and not a.startswith('--min-macos='):
|
||||
raise SystemExit(f'make_app.py: unknown option {a} (usage: make_app.py [build dir] [output dir] [--dmg] [--min-macos=<version>])')
|
||||
if a not in ('--dmg', '--test') and not a.startswith('--min-macos='):
|
||||
raise SystemExit(f'make_app.py: unknown option {a} (usage: make_app.py [build dir] [output dir] [--dmg] '
|
||||
f'[--min-macos=<version>] [--test])')
|
||||
if TEST and DMG:
|
||||
raise SystemExit('make_app.py: --test and --dmg: a test app is never a download')
|
||||
# The app's name (the bundle's, the displayed one and the menu bar's), bundle
|
||||
# identifier and symbols' name; --test's are its own.
|
||||
NAME = 'SpeedBreaker (test)' if TEST else 'SpeedBreaker'
|
||||
BUNDLE_ID = 'local.speedbreaker.SpeedBreaker' + ('.test' if TEST else '')
|
||||
FILE = 'SpeedBreaker-test' if TEST else 'SpeedBreaker'
|
||||
BUILD = os.path.abspath(ARGS[0] if len(ARGS) > 0 else os.path.join(ROOT, 'build/main'))
|
||||
OUT = os.path.abspath(ARGS[1] if len(ARGS) > 1 else os.path.join(ROOT, 'build/app'))
|
||||
BIN = os.path.join(BUILD, 'runtime/SpeedBreaker')
|
||||
@@ -492,7 +506,7 @@ def main():
|
||||
print(f'make_app.py: warning: {BUILD} is configured for macOS {minimum}, not the project\'s {default} '
|
||||
f'(CMakeLists.txt): not for release', file=sys.stderr)
|
||||
mvk_version = moltenvk_version()
|
||||
app = os.path.join(OUT, 'SpeedBreaker.app')
|
||||
app = os.path.join(OUT, NAME + '.app')
|
||||
shutil.rmtree(app, ignore_errors=True)
|
||||
contents = os.path.join(app, 'Contents')
|
||||
macos, frameworks = os.path.join(contents, 'MacOS'), os.path.join(contents, 'Frameworks')
|
||||
@@ -505,7 +519,7 @@ def main():
|
||||
# The full symbols for us, then the debug map out of the app's copy.
|
||||
symbols = os.path.join(OUT, 'symbols')
|
||||
os.makedirs(symbols, exist_ok=True)
|
||||
dsym = os.path.join(symbols, f'SpeedBreaker-{version}-{commit}{"-dirty" if dirty else ""}-macos.dSYM')
|
||||
dsym = os.path.join(symbols, f'{FILE}-{version}-{commit}{"-dirty" if dirty else ""}-macos.dSYM')
|
||||
shutil.rmtree(dsym, ignore_errors=True)
|
||||
subprocess.run(['dsymutil', BIN, '-o', dsym], check=True, capture_output=True)
|
||||
run('strip', '-S', exe)
|
||||
@@ -602,10 +616,10 @@ def main():
|
||||
'CFBundleDevelopmentRegion': 'en',
|
||||
'CFBundleExecutable': 'SpeedBreaker',
|
||||
'CFBundleIconFile': 'AppIcon',
|
||||
'CFBundleIdentifier': 'local.speedbreaker.SpeedBreaker',
|
||||
'CFBundleIdentifier': BUNDLE_ID,
|
||||
'CFBundleInfoDictionaryVersion': '6.0',
|
||||
'CFBundleName': 'SpeedBreaker',
|
||||
'CFBundleDisplayName': 'SpeedBreaker',
|
||||
'CFBundleName': NAME,
|
||||
'CFBundleDisplayName': NAME,
|
||||
'CFBundlePackageType': 'APPL',
|
||||
'CFBundleShortVersionString': version,
|
||||
'CFBundleVersion': build_number(commit),
|
||||
|
||||
@@ -29,7 +29,8 @@ as the relaunch phases reuse the video phase's profile):
|
||||
the game's exposure adapts, so a change is judged against the two
|
||||
captures around it, and a bracket with the menu open and nothing
|
||||
changed is the control): anti-aliasing, upscaling, sharpening,
|
||||
anisotropic filtering, aspect ratio, the performance overlay;
|
||||
anisotropic filtering, aspect ratio, Brightness, Xbox 360 Display
|
||||
Gamma, the performance overlay;
|
||||
Mipmaps and Internal Resolution marked RESTART and changing
|
||||
nothing yet; V-Sync's present mode and frame rate; the deadzones
|
||||
on a virtual pad. Also the defaults of a new profile, and
|
||||
@@ -99,9 +100,10 @@ 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'],
|
||||
'Display': ['video.fullscreen', 'video.vsync', 'video.frame_rate', 'video.aspect_ratio', 'video.brightness',
|
||||
'video.xbox_display_gamma'],
|
||||
'Graphics': ['video.anti_aliasing', 'video.scaling', 'video.sharpening', 'video.anisotropic_filtering',
|
||||
'video.mipmaps', 'video.internal_resolution'],
|
||||
'Audio': ['audio.master_volume', 'audio.mute', 'audio.mute_in_background'],
|
||||
@@ -112,7 +114,7 @@ ROWS = {
|
||||
DEFAULTS = {
|
||||
'video.fullscreen': 'false', 'video.vsync': 'true', 'video.frame_rate': '"60"', 'video.present_scale': '"100"',
|
||||
'video.anti_aliasing': 'true', 'video.scaling': '"bicubic"',
|
||||
'video.aspect_ratio': '"auto"', 'video.sharpening': '0.3', 'video.anisotropic_filtering': '"auto"',
|
||||
'video.aspect_ratio': '"auto"', 'video.brightness': '0', 'video.xbox_display_gamma': 'true', 'video.sharpening': '0.3', 'video.anisotropic_filtering': '"auto"',
|
||||
'video.mipmaps': 'true', 'video.internal_resolution': '"auto"', 'audio.master_volume': '100', 'audio.mute': 'false',
|
||||
'audio.mute_in_background': 'false', 'input.stick_deadzone': '0.08', 'input.trigger_deadzone': '0.02',
|
||||
'input.vibration': 'true', 'advanced.async_shaders': 'true', 'advanced.performance_overlay': 'false',
|
||||
@@ -269,6 +271,8 @@ def phase_video():
|
||||
p.close()
|
||||
picture_checks(p)
|
||||
bracket(p, 'aspect_169', [('video.aspect_ratio', 1, '16:9')], [('video.aspect_ratio', -1, 'Fill Screen')])
|
||||
bracket(p, 'bright_2', [('video.brightness', 2, '+2')], [('video.brightness', -2, '0')])
|
||||
bracket(p, 'gamma_off', [('video.xbox_display_gamma', -1, 'Off')], [('video.xbox_display_gamma', 1, 'On')])
|
||||
# Restart options: changed, the picture as before; put back (the running
|
||||
# values again), as before too.
|
||||
bracket(p, 'restart', [('video.mipmaps', 1, 'Off', 'at the next launch'),
|
||||
@@ -818,6 +822,14 @@ def analyze_video(res, run, log):
|
||||
ok = side(b) < 1.0 and side(r) > 4.0 and side(back) > 4.0
|
||||
res.add('video.aspect_ratio', phase, '16:9: black bars at the sides; Fill Screen again fills them',
|
||||
'PASS' if ok else 'FAIL', f'outer {edge} px columns, mean luma: {side(r):.1f} -> {side(b):.2f} -> {side(back):.1f}')
|
||||
# Brightness +2 (exponent 0.874): about +8..+13 levels in the shadows and
|
||||
# mid-tones. The 360's display gamma (the game's ramp at its default
|
||||
# brightness: 16 -> 12.5, 128 -> 134.4) is on by default: turned off,
|
||||
# lighter shadows and darker mid-tones.
|
||||
tone_shift(res, 'video.brightness', phase, '+2: brighter shadows and mid-tones', s, 'bright_2',
|
||||
{(24, 64): (3, 15), (96, 160): (6, 16)})
|
||||
tone_shift(res, 'video.xbox_display_gamma', phase, "off: lighter shadows, darker mid-tones (the game's ramp gone)", s, 'gamma_off',
|
||||
{(6, 20): (0.5, 6), (96, 160): (-10, -2)})
|
||||
unchanged_picture(res, 'video.mipmaps', phase, 'Mipmaps and Internal Resolution changed and put back: '
|
||||
'the picture as with nothing changed', s, 'restart')
|
||||
vsync_results(res, phase, log)
|
||||
@@ -983,6 +995,40 @@ def changes_picture(res, option, phase, check, s, label, rows, strict):
|
||||
f'(1% by chance; want over 5%), or detail energy x{ratio:.3f} (want off 1 by over {margin:.3f})')
|
||||
|
||||
|
||||
def tone_shift(res, option, phase, check, s, label, want):
|
||||
"""Brightness and the 360's display gamma: per band of the captures'
|
||||
luma before the change, how far the changed capture moved from the two
|
||||
around it (interpolated to its time), over the blocks those two agree
|
||||
on. `want` maps (lo, hi) luma bands to (min, max) shifts in levels."""
|
||||
tri = bracket_shots(s, label)
|
||||
if not tri:
|
||||
res.add(option, phase, check, 'INCONCLUSIVE', f'captures for {label} missing')
|
||||
return
|
||||
r, c, b, w = tri
|
||||
keep = still_blocks(r, b)
|
||||
if keep.mean() < 0.5:
|
||||
res.add(option, phase, check, 'INCONCLUSIVE', f'the scene moved around the change ({keep.mean():.0%} still)')
|
||||
return
|
||||
h, wd = keep.shape[0] * BLOCK, keep.shape[1] * BLOCK
|
||||
mask = np.repeat(np.repeat(keep, BLOCK, axis=0), BLOCK, axis=1)
|
||||
luma = lambda x: x[:h, :wd] @ np.array([0.2126, 0.7152, 0.0722])
|
||||
lr, lc, lb = luma(r), luma(c), luma(b)
|
||||
base = lr + w * (lb - lr)
|
||||
parts, ok = [], True
|
||||
for (lo, hi), (mn, mx) in want.items():
|
||||
sel = mask & (base >= lo) & (base < hi)
|
||||
if sel.sum() < 500:
|
||||
parts.append(f'{lo}-{hi}: too few pixels')
|
||||
ok = False
|
||||
continue
|
||||
d = float(np.mean(lc[sel] - base[sel]))
|
||||
good = mn <= d <= mx
|
||||
ok = ok and good
|
||||
parts.append(f'{lo}-{hi}: {d:+.1f} (want {mn:+g}..{mx:+g})')
|
||||
back = float(np.mean(np.abs(lb - lr)[mask]))
|
||||
res.add(option, phase, check, 'PASS' if ok else 'FAIL', '; '.join(parts) + f'; put back: mean difference {back:.2f}')
|
||||
|
||||
|
||||
def picture_results(res, phase, s, strict_aniso=False):
|
||||
bracketed(res, 'video.anti_aliasing', phase, 'off: more detail energy (jagged edges)', s, 'aa_off', 'up')
|
||||
bracketed(res, 'video.scaling', phase, 'bilinear: softer than bicubic', s, 'bilinear', 'down')
|
||||
|
||||
+102
-3
@@ -30,9 +30,15 @@ sb_test(depth_only_test ARGS ${RT}/gpu/renderer.cpp)
|
||||
# and the scaled resolve memory's layout (gpu/render_scale.h, header only);
|
||||
# renderer.cpp is read to check its scaled-memory shaders use it.
|
||||
sb_test(render_scale_test ARGS ${RT}/gpu/renderer.cpp)
|
||||
# The police vid-cam's side bars and the fill past the 16:9 band
|
||||
# (gpu/edge_bars.h, header only).
|
||||
# The police vid-cam's side bars, the intro's black frames, the results
|
||||
# screen's backdrop and what fills the screen round them (gpu/edge_bars.h,
|
||||
# header only).
|
||||
sb_test(edge_bars_test)
|
||||
# GitHub #19's menu borders (gpu/edge_bars.h, header only): the Showcase's
|
||||
# vignette stretched to the screen's edges, the Blacklist bio's black panel
|
||||
# filled all round, at every screen shape; renderer.cpp is read to check it
|
||||
# asks for and applies the plans on both paths.
|
||||
sb_test(menu_borders_test ARGS ${RT}/gpu/renderer.cpp)
|
||||
# A saved pipeline cache from another GPU, driver or OS version, and the log
|
||||
# line that says so (gpu/pipeline_cache_header.h, header only).
|
||||
sb_test(pipeline_cache_header_test)
|
||||
@@ -44,23 +50,83 @@ 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)
|
||||
# Settings > Display > Brightness's curve and the 360's display gamma ramp
|
||||
# (video/display_tone.h, video/display_gamma.h: the DC_LUT_* registers as
|
||||
# Direct3D writes them), header only; presenter.cpp is read to check the
|
||||
# tone shader's wrapping of the scale shaders, settings.cpp for the two
|
||||
# options' defaults (Xbox 360 Display Gamma on since v0.1.4).
|
||||
sb_test(display_tone_test ARGS ${RT}/video/presenter.cpp ${RT}/user/settings.cpp)
|
||||
sb_test(refresh_fit_test SOURCES ${RT}/video/refresh_fit.cpp)
|
||||
# How late the guest vblank's thread wakes on this machine, alone and with
|
||||
# every core busy (video/vblank_timer.cpp; checked on macOS, where its
|
||||
# scheduling changed in v0.1.4).
|
||||
sb_test(vblank_timer_test SOURCES ${RT}/video/vblank_timer.cpp)
|
||||
# When the Mac shows a new frame at a set time after its vblank, and the
|
||||
# watchdog that turns it off (video/present_schedule.h, header only).
|
||||
sb_test(present_schedule_test)
|
||||
# The audio render callback's cadence against the output device
|
||||
# (apu/audio_cadence.h, header only): a device that stops taking audio
|
||||
# doesn't stop the game's sound (GitHub issue #20: the sound stopped for good,
|
||||
# then the game hung after the race), and a working one is followed as before.
|
||||
sb_test(audio_cadence_test)
|
||||
# The present waiter uses a swapchain only while it is the current one, and
|
||||
# none is destroyed under such a use (video/swapchain_use.h, header only):
|
||||
# fake swapchains destroyed and made again under a waiting thread; presenter.cpp
|
||||
# is read to check every waiter call that takes a swapchain is inside a use.
|
||||
sb_test(swapchain_use_test ARGS ${RT}/video/presenter.cpp)
|
||||
sb_test(frame_rate_policy_test SOURCES ${RT}/video/frame_rate_policy.cpp)
|
||||
# When the presenter builds its swapchain again and at what size (an iPhone's
|
||||
# view turned portrait by SDL, sizes seen while inactive, SUBOPTIMAL), and the
|
||||
# UI's display size: scenarios, then planted wrong rules that must fail one.
|
||||
sb_test(swapchain_policy_test SOURCES ${RT}/video/swapchain_policy.cpp)
|
||||
# The front end's matrix after the window changes shape: the game's
|
||||
# pointer-keyed matrix cache and the hook's resend (game/front_end_matrix.h,
|
||||
# header only; the "EVENT HANDLER" label above the boot PSA).
|
||||
sb_test(front_end_matrix_test)
|
||||
# Settings > Display > Fullscreen against the window's own state
|
||||
# (video/fullscreen_policy.h, header only): the green button and other system
|
||||
# changes never change the saved setting, the menu does; presenter.cpp is read
|
||||
# to check its window events don't write the setting.
|
||||
sb_test(fullscreen_policy_test ARGS ${RT}/video/presenter.cpp)
|
||||
# Game time in whole vblanks (game/whole_vblanks.h), header only.
|
||||
sb_test(whole_vblanks_test)
|
||||
# The nitrous light trails' ring at the 360's 30 fps length (game/light_trails.h,
|
||||
# header only; GitHub issue #10).
|
||||
sb_test(light_trails_test)
|
||||
sb_test(guest_time_test SOURCES ${RT}/cpu/guest_time.cpp)
|
||||
# When the mouse cursor shows over the game (ui/cursor_idle.h, header only),
|
||||
# and how ui.cpp drives it.
|
||||
sb_test(cursor_idle_test ARGS ${RT}/ui/ui.cpp)
|
||||
sb_test(report_zip_test SOURCES ${RT}/report/zip.cpp ARGS ${CMAKE_CURRENT_BINARY_DIR}/report_zip_out)
|
||||
find_package(Python3 COMPONENTS Interpreter)
|
||||
if(Python3_FOUND)
|
||||
@@ -112,9 +178,42 @@ sb_test(memory_status_test SOURCES ${RT}/kernel/memory_status.cpp ${RT}/platform
|
||||
# The version line in the log, and when the "What's new" note shows
|
||||
# (user/version_check.h, header only).
|
||||
sb_test(version_check_test)
|
||||
# The keys a Controls row can hold and their names in settings.toml
|
||||
# (user/key_names.h, header only), each row against SDL's scancode name.
|
||||
# The only test that links SDL3, so it keeps the build tree's path to it even
|
||||
# with -DCMAKE_SKIP_BUILD_RPATH=ON: the Steam Frame's cmake line
|
||||
# (scripts/steamos/README.md) keeps ~/deps/sdl3/lib out of the game that
|
||||
# way, and ctest runs the tests without LD_LIBRARY_PATH, so this one couldn't
|
||||
# load libSDL3 there (v0.1.5). Only this test: the game's own rpath is
|
||||
# unchanged (none on the Frame).
|
||||
find_package(SDL3 REQUIRED CONFIG)
|
||||
sb_test(key_names_test)
|
||||
target_link_libraries(key_names_test PRIVATE SDL3::SDL3)
|
||||
set_target_properties(key_names_test PROPERTIES SKIP_BUILD_RPATH OFF)
|
||||
# The Key options (Settings > Controls): the table, the [keys] names in the
|
||||
# file, and what Set accepts (user/settings.cpp and the toml++ XenonRecomp
|
||||
# vendors; no SDL).
|
||||
sb_test(settings_keys_test SOURCES ${RT}/user/settings.cpp)
|
||||
target_include_directories(settings_keys_test SYSTEM PRIVATE ${ROOT}/tools/XenonRecomp/thirdparty/tomlplusplus/include)
|
||||
# The keyboard's controls and how held keys become a pad (hid/key_map.h), header only.
|
||||
sb_test(key_map_test)
|
||||
# Check for Updates' logic: versions, GitHub's JSON, curl's output and the
|
||||
# release notes (update/check.cpp, self-contained).
|
||||
sb_test(update_check_test SOURCES ${RT}/update/check.cpp)
|
||||
# Settings > Quit Game: where it is offered (not on iPhone or iPad) and its
|
||||
# confirmation (ui/quit_game.h, header only); the runtime's code around it
|
||||
# is read: the button and prompt gated, the focus on Cancel, B and Escape
|
||||
# cancelling, and the quit taking the window close's way out.
|
||||
sb_test(quit_game_test ARGS ${RT})
|
||||
# A requested termination (SIGTERM: a launcher's stop, a shutdown, kill;
|
||||
# SIGINT; SIGHUP) quits as the window's close button does, and only a session that
|
||||
# didn't end cleanly leaves its crash marker (platform/quit_signals.h,
|
||||
# report/crash_marker.h, header only): signals in child processes, the marker
|
||||
# in a scratch folder, SIGTERM vs SIGKILL end to end; main.cpp, presenter.cpp
|
||||
# and crash.cpp are read to check the quit paths use them. POSIX only (fork).
|
||||
if(NOT WIN32)
|
||||
sb_test(clean_exit_test ARGS ${CMAKE_CURRENT_BINARY_DIR}/clean_exit_scratch ${RT})
|
||||
endif()
|
||||
# The jump tables XenonRecomp makes switches of (config/switch_tables.toml)
|
||||
# against the leaf functions' bounds (config/nfsmw.toml): no label or default
|
||||
# outside its function, and the tables a borrowed bounds check broke (the
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// audio_cadence_test: the render callback's cadence against the output
|
||||
// device (apu/audio_cadence.h), run against a simulated device that takes
|
||||
// `pull` frames every `pull` ticks of its own clock (as a 1024-sample buffer
|
||||
// takes 4 frames of 256), a little or a lot fast or slow, or that stops
|
||||
// taking audio.
|
||||
// 1. GitHub issue #20: a device that stops taking audio for good. The game's
|
||||
// sound (its render callback) carries on at real time once the device
|
||||
// has taken nothing for StallTicks; every frame is dropped from then on,
|
||||
// so the queue is held. The cadence as it was up to v0.1.5 rendered
|
||||
// nothing from the moment the queue filled up: that is checked too, as
|
||||
// the bug's model.
|
||||
// 2. A device that takes audio, from 5% slow to 5% fast (SDL's dummy driver
|
||||
// runs ~3% slow), with pulls of 1 to 16 frames: never a stall, nothing
|
||||
// dropped, and frame for frame what the v0.1.5 cadence rendered, for 10
|
||||
// minutes.
|
||||
// 3. A device that stalls for 2 s and takes audio again: the stall ends at
|
||||
// its first pull, the stale queue is cleared, and the queue is back at
|
||||
// its target within a second, nothing dropped after.
|
||||
// 4. A route change to a larger buffer (Bluetooth headphones: target 8 ->
|
||||
// 32 frames, pulls of 16): as before, frame for frame.
|
||||
// 5. The stall threshold: StallTicks idle ticks, not one fewer.
|
||||
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/audio_cadence_test.cpp -o /tmp/audio_cadence_test
|
||||
#include <apu/audio_cadence.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failed = 0;
|
||||
|
||||
void Check(bool ok, const std::string& what)
|
||||
{
|
||||
if (!ok)
|
||||
{
|
||||
s_failed++;
|
||||
fprintf(stderr, "FAIL: %s\n", what.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// The cadence up to v0.1.5 (apu/audio.cpp): one frame more or less when
|
||||
// the queue strays more than 2 frames from its target, nothing else.
|
||||
int OldFrames(int queued, int target)
|
||||
{
|
||||
return queued > target + 2 ? 0 : queued < target - 2 ? 2 : 1;
|
||||
}
|
||||
|
||||
struct Device
|
||||
{
|
||||
int pull = 4; // frames taken at once
|
||||
double rate = 1.0; // its clock against ours
|
||||
bool taking = true;
|
||||
double due = 0; // ticks of its clock until its next pull
|
||||
|
||||
// One tick: what it takes from a queue of `queued` frames.
|
||||
int Take(int queued)
|
||||
{
|
||||
if (!taking)
|
||||
return 0;
|
||||
int took = 0;
|
||||
for (due -= rate; due <= 0; due += pull)
|
||||
took += std::min(queued - took, pull);
|
||||
return took;
|
||||
}
|
||||
};
|
||||
|
||||
struct Sim
|
||||
{
|
||||
apu::AudioCadence cadence;
|
||||
bool oldRule = false;
|
||||
int target = 8;
|
||||
int queued = 0;
|
||||
int lastFrames = 0;
|
||||
long long rendered = 0, dropped = 0, taken = 0;
|
||||
int maxQueued = 0;
|
||||
|
||||
// One tick (5.33 ms): the device takes what it takes, then the
|
||||
// render thread renders.
|
||||
void Tick(Device& d)
|
||||
{
|
||||
const int took = d.Take(queued);
|
||||
queued -= took;
|
||||
taken += took;
|
||||
const int frames = oldRule ? OldFrames(queued, target) : cadence.Frames(queued, target);
|
||||
if (!oldRule && cadence.Resumed())
|
||||
queued = 0; // audio.cpp clears the stale audio
|
||||
for (int i = 0; i < frames; i++)
|
||||
{
|
||||
rendered++;
|
||||
if (!oldRule && cadence.Stalled())
|
||||
dropped++;
|
||||
else
|
||||
queued++;
|
||||
}
|
||||
lastFrames = frames;
|
||||
maxQueued = std::max(maxQueued, queued);
|
||||
}
|
||||
};
|
||||
|
||||
constexpr long long kTicksPerSecond = 48000 / 256; // 187.5 -> 187
|
||||
|
||||
void DeviceStopsForGood(bool oldRule)
|
||||
{
|
||||
Sim s;
|
||||
s.oldRule = oldRule;
|
||||
Device d;
|
||||
for (int i = 0; i < 1000; i++) // ~5 s of normal play
|
||||
s.Tick(d);
|
||||
d.taking = false;
|
||||
const long long before = s.rendered;
|
||||
const long long ticks = 60 * kTicksPerSecond; // a minute of a device that takes nothing
|
||||
for (long long i = 0; i < ticks; i++)
|
||||
s.Tick(d);
|
||||
const long long rendered = s.rendered - before;
|
||||
if (oldRule)
|
||||
{
|
||||
// The bug's model: once the queue is over its target, no render
|
||||
// at all, so the game's mixer never runs again (#20).
|
||||
Check(rendered <= 8, "old rule: a stalled device stops the render callback (rendered " + std::to_string(rendered) + ")");
|
||||
return;
|
||||
}
|
||||
// Before the stall shows, the queue sits over its target and frames
|
||||
// are skipped (as before); from then on, one a tick.
|
||||
const long long allowed = apu::AudioCadence::StallTicks(s.target) + 1;
|
||||
Check(rendered >= ticks - allowed, "a stalled device: the game's sound carries on at real time (rendered " +
|
||||
std::to_string(rendered) + " of " + std::to_string(ticks) + " ticks)");
|
||||
Check(s.cadence.Stalled() && s.cadence.Stalls() == 1, "a stalled device is one stall, still on");
|
||||
Check(s.maxQueued <= s.target + apu::AudioCadence::kSlack + 2,
|
||||
"a stalled device: the queue is held (max " + std::to_string(s.maxQueued) + ")");
|
||||
Check(s.dropped >= rendered - (s.target + apu::AudioCadence::kSlack + 2),
|
||||
"a stalled device: what it can't take is dropped (dropped " + std::to_string(s.dropped) + ")");
|
||||
}
|
||||
|
||||
// A device that takes audio: the cadence renders frame for frame what
|
||||
// the v0.1.5 one did, and never sees a stall.
|
||||
void TakesAudio(double rate, int pull, int target, long long ticks)
|
||||
{
|
||||
Sim s, old;
|
||||
old.oldRule = true;
|
||||
s.target = old.target = target;
|
||||
Device d, od;
|
||||
d.rate = od.rate = rate;
|
||||
d.pull = od.pull = pull;
|
||||
long long differ = 0;
|
||||
for (long long i = 0; i < ticks; i++)
|
||||
{
|
||||
s.Tick(d);
|
||||
old.Tick(od);
|
||||
differ += s.lastFrames != old.lastFrames;
|
||||
}
|
||||
const std::string what = "a device at " + std::to_string(rate) + "x, pulls of " + std::to_string(pull) + ", target " +
|
||||
std::to_string(target) + ": ";
|
||||
Check(s.cadence.Stalls() == 0 && s.dropped == 0, what + "no stall, nothing dropped (" + std::to_string(s.cadence.Stalls()) +
|
||||
" stalls, " + std::to_string(s.dropped) + " dropped)");
|
||||
Check(differ == 0, what + "rendered as v0.1.5 did, frame for frame (" + std::to_string(differ) + " ticks differ)");
|
||||
Check(s.rendered == s.taken + s.queued, what + "rendered = taken + queued");
|
||||
}
|
||||
|
||||
void StallsAndResumes()
|
||||
{
|
||||
Sim s;
|
||||
Device d;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
s.Tick(d);
|
||||
d.taking = false;
|
||||
int stalledAt = -1;
|
||||
for (long long i = 0; i < 2 * kTicksPerSecond; i++)
|
||||
{
|
||||
s.Tick(d);
|
||||
if (stalledAt < 0 && s.cadence.Stalled())
|
||||
stalledAt = int(i);
|
||||
}
|
||||
Check(stalledAt == apu::AudioCadence::StallTicks(s.target) - 1, "the stall shows after StallTicks idle ticks (" +
|
||||
std::to_string(stalledAt + 1) + ")");
|
||||
Check(s.dropped > 0, "a 2 s stall drops frames");
|
||||
d.taking = true;
|
||||
long long droppedAtResume = -1;
|
||||
int over = -1, back = -1;
|
||||
for (long long i = 0; i < 60 * kTicksPerSecond; i++)
|
||||
{
|
||||
s.Tick(d);
|
||||
if (over < 0 && !s.cadence.Stalled())
|
||||
{
|
||||
over = int(i);
|
||||
droppedAtResume = s.dropped;
|
||||
s.maxQueued = 0;
|
||||
}
|
||||
if (over >= 0 && back < 0 && s.queued >= s.target - apu::AudioCadence::kSlack - d.pull &&
|
||||
s.queued <= s.target + apu::AudioCadence::kSlack)
|
||||
back = int(i);
|
||||
}
|
||||
Check(over >= 0 && over <= d.pull, "the stall ends at the device's first pull (" + std::to_string(over) + " ticks)");
|
||||
Check(!s.cadence.Stalled() && s.cadence.Stalls() == 1, "one stall, over");
|
||||
Check(back >= 0 && back <= kTicksPerSecond, "the queue is back at its target within a second (" + std::to_string(back) + " ticks)");
|
||||
Check(s.dropped == droppedAtResume, "nothing dropped once the device takes audio again");
|
||||
Check(s.maxQueued <= s.target + apu::AudioCadence::kSlack + 2, "no stale audio left after the stall (max queue " +
|
||||
std::to_string(s.maxQueued) + ")");
|
||||
}
|
||||
|
||||
void RouteToLargerBuffer()
|
||||
{
|
||||
Sim s, old;
|
||||
old.oldRule = true;
|
||||
Device d, od;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
s.Tick(d);
|
||||
old.Tick(od);
|
||||
}
|
||||
// Bluetooth headphones: a 4096-sample buffer (16 frames), target 32.
|
||||
s.target = old.target = 32;
|
||||
d.pull = od.pull = 16;
|
||||
long long differ = 0;
|
||||
for (long long i = 0; i < 60 * kTicksPerSecond; i++)
|
||||
{
|
||||
s.Tick(d);
|
||||
old.Tick(od);
|
||||
differ += s.lastFrames != old.lastFrames;
|
||||
}
|
||||
Check(differ == 0 && s.cadence.Stalls() == 0 && s.dropped == 0,
|
||||
"a route change to a larger buffer: as before, frame for frame (" + std::to_string(differ) + " ticks differ)");
|
||||
}
|
||||
|
||||
void StallThreshold()
|
||||
{
|
||||
using C = apu::AudioCadence;
|
||||
for (int target : { 4, 8, 32 })
|
||||
{
|
||||
// A queue over its target that stays put: nothing rendered, and
|
||||
// the device takes nothing.
|
||||
const int queued = target + C::kSlack + 1;
|
||||
C c;
|
||||
c.Frames(queued, target);
|
||||
for (int idle = 1; idle < C::StallTicks(target); idle++)
|
||||
c.Frames(queued, target);
|
||||
Check(!c.Stalled(), "target " + std::to_string(target) + ": one idle tick short of StallTicks isn't a stall");
|
||||
c.Frames(queued, target);
|
||||
Check(c.Stalled(), "target " + std::to_string(target) + ": StallTicks idle ticks are a stall");
|
||||
c.Frames(queued, target);
|
||||
Check(c.Stalled(), "a stalled queue that stays put is still a stall");
|
||||
c.Frames(queued - 1, target);
|
||||
Check(!c.Stalled() && c.Resumed() && !c.Resumed(), "a stall ends when the device takes a frame, reported once");
|
||||
}
|
||||
Check(C::StallTicks(8) == C::kStallTicks && C::StallTicks(64) == 256, "StallTicks: half a second, or four targets");
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
DeviceStopsForGood(true);
|
||||
DeviceStopsForGood(false);
|
||||
for (double rate : { 0.95, 0.966, 0.998, 1.0, 1.002, 1.05 })
|
||||
for (int pull : { 1, 2, 4, 16 })
|
||||
TakesAudio(rate, pull, pull == 16 ? 32 : 8, 600 * kTicksPerSecond);
|
||||
StallsAndResumes();
|
||||
RouteToLargerBuffer();
|
||||
StallThreshold();
|
||||
if (s_failed)
|
||||
{
|
||||
fprintf(stderr, "%d checks failed\n", s_failed);
|
||||
return 1;
|
||||
}
|
||||
printf("audio_cadence_test: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
+78
-11
@@ -6,9 +6,10 @@
|
||||
// build/band_filter_test
|
||||
// What it checks:
|
||||
// - BandOf's rows and columns are those whose centres lie inside the band
|
||||
// (a pixel-centre oracle), and with the edge-bar fills' rows
|
||||
// (gpu::bars::BlackoutRects) they split the frame with no gap and no
|
||||
// overlap, at every internal resolution (a fuzz over screen shapes too);
|
||||
// (a pixel-centre oracle), and with the edge-bar fills' rows (gpu::
|
||||
// bars::BlackoutRects of a frame as big as the band) they split the
|
||||
// frame with no gap and no overlap, at every internal resolution (a fuzz
|
||||
// over screen shapes too);
|
||||
// - the scaling pass, modelled along the axis that is split (bilinear,
|
||||
// Catmull-Rom with its 2x2 clamp and sharpening, and the 7-fetch
|
||||
// variant), on a frame whose strips are black and whose band is white:
|
||||
@@ -16,10 +17,17 @@
|
||||
// every one in the band exactly white (the career intro's movie
|
||||
// letterbox bled ~5% of the band into the strip's last row before);
|
||||
// - every fetch the anti-aliasing pass makes for a pixel reads only its
|
||||
// own span's texels.
|
||||
// own span's texels;
|
||||
// - FSR 1, where the picture is enlarged along the split axis: each of
|
||||
// EASU's taps for a screen pixel (texels floor(t - 0.5) - 1 to + 2 round
|
||||
// its centre t) reads only that pixel's span, and each neighbour RCAS
|
||||
// reads (the pixel's own picture, one pixel either way) lies in the same
|
||||
// span, so the black strips stay black and the band's edge rows equal its
|
||||
// middle ones.
|
||||
// The same checks run with planted faults (no split, a band edge one texel
|
||||
// off either way, clamping to the span's edges instead of its texel
|
||||
// centres): each must fail.
|
||||
// centres, an unclamped gather or EASU tap, RCAS reading across the edge):
|
||||
// each must fail.
|
||||
#include <gpu/edge_bars.h>
|
||||
#include <video/band_filter.h>
|
||||
#include <video/picture_fit.h>
|
||||
@@ -48,6 +56,8 @@ struct Model
|
||||
std::function<Span(float t, int32_t lo, int32_t hi, int32_t size)> spanOf = video::band::SpanOf;
|
||||
std::function<float(float t, Span s)> clamp = video::band::Clamp;
|
||||
std::function<int32_t(int32_t i, Span s)> clampTexel = video::band::ClampTexel;
|
||||
std::function<int32_t(int32_t i, int32_t j, int32_t lo, int32_t hi, int32_t outSize, int32_t srcSize)> rcasNeighbour =
|
||||
video::band::RcasNeighbour;
|
||||
};
|
||||
|
||||
// One axis of the frame image: texel values, the sampler's clamp to edge.
|
||||
@@ -174,13 +184,55 @@ static int AaCase(const Model& m, int32_t size, int32_t lo0, int32_t hi0)
|
||||
return g_failures - before;
|
||||
}
|
||||
|
||||
// FSR 1 along the split axis of one screen and image size, where the
|
||||
// picture is enlarged that way (else bicubic draws it: skipped). EASU's taps
|
||||
// for each screen pixel, and the screen pixels RCAS reads for it, against
|
||||
// where that pixel truly is. Returns the failures it added.
|
||||
static int FsrCase(const Model& m, const Shape& screen, const Shape& image)
|
||||
{
|
||||
const int before = g_failures;
|
||||
const video::PictureFit fit = video::FitPicture(screen.w, screen.h, 1280.0f, 720.0f, true, true);
|
||||
const video::FrameBand band = video::BandOf(image.w, image.h, fit.scaleX, fit.scaleY);
|
||||
const bool tall = fit.scaleY < 1.0f;
|
||||
const int32_t size = int32_t(tall ? image.h : image.w), pixels = tall ? fit.height : fit.width;
|
||||
if (pixels <= size)
|
||||
return 0;
|
||||
const int32_t trueLo = tall ? band.y0 : band.x0, trueHi = tall ? band.y1 : band.x1;
|
||||
int32_t lo = trueLo, hi = trueHi;
|
||||
m.band(lo, hi, size);
|
||||
auto own = [&](int32_t r) { return video::band::SpanOf(video::band::SourceOf(r, pixels, size), trueLo, trueHi, size); };
|
||||
for (int32_t r = 0; r < pixels && g_failures - before <= 8; r++)
|
||||
{
|
||||
const float t = video::band::SourceOf(r, pixels, size);
|
||||
const Span s = m.spanOf(t, lo, hi, size), truth = own(r);
|
||||
const int32_t fp = int32_t(std::floor(t - 0.5f));
|
||||
for (int32_t k = -1; k <= 2; k++)
|
||||
{
|
||||
const int32_t texel = std::clamp(m.clampTexel(fp + k, s), 0, size - 1); // and the sampler's clamp to edge
|
||||
CHECK(texel >= truth.a && texel < truth.b, "%ux%u, image %ux%u: EASU tap %+d of screen pixel %d (frame %.3f) reads texel %d outside its span [%d,%d)",
|
||||
screen.w, screen.h, image.w, image.h, k, r, t, texel, truth.a, truth.b);
|
||||
}
|
||||
for (int32_t d : { -1, 1 })
|
||||
{
|
||||
const int32_t read = m.rcasNeighbour(r, r + d, lo, hi, pixels, size);
|
||||
const Span rs = own(read);
|
||||
CHECK(rs.a == truth.a && rs.b == truth.b, "%ux%u, image %ux%u: RCAS's neighbour %+d of screen pixel %d reads pixel %d, in [%d,%d) not [%d,%d)",
|
||||
screen.w, screen.h, image.w, image.h, d, r, read, rs.a, rs.b, truth.a, truth.b);
|
||||
}
|
||||
}
|
||||
return g_failures - before;
|
||||
}
|
||||
|
||||
static int AllCases(const Model& m)
|
||||
{
|
||||
int failed = 0;
|
||||
for (const Shape& s : kScreens)
|
||||
for (const Shape& im : kImages)
|
||||
{
|
||||
for (int kind = 0; kind < 3; kind++)
|
||||
failed += ScaleCase(m, s, im, kind) ? 1 : 0;
|
||||
failed += FsrCase(m, s, im) ? 1 : 0;
|
||||
}
|
||||
// The anti-aliasing at the frame's resolution: 16:10, 4:3, 3:2 rows and
|
||||
// ultrawide columns at 1x and 2x.
|
||||
for (const Shape& s : { Shape{ 1280, 800 }, Shape{ 1024, 768 }, Shape{ 1200, 800 }, Shape{ 2560, 1080 } })
|
||||
@@ -194,9 +246,10 @@ static int AllCases(const Model& m)
|
||||
}
|
||||
|
||||
// BandOf against the pixel-centre oracle, and against the edge-bar fills:
|
||||
// with the black frame's fill rows (gpu::bars::BlackoutRects, through a
|
||||
// full-frame front-end viewport: NDC y = -clip y) every row is in exactly
|
||||
// one of the band and the fill. Columns likewise under Hor+.
|
||||
// with the fill of a black frame as big as the band (gpu::bars::
|
||||
// BlackoutRects, through a full-frame front-end viewport: NDC y = -clip y)
|
||||
// every row is in exactly one of the band and the fill. Columns likewise
|
||||
// under Hor+.
|
||||
static void BandRowsCase(uint32_t w, uint32_t h, float sx, float sy)
|
||||
{
|
||||
const video::FrameBand b = video::BandOf(w, h, sx, sy);
|
||||
@@ -220,8 +273,11 @@ static void BandRowsCase(uint32_t w, uint32_t h, float sx, float sy)
|
||||
CHECK(wrong == 0, "%ux%u scale %.4f: %d %s differ from the pixel-centre oracle", w, h, scale, wrong, axis ? "rows" : "columns");
|
||||
}
|
||||
const float ps[4] = { 1.0f, -1.0f, 0.0f, 0.0f };
|
||||
gpu::bars::Rect r[2]{};
|
||||
const uint32_t n = gpu::bars::BlackoutRects(ps, float(w), float(h), { 0, 0, int32_t(w), int32_t(h) }, sx, sy, r);
|
||||
// The fill of a black frame as big as the band itself: everything past
|
||||
// the band (the rows above and below it, or the columns beside it).
|
||||
const gpu::bars::Box band{ -sx, sx, -sy, sy };
|
||||
gpu::bars::Rect r[4]{};
|
||||
const uint32_t n = gpu::bars::BlackoutRects(band, ps, float(w), float(h), { 0, 0, int32_t(w), int32_t(h) }, r);
|
||||
int wrongPx = 0;
|
||||
for (int32_t y = 0; y < int32_t(h); y += (y % 97 == 0 ? 1 : 7))
|
||||
for (int32_t x = 0; x < int32_t(w); x += 13)
|
||||
@@ -281,7 +337,16 @@ int main()
|
||||
|
||||
const Model fixed{ "band_filter.h" };
|
||||
const int failed = AllCases(fixed);
|
||||
printf("scaling (3 filters x %zu screens x %zu images) and anti-aliasing fetches: %d case(s) failed\n", std::size(kScreens), std::size(kImages), failed);
|
||||
int fsrCases = 0;
|
||||
for (const Shape& s : kScreens)
|
||||
for (const Shape& im : kImages)
|
||||
{
|
||||
const video::PictureFit fit = video::FitPicture(s.w, s.h, 1280.0f, 720.0f, true, true);
|
||||
fsrCases += (fit.scaleY < 1.0f ? fit.height > int32_t(im.h) : fit.width > int32_t(im.w)) ? 1 : 0;
|
||||
}
|
||||
CHECK(fsrCases >= 8, "only %d FSR 1 cases enlarge along the split axis", fsrCases);
|
||||
printf("scaling (3 filters + FSR 1 where enlarged, %d cases, x %zu screens x %zu images) and anti-aliasing fetches: %d case(s) failed\n",
|
||||
fsrCases, std::size(kScreens), std::size(kImages), failed);
|
||||
|
||||
printf("planted faults (each must fail):\n");
|
||||
const Model planted[] = {
|
||||
@@ -293,6 +358,8 @@ int main()
|
||||
[](float t, Span s) { return std::clamp(t, float(s.a), float(s.b)); } },
|
||||
{ "no clamp of the gather", [](int32_t&, int32_t&, int32_t) {}, video::band::SpanOf, video::band::Clamp,
|
||||
[](int32_t i, Span) { return i; } },
|
||||
{ "RCAS reads across the edge", [](int32_t&, int32_t&, int32_t) {}, video::band::SpanOf, video::band::Clamp, video::band::ClampTexel,
|
||||
[](int32_t, int32_t j, int32_t, int32_t, int32_t outSize, int32_t) { return std::clamp(j, 0, outSize - 1); } },
|
||||
};
|
||||
for (const Model& m : planted)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// A requested termination quits the game cleanly, and only a session that
|
||||
// didn't end cleanly leaves its crash marker (platform/quit_signals.h,
|
||||
// report/crash_marker.h). v0.1.5 left the empty marker after every session
|
||||
// (a Steam Frame session closed from Steam left one, as if it had crashed),
|
||||
// and SIGHUP ended it at once, saving nothing.
|
||||
// - Signals, each case in a child process: SIGTERM, SIGINT and SIGHUP
|
||||
// become a quit request and the process lives on to quit; the first
|
||||
// request is the one kept; a signal ignored at launch (nohup) stays
|
||||
// ignored; SDL's own handler would leave them to us (it takes a signal
|
||||
// only while its action is the default); SIGKILL still kills. Controls:
|
||||
// without the handlers SIGHUP kills (v0.1.5).
|
||||
// - The marker: made empty, removed by a clean end; what the next launch
|
||||
// finds after a clean end, a SIGKILL, a crash report it couldn't rename,
|
||||
// and beside the current session's own. Then main()'s order end to end in
|
||||
// child processes: SIGTERM -> the quit -> no marker; SIGKILL -> marker.
|
||||
// - The optional argument is the runtime folder: main.cpp, presenter.cpp
|
||||
// and crash.cpp are read to check the handlers come before SDL, the frame
|
||||
// loop leaves on a request, every clean end clears the marker (last, just
|
||||
// before the exit) and a crash being reported keeps it; then planted
|
||||
// mutations of them that must fail.
|
||||
//
|
||||
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/clean_exit_test.cpp -o build/clean_exit_test
|
||||
// build/clean_exit_test [<scratch folder>] [runtime]
|
||||
#include <platform/quit_signals.h>
|
||||
#include <report/crash_marker.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace
|
||||
{
|
||||
// `body` in a child process: its exit code (0: passed), or 128 + the
|
||||
// signal that killed it.
|
||||
int InChild(const std::function<int()>& body)
|
||||
{
|
||||
fflush(stdout);
|
||||
const pid_t pid = fork();
|
||||
if (pid == 0)
|
||||
_exit(body());
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) ? WEXITSTATUS(status) : WIFSIGNALED(status) ? 128 + WTERMSIG(status) : 255;
|
||||
}
|
||||
|
||||
bool WaitFor(const std::function<bool()>& done, int ms)
|
||||
{
|
||||
for (int i = 0; i < ms; i++)
|
||||
{
|
||||
if (done())
|
||||
return true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
return done();
|
||||
}
|
||||
|
||||
void Signals()
|
||||
{
|
||||
printf("signals:\n");
|
||||
for (int sig : quitsignals::kSignals)
|
||||
{
|
||||
const int rc = InChild([sig] {
|
||||
quitsignals::Install();
|
||||
raise(sig);
|
||||
return quitsignals::Requested() == sig ? 0 : 1;
|
||||
});
|
||||
CHECK(rc == 0, "%s: a quit request, and the game lives on to quit (child: %d)", quitsignals::Name(sig), rc);
|
||||
}
|
||||
// From another process (as Steam, systemd or kill send it), while
|
||||
// the game runs.
|
||||
{
|
||||
fflush(stdout);
|
||||
const pid_t pid = fork();
|
||||
if (pid == 0)
|
||||
{
|
||||
quitsignals::Install();
|
||||
const bool seen = WaitFor([] { return quitsignals::Requested() != 0; }, 5000);
|
||||
_exit(seen && quitsignals::Requested() == SIGTERM ? 0 : 1);
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
kill(pid, SIGTERM);
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0, "SIGTERM from another process: seen, the child quit by itself");
|
||||
}
|
||||
{
|
||||
const int rc = InChild([] {
|
||||
quitsignals::Install();
|
||||
raise(SIGTERM);
|
||||
raise(SIGHUP);
|
||||
raise(SIGINT);
|
||||
return quitsignals::Requested() == SIGTERM ? 0 : 1;
|
||||
});
|
||||
CHECK(rc == 0, "the first request is kept; later ones change nothing (child: %d)", rc);
|
||||
}
|
||||
{
|
||||
const int rc = InChild([] {
|
||||
signal(SIGHUP, SIG_IGN); // nohup
|
||||
quitsignals::Install();
|
||||
raise(SIGHUP);
|
||||
struct sigaction now{};
|
||||
sigaction(SIGHUP, nullptr, &now);
|
||||
return quitsignals::Requested() == 0 && now.sa_handler == SIG_IGN ? 0 : 1;
|
||||
});
|
||||
CHECK(rc == 0, "SIGHUP ignored at launch (nohup) stays ignored (child: %d)", rc);
|
||||
}
|
||||
{
|
||||
// SDL_EventSignal_Init (SDL 3.4.16, events/SDL_quit.c) puts its
|
||||
// handler only where the action is the default.
|
||||
const int rc = InChild([] {
|
||||
quitsignals::Install();
|
||||
for (int sig : quitsignals::kSignals)
|
||||
{
|
||||
struct sigaction action{};
|
||||
sigaction(sig, nullptr, &action);
|
||||
if (action.sa_handler == SIG_DFL && reinterpret_cast<void*>(action.sa_sigaction) == reinterpret_cast<void*>(SIG_DFL))
|
||||
return 1; // SDL would take it
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
CHECK(rc == 0, "SDL leaves the three signals to the game's handler (child: %d)", rc);
|
||||
}
|
||||
{
|
||||
const int rc = InChild([] {
|
||||
quitsignals::Install();
|
||||
raise(SIGKILL);
|
||||
return 0;
|
||||
});
|
||||
CHECK(rc == 128 + SIGKILL, "SIGKILL still kills (child: %d)", rc);
|
||||
}
|
||||
{
|
||||
const int rc = InChild([] {
|
||||
raise(SIGHUP);
|
||||
return 0;
|
||||
});
|
||||
CHECK(rc == 128 + SIGHUP, "control: without the handlers SIGHUP kills, as in v0.1.5 (child: %d)", rc);
|
||||
}
|
||||
}
|
||||
|
||||
bool Exists(const fs::path& p)
|
||||
{
|
||||
std::error_code ec;
|
||||
return fs::exists(p, ec);
|
||||
}
|
||||
|
||||
const report::marker::Leftover* FindSession(const std::vector<report::marker::Leftover>& found, const std::string& session)
|
||||
{
|
||||
for (const auto& l : found)
|
||||
if (l.session == session)
|
||||
return &l;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void Marker(const fs::path& scratch)
|
||||
{
|
||||
printf("the crash marker:\n");
|
||||
namespace marker = report::marker;
|
||||
std::error_code ec;
|
||||
fs::remove_all(scratch, ec);
|
||||
fs::create_directories(scratch, ec);
|
||||
CHECK(!ec, "can't make %s", scratch.c_str());
|
||||
|
||||
const std::string clean = "2026-10-15_11-00-00", killed = "2026-10-15_11-10-00", crashed = "2026-10-15_11-20-00",
|
||||
current = "2026-10-15_11-30-00";
|
||||
auto arm = [&](const std::string& session) {
|
||||
const std::string path = marker::PathFor(scratch, session).string();
|
||||
return std::pair{ marker::Arm(path.c_str()), path };
|
||||
};
|
||||
{
|
||||
auto [fd, path] = arm(clean);
|
||||
CHECK(fd >= 0 && Exists(path) && fs::file_size(path, ec) == 0, "armed: an empty %s", path.c_str());
|
||||
marker::Clear(fd, path.c_str());
|
||||
CHECK(!Exists(path), "a clean end removes it");
|
||||
}
|
||||
{
|
||||
auto [fd, path] = arm(killed);
|
||||
close(fd); // the process gone without a word (SIGKILL)
|
||||
}
|
||||
{
|
||||
auto [fd, path] = arm(crashed);
|
||||
const char report[] = "SpeedBreaker (NFS: Most Wanted, Xbox 360): crash report\n";
|
||||
CHECK(write(fd, report, sizeof(report) - 1) > 0, "write");
|
||||
close(fd); // the handler's rename didn't happen
|
||||
}
|
||||
auto [currentFd, currentPath] = arm(current);
|
||||
const std::vector<marker::Leftover> found = marker::Find(scratch, current);
|
||||
CHECK(found.size() == 2, "the next launch finds 2 earlier markers (%zu)", found.size());
|
||||
const marker::Leftover* k = FindSession(found, killed);
|
||||
const marker::Leftover* c = FindSession(found, crashed);
|
||||
CHECK(k && k->ended == marker::Ended::WithoutQuitting, "SIGKILL's empty marker: ended without quitting");
|
||||
CHECK(c && c->ended == marker::Ended::Crashed, "a written marker: a crash report");
|
||||
CHECK(!FindSession(found, clean), "the clean end left nothing");
|
||||
CHECK(!FindSession(found, current), "the current session's own marker isn't an earlier one's");
|
||||
marker::Clear(currentFd, currentPath.c_str());
|
||||
|
||||
// main()'s order, end to end: arm the marker, install the handlers,
|
||||
// run frames until a quit is requested, then the quit path clears
|
||||
// the marker and exits 0.
|
||||
auto session = [&](const std::string& name) {
|
||||
fflush(stdout);
|
||||
const pid_t pid = fork();
|
||||
if (pid == 0)
|
||||
{
|
||||
const std::string path = marker::PathFor(scratch, name).string();
|
||||
const int fd = marker::Arm(path.c_str());
|
||||
quitsignals::Install();
|
||||
std::ofstream(scratch / (name + ".ready")) << "ready\n";
|
||||
while (!quitsignals::Requested()) // RunFrame
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
marker::Clear(fd, path.c_str()); // report::ClearCrashMarker
|
||||
_exit(0);
|
||||
}
|
||||
CHECK(WaitFor([&] { return Exists(scratch / (name + ".ready")); }, 5000), "%s: the child started", name.c_str());
|
||||
return pid;
|
||||
};
|
||||
for (int sig : quitsignals::kSignals)
|
||||
{
|
||||
const std::string name = std::string("signal-") + quitsignals::Name(sig);
|
||||
const pid_t pid = session(name);
|
||||
kill(pid, sig);
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0, "%s: the session quit by itself, exit 0", quitsignals::Name(sig));
|
||||
CHECK(!Exists(marker::PathFor(scratch, name)), "%s: no marker left", quitsignals::Name(sig));
|
||||
}
|
||||
{
|
||||
const pid_t pid = session("signal-SIGKILL");
|
||||
kill(pid, SIGKILL);
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
CHECK(WIFSIGNALED(status) && WTERMSIG(status) == SIGKILL, "SIGKILL: killed");
|
||||
const std::vector<marker::Leftover> after = marker::Find(scratch, current);
|
||||
const marker::Leftover* l = FindSession(after, "signal-SIGKILL");
|
||||
CHECK(l && l->ended == marker::Ended::WithoutQuitting, "SIGKILL: its marker stays, and the next launch finds it ended without quitting");
|
||||
}
|
||||
fs::remove_all(scratch, ec);
|
||||
}
|
||||
|
||||
std::string Read(const fs::path& path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
struct Sources
|
||||
{
|
||||
std::string main, presenter, crash;
|
||||
};
|
||||
|
||||
// A function's body: from `head` to the brace at its own indent.
|
||||
std::string Body(const std::string& src, const std::string& head)
|
||||
{
|
||||
const size_t at = src.find(head);
|
||||
if (at == std::string::npos)
|
||||
return {};
|
||||
const size_t line = src.rfind('\n', at) + 1;
|
||||
const std::string end = "\n" + src.substr(line, at - line) + "}";
|
||||
const size_t stop = src.find(end, at);
|
||||
return src.substr(at, stop == std::string::npos ? std::string::npos : stop - at);
|
||||
}
|
||||
|
||||
// `a` before `b` in `text`, both there.
|
||||
bool Before(const std::string& text, const std::string& a, const std::string& b, size_t from = 0)
|
||||
{
|
||||
const size_t x = text.find(a, from), y = text.find(b, from);
|
||||
return x != std::string::npos && y != std::string::npos && x < y;
|
||||
}
|
||||
|
||||
std::vector<std::string> SourceProblems(const Sources& s)
|
||||
{
|
||||
std::vector<std::string> problems;
|
||||
// main.cpp: the handlers before SDL (the windowed start), and after
|
||||
// the crash handlers' marker exists.
|
||||
if (!Before(s.main, "report::PrepareCrashReports();", "quitsignals::Install();") ||
|
||||
!Before(s.main, "quitsignals::Install();", "bool windowed = video::Initialize();"))
|
||||
problems.push_back("main.cpp: quitsignals::Install() isn't between PrepareCrashReports and the window's video::Initialize");
|
||||
// Any return from main() after the marker exists clears it.
|
||||
const size_t prepared = s.main.find("report::PrepareCrashReports();");
|
||||
const size_t guard = s.main.find("~ClearMarkerOnReturn() { report::ClearCrashMarker(); }");
|
||||
const size_t firstReturnAfter = prepared == std::string::npos ? std::string::npos : s.main.find(" return ", prepared);
|
||||
if (guard == std::string::npos || prepared == std::string::npos || guard < prepared || guard > firstReturnAfter)
|
||||
problems.push_back("main.cpp: no marker-clearing guard between PrepareCrashReports and the first return after it");
|
||||
// The window's quit path: the marker cleared after the shutdown, just
|
||||
// before the log flush and the exit.
|
||||
const size_t loop = s.main.find("while (video::RunFrame())");
|
||||
if (loop == std::string::npos)
|
||||
problems.push_back("main.cpp: no game loop");
|
||||
else
|
||||
{
|
||||
const std::string tail = s.main.substr(loop, s.main.find("\n}", loop) - loop);
|
||||
if (!Before(tail, "video::Shutdown();", "report::ClearCrashMarker();") ||
|
||||
!Before(tail, "report::ClearCrashMarker();", "report::FlushLog();") ||
|
||||
!Before(tail, "report::ClearCrashMarker();", "std::_Exit(0);") || tail.find("settings::Save()") == std::string::npos)
|
||||
problems.push_back("main.cpp: the quit path doesn't save settings and clear the marker after the shutdown, before the exit");
|
||||
}
|
||||
// The launcher lifeline's exit and the headless one clear it too.
|
||||
for (const char* head : { "launcher (pid %d) is gone: exiting", "quitting\\n\", quitsignals::Name(sig));" })
|
||||
{
|
||||
const size_t at = s.main.find(head);
|
||||
if (at == std::string::npos || !Before(s.main, "report::ClearCrashMarker();", "std::_Exit(0);", at))
|
||||
problems.push_back(std::string("main.cpp: the exit after \"") + head + "\" doesn't clear the marker first");
|
||||
}
|
||||
if (s.main.find("while (!s_guestReturned.load() && !quitsignals::Requested())") == std::string::npos)
|
||||
problems.push_back("main.cpp: without a window, a quit request isn't waited for");
|
||||
// presenter.cpp: RunFrame leaves on a request, before taking events.
|
||||
const std::string run = Body(s.presenter, "bool RunFrame()");
|
||||
const size_t request = run.find("if (quitsignals::Requested())");
|
||||
if (request == std::string::npos || run.find("return false;", request) > run.find("while (SDL_PollEvent(&e))") ||
|
||||
run.find("while (SDL_PollEvent(&e))") == std::string::npos)
|
||||
problems.push_back("presenter.cpp: RunFrame doesn't leave on a quit request before its event loop");
|
||||
// crash.cpp: a crash being reported keeps its report.
|
||||
const std::string clear = Body(s.crash, "void ClearCrashMarker()");
|
||||
if (!Before(clear, "if (s_crashing.load())", "marker::Clear("))
|
||||
problems.push_back("crash.cpp: ClearCrashMarker doesn't leave a crash being reported alone");
|
||||
const std::string notice = Body(s.crash, "void NoticePreviousCrash(bool lastClearsMarker)");
|
||||
if (notice.find("marker::Ended::Crashed") == std::string::npos || notice.find("ended without quitting") == std::string::npos)
|
||||
problems.push_back("crash.cpp: the next launch doesn't rename crash reports and log sessions that ended without quitting");
|
||||
return problems;
|
||||
}
|
||||
|
||||
std::string Replaced(std::string s, const std::string& from, const std::string& to)
|
||||
{
|
||||
const size_t at = s.find(from);
|
||||
if (at == std::string::npos)
|
||||
return {};
|
||||
return s.replace(at, from.size(), to);
|
||||
}
|
||||
|
||||
void Runtime(const fs::path& runtime)
|
||||
{
|
||||
printf("main.cpp, presenter.cpp, crash.cpp:\n");
|
||||
const Sources src{ Read(runtime / "main.cpp"), Read(runtime / "video/presenter.cpp"), Read(runtime / "report/crash.cpp") };
|
||||
CHECK(!src.main.empty() && !src.presenter.empty() && !src.crash.empty(), "can't read the sources in %s", runtime.c_str());
|
||||
if (src.main.empty() || src.presenter.empty() || src.crash.empty())
|
||||
return;
|
||||
for (const std::string& p : SourceProblems(src))
|
||||
CHECK(false, "%s", p.c_str());
|
||||
struct Plant
|
||||
{
|
||||
const char* name;
|
||||
std::string Sources::*file;
|
||||
std::vector<std::pair<std::string, std::string>> edits; // each: the first `from` becomes `to`
|
||||
};
|
||||
const Plant plants[] = {
|
||||
{ "v0.1.5's quit path (no marker cleared)", &Sources::main,
|
||||
{ { " // A clean end: no crash marker left (a crash in the shutdown above still\n // reported one).\n"
|
||||
" report::ClearCrashMarker();\n", "" } } },
|
||||
{ "the marker cleared before the shutdown", &Sources::main,
|
||||
{ { " gpu::renderer::Shutdown();\n video::Shutdown();\n",
|
||||
" report::ClearCrashMarker();\n gpu::renderer::Shutdown();\n video::Shutdown();\n" } } },
|
||||
{ "no handlers (SDL's, and SIGHUP's default)", &Sources::main, { { " quitsignals::Install();\n", "" } } },
|
||||
{ "the handlers after SDL starts", &Sources::main,
|
||||
{ { " quitsignals::Install();\n", "" },
|
||||
{ " bool windowed = video::Initialize();\n", " bool windowed = video::Initialize();\n quitsignals::Install();\n" } } },
|
||||
{ "no guard for returns", &Sources::main, { { "~ClearMarkerOnReturn() { report::ClearCrashMarker(); }", "~ClearMarkerOnReturn() {}" } } },
|
||||
{ "the lifeline's exit keeps the marker", &Sources::main,
|
||||
{ { " gpu::renderer::Shutdown();\n report::ClearCrashMarker();\n",
|
||||
" gpu::renderer::Shutdown();\n" } } },
|
||||
{ "headless: no wait for a request", &Sources::main,
|
||||
{ { "while (!s_guestReturned.load() && !quitsignals::Requested())", "while (!s_guestReturned.load())" } } },
|
||||
{ "RunFrame ignores the request", &Sources::presenter, { { " if (quitsignals::Requested())\n return false;\n", "" } } },
|
||||
{ "a crash being reported cleared", &Sources::crash, { { " if (s_crashing.load())\n return;\n", "" } } },
|
||||
};
|
||||
for (const Plant& p : plants)
|
||||
{
|
||||
Sources mutated = src;
|
||||
bool applied = true;
|
||||
for (const auto& [from, to] : p.edits)
|
||||
{
|
||||
std::string next = Replaced(mutated.*p.file, from, to);
|
||||
applied = applied && !next.empty();
|
||||
if (!next.empty())
|
||||
mutated.*p.file = next;
|
||||
}
|
||||
CHECK(applied, "plant \"%s\": its text isn't in the source", p.name);
|
||||
if (!applied)
|
||||
continue;
|
||||
const bool caught = !SourceProblems(mutated).empty();
|
||||
CHECK(caught, "plant \"%s\" not caught", p.name);
|
||||
printf(" plant: %s: %s\n", p.name, caught ? "caught" : "NOT caught");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
Signals();
|
||||
fs::path scratch = argc > 1 ? fs::path(argv[1]) : fs::temp_directory_path() / ("clean_exit_test." + std::to_string(getpid()));
|
||||
Marker(scratch);
|
||||
if (argc > 2)
|
||||
Runtime(argv[2]);
|
||||
else
|
||||
printf("(the runtime folder not given: its checks skipped)\n");
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Tests for ui::CursorIdle (runtime/ui/cursor_idle.h): when the mouse cursor
|
||||
// shows over the game. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/cursor_idle_test.cpp -o build/cursor_idle_test
|
||||
// build/cursor_idle_test runtime/ui/ui.cpp
|
||||
// With ui.cpp's path it also checks how ui.cpp drives it: ImGui's backend
|
||||
// kept off the cursor outside the menus (it shows it every frame it lays
|
||||
// out: the performance overlay, a toast), SDL_HideCursor only from the
|
||||
// policy, and no relative mode.
|
||||
#include <ui/cursor_idle.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uint64_t kS = 1'000'000'000; // a second in ns
|
||||
constexpr uint64_t kStart = 5 * kS; // SDL's clock at the first frame
|
||||
|
||||
void Policy()
|
||||
{
|
||||
using ui::CursorIdle;
|
||||
CursorIdle c;
|
||||
// Launch: shown wherever the pointer is, for 2 s.
|
||||
CHECK(c.Shows(kStart, false), "the first frame: shown");
|
||||
CHECK(c.Shows(kStart + kS, false), "1 s in: shown");
|
||||
CHECK(!c.Shows(kStart + 2 * kS, false), "2 s without the mouse moving: hidden");
|
||||
CHECK(!c.Shows(kStart + 60 * kS, false), "a minute on: hidden");
|
||||
// The mouse moves: shown at once, for 2 s from its last movement.
|
||||
c.Moved(kStart + 60 * kS);
|
||||
CHECK(c.Shows(kStart + 60 * kS, false), "moved: shown");
|
||||
c.Moved(kStart + 61 * kS);
|
||||
CHECK(c.Shows(kStart + 62 * kS + kS / 2, false), "moving on: 2 s from the last movement");
|
||||
CHECK(!c.Shows(kStart + 63 * kS, false), "then hidden");
|
||||
// A menu or the installer has the mouse: shown, however still.
|
||||
CHECK(c.Shows(kStart + 70 * kS, true), "a menu open: shown");
|
||||
CHECK(c.Shows(kStart + 300 * kS, true), "a menu open for minutes: shown");
|
||||
// Closed by the controller (no mouse since): hidden at once.
|
||||
CHECK(!c.Shows(kStart + 301 * kS, false), "closed without the mouse: hidden");
|
||||
// Closed by a click: shown for 2 s from the click.
|
||||
c.Moved(kStart + 400 * kS);
|
||||
CHECK(c.Shows(kStart + 401 * kS, false) && !c.Shows(kStart + 402 * kS, false), "closed with a click: 2 s, then hidden");
|
||||
// An event stamped before a later one doesn't take the time back.
|
||||
c.Moved(kStart + 399 * kS);
|
||||
CHECK(c.Shows(kStart + 401 * kS + kS / 2, false), "an older timestamp doesn't move it back");
|
||||
// A movement before the first frame (SDL's clock from SDL_Init): counts.
|
||||
CursorIdle early;
|
||||
early.Moved(kStart - kS);
|
||||
CHECK(early.Shows(kStart, false) && !early.Shows(kStart + kS, false), "moved before the first frame: 2 s from then");
|
||||
|
||||
// What counts as the mouse.
|
||||
CHECK(CursorIdle::Activity(true, false, 3.0f, 0.0f), "a real mouse's motion");
|
||||
CHECK(CursorIdle::Activity(true, false, 0.0f, -0.5f), "a sub-point motion");
|
||||
CHECK(!CursorIdle::Activity(true, false, 0.0f, 0.0f), "a motion that moved nothing (a window entered)");
|
||||
CHECK(CursorIdle::Activity(false, false, 0.0f, 0.0f), "a click or the wheel");
|
||||
CHECK(!CursorIdle::Activity(true, true, 25.0f, 4.0f), "a finger's (or a pen's) motion: not the mouse (the Deck's screen)");
|
||||
CHECK(!CursorIdle::Activity(false, true, 0.0f, 0.0f), "a tap: not the mouse");
|
||||
}
|
||||
|
||||
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 + what.size()))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
void Source(const char* path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
if (!in)
|
||||
{
|
||||
printf(" %s: can't read\n", path);
|
||||
g_failures++;
|
||||
return;
|
||||
}
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
const std::string ui = ss.str();
|
||||
CHECK(Count(ui, "io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange") == 1 &&
|
||||
Count(ui, "io.ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange") == 1,
|
||||
"ui.cpp keeps ImGui's backend off the cursor outside the menus, and gives it back in them");
|
||||
CHECK(Count(ui, "SDL_HideCursor()") == 1 && Count(ui, "SDL_ShowCursor()") == 1, "ui.cpp shows and hides the cursor in one place");
|
||||
CHECK(Count(ui, "RelativeMouseMode") == 0, "no relative mode (it would capture the mouse)");
|
||||
CHECK(Count(ui, "s_cursorIdle.Moved(") == 1 && Count(ui, " if (MouseActivity(e))\n") == 1, "every SDL event is looked at for the mouse");
|
||||
printf(" %s: checked\n", path);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
printf("when the cursor shows:\n");
|
||||
Policy();
|
||||
if (argc > 1)
|
||||
{
|
||||
printf("ui.cpp:\n");
|
||||
Source(argv[1]);
|
||||
}
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The picture's tone curve (video/display_tone.h: Settings > Display >
|
||||
// Brightness) and the 360's display gamma ramp as the game programs it
|
||||
// (video/display_gamma.h: the DC_LUT_* register protocol), header only. The
|
||||
// optional arguments are runtime/video/presenter.cpp (its tone shader wraps
|
||||
// the scale shaders it uses, which must each have exactly one main) and
|
||||
// runtime/user/settings.cpp (the two options' defaults: Brightness 0, Xbox 360
|
||||
// Display Gamma on since v0.1.4).
|
||||
//
|
||||
// Build by hand: c++ -std=c++20 -O2 -UNDEBUG -I runtime tests/display_tone_test.cpp
|
||||
#include <video/display_tone.h>
|
||||
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace video;
|
||||
namespace dg = video::display_gamma;
|
||||
|
||||
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)
|
||||
|
||||
double Levels(double v) { return v * 255.0; }
|
||||
|
||||
// ---- A model of Most Wanted's (and Carbon's) Direct3D ramp, from the
|
||||
// recompiled code (the constants are the XEX's):
|
||||
// the game (sub_82458C20): ramp16[k] = int(pow(k/255, g) * 65535 + 0.5),
|
||||
// g = 1.06 at its Brightness 0 .. 0.57 at 1 (0.815 at its default 0.5)
|
||||
// Direct3D (sub_825A1DA8): table[i] = int(f(i/1023) * 1023 + 0.5) for
|
||||
// the type VdGetCurrentDisplayGamma reports: 1 sRGB encode, 2 (and
|
||||
// others) the BT.709 curve, 3 a power, each of the sRGB decode;
|
||||
// ramp16' = table[ramp16 >> 6] << 6
|
||||
// the DC LUT (sub_825A20A8): DC_LUT_30_COLOR = ramp16' >> 6 per channel.
|
||||
double SrgbDecode(double x) { return x <= 0.04045 ? x * 0.07739938050508499 : std::pow((x + 0.054999999701976776) * 0.9478673338890076, 2.4000000953674316); }
|
||||
double SrgbEncode(double x) { return x <= 0.0031308 ? x * 12.92 : std::pow(x, 0.4166666567325592) * 1.0549999475479126 - 0.054999999701976776; }
|
||||
double Bt709(double x) { return x < 0.018 ? x * 4.5 : std::pow(x, 0.44999998807907104) * 1.0989999771118164 - 0.0989999994635582; }
|
||||
|
||||
std::vector<uint32_t> D3dRamp10(double g, int type)
|
||||
{
|
||||
std::vector<uint32_t> table(1024), out(256);
|
||||
for (int i = 0; i < 1024; i++)
|
||||
{
|
||||
double x = SrgbDecode(i * 0.0009775171056389809);
|
||||
double y = type == 1 ? SrgbEncode(x) : type == 3 ? std::pow(x, 1.0 / 2.22222233) : Bt709(x);
|
||||
table[i] = uint32_t(std::clamp(int(y * 1023.0 + 0.5), 0, 1023));
|
||||
}
|
||||
for (int k = 0; k < 256; k++)
|
||||
{
|
||||
uint32_t ramp16 = uint32_t(std::pow(k / 255.0, g) * 65535.0 + 0.5) & 0xFFFF;
|
||||
out[k] = ((table[ramp16 >> 6] << 6) & 0xFFFF) >> 6;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The register writes sub_825A20A8 makes for a ramp (r = g = b), as the
|
||||
// command processor hands them on.
|
||||
std::vector<std::pair<uint32_t, uint32_t>> D3dWrites(const std::vector<uint32_t>& r10, const std::vector<uint32_t>* g10 = nullptr,
|
||||
const std::vector<uint32_t>* b10 = nullptr)
|
||||
{
|
||||
std::vector<std::pair<uint32_t, uint32_t>> w = { { dg::kRegRwMode, 0 }, { dg::kRegRwIndex, 0 }, { dg::kRegWriteEnMask, 7 } };
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
{
|
||||
uint32_t r = r10[k], g = g10 ? (*g10)[k] : r10[k], b = b10 ? (*b10)[k] : r10[k];
|
||||
w.push_back({ dg::kRegColor30, (r << 20) | (g << 10) | b });
|
||||
w.push_back({ dg::kRegRwIndex, k + 1 }); // the COND_WRITE after each entry; 0x100 at the end
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
// Feeds writes; returns how many completed a table.
|
||||
int Feed(dg::Decoder& d, const std::vector<std::pair<uint32_t, uint32_t>>& writes)
|
||||
{
|
||||
int completed = 0;
|
||||
for (auto [reg, value] : writes)
|
||||
completed += d.Write(reg, value) ? 1 : 0;
|
||||
return completed;
|
||||
}
|
||||
|
||||
void Neutral()
|
||||
{
|
||||
CHECK(tone::Neutral(0, false), "Brightness 0 with the ramp off must be neutral");
|
||||
CHECK(!tone::Neutral(1, false) && !tone::Neutral(-1, false), "any other Brightness is not");
|
||||
CHECK(!tone::Neutral(0, true), "the 360's ramp on is not neutral");
|
||||
CHECK(tone::BrightnessExponent(0) == 1.0, "exponent at 0: %g", tone::BrightnessExponent(0));
|
||||
tone::Lut lut = tone::BuildLut(0, nullptr);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
CHECK(lut.entry[k][c] == float(k / 255.0), "neutral table [%u][%u] = %.9f", k, c, lut.entry[k][c]);
|
||||
// ... and what the shader reads from it at a value and between two.
|
||||
for (uint32_t k = 0; k <= 255; k++)
|
||||
CHECK(std::abs(tone::Sample(lut, 0, k / 255.0) - k / 255.0) < 1e-7, "neutral sample at %u", k);
|
||||
CHECK(std::abs(tone::Sample(lut, 1, 0.5) - 0.5) < 1e-7, "neutral sample between entries");
|
||||
}
|
||||
|
||||
void BrightnessSteps()
|
||||
{
|
||||
double prevMid = -1;
|
||||
for (int32_t s = tone::kBrightnessMin; s <= tone::kBrightnessMax; s++)
|
||||
{
|
||||
tone::Lut lut = tone::BuildLut(s, nullptr);
|
||||
CHECK(lut.entry[0][0] == 0.0f && lut.entry[255][0] == 1.0f, "step %d: black and white stay put (%g, %g)", s,
|
||||
lut.entry[0][0], lut.entry[255][0]);
|
||||
for (uint32_t k = 1; k < 256; k++)
|
||||
CHECK(lut.entry[k][0] > lut.entry[k - 1][0], "step %d: not increasing at %u", s, k);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(lut.entry[k][0] == lut.entry[k][1] && lut.entry[k][1] == lut.entry[k][2], "step %d: channels differ at %u", s, k);
|
||||
double mid = Levels(lut.entry[128][0]);
|
||||
if (s > 0)
|
||||
for (uint32_t k = 1; k < 255; k++)
|
||||
CHECK(lut.entry[k][0] > k / 255.0f, "step %d: %u not brighter", s, k);
|
||||
if (s < 0)
|
||||
for (uint32_t k = 1; k < 255; k++)
|
||||
CHECK(lut.entry[k][0] < k / 255.0f, "step %d: %u not darker", s, k);
|
||||
if (prevMid >= 0)
|
||||
{
|
||||
// Gentle: mid-grey moves 4..7 levels of 255 a step; no value
|
||||
// moves more than 9 in one step.
|
||||
CHECK(mid - prevMid >= 4.0 && mid - prevMid <= 7.0, "step %d: mid-grey moved %.2f levels", s, mid - prevMid);
|
||||
tone::Lut before = tone::BuildLut(s - 1, nullptr);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(Levels(lut.entry[k][0] - before.entry[k][0]) <= 9.0, "step %d: value %u moved %.2f levels", s, k,
|
||||
Levels(lut.entry[k][0] - before.entry[k][0]));
|
||||
}
|
||||
prevMid = mid;
|
||||
}
|
||||
// The ends reach about as far as the games' own brightness range
|
||||
// does from its default (their exponent 0.57 .. 1.06 around 0.815):
|
||||
// mid-grey 157.5 .. 104.0 levels.
|
||||
double brightest = Levels(tone::ApplyBrightness(128 / 255.0, tone::kBrightnessMax));
|
||||
double darkest = Levels(tone::ApplyBrightness(128 / 255.0, tone::kBrightnessMin));
|
||||
double gameBright = Levels(std::pow(128 / 255.0, 0.57 / 0.815)), gameDark = Levels(std::pow(128 / 255.0, 1.06 / 0.815));
|
||||
CHECK(std::abs(brightest - gameBright) <= 4.0, "+5 mid-grey %.1f, the game's brightest %.1f", brightest, gameBright);
|
||||
CHECK(std::abs(darkest - gameDark) <= 8.0, "-5 mid-grey %.1f, the game's darkest %.1f", darkest, gameDark);
|
||||
// Out of range is clamped (a hand-edited settings.toml).
|
||||
CHECK(tone::BrightnessExponent(9) == tone::BrightnessExponent(5) && tone::BrightnessExponent(-9) == tone::BrightnessExponent(-5),
|
||||
"steps clamp to -5..+5");
|
||||
printf("brightness: mid-grey (128) by step:");
|
||||
for (int32_t s = tone::kBrightnessMin; s <= tone::kBrightnessMax; s++)
|
||||
printf(" %+d:%.1f", s, Levels(tone::ApplyBrightness(128 / 255.0, s)));
|
||||
printf("; 16 -> %.1f at +5, %.1f at -5\n", Levels(tone::ApplyBrightness(16 / 255.0, 5)), Levels(tone::ApplyBrightness(16 / 255.0, -5)));
|
||||
}
|
||||
|
||||
void HalfTexels()
|
||||
{
|
||||
CHECK(tone::ToHalf(0.0f) == 0 && tone::ToHalf(1.0f) == 0x3C00 && tone::ToHalf(0.5f) == 0x3800, "exact halves");
|
||||
CHECK(tone::FromHalf(0x3C00) == 1.0f && tone::FromHalf(0x3555) > 0.333f && tone::FromHalf(0x3555) < 0.3334f, "decode");
|
||||
uint16_t prev = 0;
|
||||
for (uint32_t i = 1; i <= 100000; i++)
|
||||
{
|
||||
float x = i / 100000.0f;
|
||||
uint16_t h = tone::ToHalf(x);
|
||||
CHECK(h >= prev, "not monotonic at %g", x);
|
||||
prev = h;
|
||||
CHECK(std::abs(tone::FromHalf(h) - x) <= x * (1.0f / 2048.0f) + 1e-7f, "half of %g is %g", x, tone::FromHalf(h));
|
||||
}
|
||||
// Subnormals (shadows under 2^-14) still round to nearest.
|
||||
CHECK(std::abs(tone::FromHalf(tone::ToHalf(3e-5f)) - 3e-5f) <= std::ldexp(1.0f, -25), "subnormal");
|
||||
// Every neutral entry within an eighth of a level.
|
||||
auto texels = tone::ToHalfTexels(tone::BuildLut(0, nullptr));
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(std::abs(tone::FromHalf(texels[k * 4]) - k / 255.0f) * 255.0f < 0.125f && texels[k * 4 + 3] == 0x3C00, "texel %u", k);
|
||||
}
|
||||
|
||||
void D3dModelMatchesTheDump()
|
||||
{
|
||||
// Carbon's ramp as logged from the command processor at the game's
|
||||
// default brightness (fidelity study, 2026-10-12): 8 -> 5.484,
|
||||
// 16 -> 12.214, 128 -> 133.9, 192 -> 196.4 (8-bit levels of 10-bit
|
||||
// entries). The model gives the same entries at g = 0.815, type 2.
|
||||
auto ramp = D3dRamp10(0.815, 2);
|
||||
CHECK(std::abs(ramp[8] * 255.0 / 1023.0 - 5.484) < 0.01, "8 -> %.3f", ramp[8] * 255.0 / 1023.0);
|
||||
CHECK(std::abs(ramp[16] * 255.0 / 1023.0 - 12.214) < 0.01, "16 -> %.3f", ramp[16] * 255.0 / 1023.0);
|
||||
CHECK(std::abs(ramp[128] * 255.0 / 1023.0 - 133.9) < 0.05, "128 -> %.3f", ramp[128] * 255.0 / 1023.0);
|
||||
CHECK(std::abs(ramp[192] * 255.0 / 1023.0 - 196.4) < 0.05, "192 -> %.3f", ramp[192] * 255.0 / 1023.0);
|
||||
// With an sRGB display (type 1) Direct3D passes the game's own ramp
|
||||
// through: with the game's ramp at 1.0 that is the identity.
|
||||
auto srgb = D3dRamp10(1.0, 1);
|
||||
dg::Table identity = dg::Table::Identity();
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(std::abs(int(srgb[k]) - int(identity.entry[k][0])) <= 1, "type 1, identity ramp: %u -> %u", k, srgb[k]);
|
||||
}
|
||||
|
||||
void DecoderFollowsDirect3d()
|
||||
{
|
||||
CHECK(!dg::IsLutRegister(0x1920) && dg::IsLutRegister(0x1921) && dg::IsLutRegister(0x1925) && dg::IsLutRegister(0x1930) &&
|
||||
!dg::IsLutRegister(0x1931) && !dg::IsLutRegister(0),
|
||||
"the LUT register range");
|
||||
auto ramp = D3dRamp10(0.815, 2);
|
||||
dg::Decoder d;
|
||||
CHECK(d.table() == dg::Table::Identity(), "a new decoder holds the identity");
|
||||
// Completed by the last entry's write, once.
|
||||
auto writes = D3dWrites(ramp);
|
||||
int completed = 0, at = -1;
|
||||
for (size_t i = 0; i < writes.size(); i++)
|
||||
if (d.Write(writes[i].first, writes[i].second))
|
||||
{
|
||||
completed++;
|
||||
at = int(i);
|
||||
}
|
||||
CHECK(completed == 1 && at == int(writes.size()) - 2, "completed %d times, at write %d of %zu", completed, at, writes.size());
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(d.table().entry[k][0] == ramp[k] && d.table().entry[k][1] == ramp[k] && d.table().entry[k][2] == ramp[k], "entry %u", k);
|
||||
CHECK(d.TableMode() && d.PwlWrites() == 0 && d.Pending() == 0, "table mode, nothing pending");
|
||||
// The same ramp again completes again (the runtime drops repeats).
|
||||
CHECK(Feed(d, writes) == 1, "a second sweep completes");
|
||||
|
||||
// Channels land where DC_LUT_30_COLOR puts them (red 20:29, blue 0:9).
|
||||
std::vector<uint32_t> r(256), g(256), b(256);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
{
|
||||
r[k] = k * 4;
|
||||
g[k] = 1023 - k * 4;
|
||||
b[k] = k;
|
||||
}
|
||||
CHECK(Feed(d, D3dWrites(r, &g, &b)) == 1, "per-channel sweep");
|
||||
CHECK(d.table().entry[10][0] == 40 && d.table().entry[10][1] == 983 && d.table().entry[10][2] == 10, "channel order: %u %u %u",
|
||||
d.table().entry[10][0], d.table().entry[10][1], d.table().entry[10][2]);
|
||||
|
||||
// DC_LUT_30_COLOR moves the index on by itself (a writer that sets
|
||||
// RW_INDEX once, then 256 colors).
|
||||
dg::Decoder autoIndex;
|
||||
std::vector<std::pair<uint32_t, uint32_t>> colorsOnly = { { dg::kRegRwIndex, 0 } };
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
colorsOnly.push_back({ dg::kRegColor30, (r[k] << 20) | (g[k] << 10) | b[k] });
|
||||
CHECK(Feed(autoIndex, colorsOnly) == 1 && autoIndex.table().entry[255][0] == r[255] && autoIndex.table().entry[1][2] == b[1],
|
||||
"auto-increment");
|
||||
|
||||
// Half a sweep is not a table.
|
||||
dg::Decoder half;
|
||||
auto partial = D3dWrites(ramp);
|
||||
partial.resize(3 + 2 * 128);
|
||||
CHECK(Feed(half, partial) == 0 && half.Pending() == 128, "half a sweep: pending %zu", half.Pending());
|
||||
|
||||
// The write-enable mask: red only.
|
||||
dg::Decoder masked;
|
||||
auto redOnly = D3dWrites(ramp);
|
||||
redOnly[2].second = 4;
|
||||
CHECK(Feed(masked, redOnly) == 1, "red-only sweep completes");
|
||||
CHECK(masked.table().entry[128][0] == ramp[128] && masked.table().entry[128][1] == dg::Table::Identity().entry[128][1],
|
||||
"red only: green kept");
|
||||
// A mask of 0 writes nothing (and completes nothing).
|
||||
dg::Decoder none;
|
||||
auto noMask = D3dWrites(ramp);
|
||||
noMask[2].second = 0;
|
||||
CHECK(Feed(none, noMask) == 0 && none.table() == dg::Table::Identity(), "mask 0");
|
||||
|
||||
// DC_LUT_SEQ_COLOR: red, green, blue a write, bits 6:15.
|
||||
dg::Decoder seq;
|
||||
int seqCompleted = seq.Write(dg::kRegRwIndex, 0) ? 1 : 0;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
seqCompleted += seq.Write(dg::kRegSeqColor, (c == 0 ? r[k] : c == 1 ? g[k] : b[k]) << 6) ? 1 : 0;
|
||||
CHECK(seqCompleted == 1 && seq.table().entry[200][0] == r[200] && seq.table().entry[200][1] == g[200] &&
|
||||
seq.table().entry[200][2] == b[200],
|
||||
"sequential writes: %d completions", seqCompleted);
|
||||
// An index write restarts at red.
|
||||
seq.Write(dg::kRegRwIndex, 5);
|
||||
seq.Write(dg::kRegSeqColor, 100 << 6); // red of 5
|
||||
seq.Write(dg::kRegRwIndex, 7);
|
||||
seq.Write(dg::kRegSeqColor, 300 << 6); // red of 7, not green
|
||||
CHECK(seq.table().entry[5][0] == 100 && seq.table().entry[7][0] == 300 && seq.table().entry[7][1] == g[7], "index restarts at red");
|
||||
|
||||
// The PWL ramp: counted, not applied; in PWL mode DC_LUT_30_COLOR
|
||||
// writes don't touch the table.
|
||||
dg::Decoder pwl;
|
||||
pwl.Write(dg::kRegLutaControl, 3);
|
||||
pwl.Write(dg::kRegRwMode, 1);
|
||||
pwl.Write(dg::kRegPwlData, 0x02000000);
|
||||
pwl.Write(dg::kRegColor30, 0x3FFFFFFF);
|
||||
CHECK(!pwl.TableMode() && pwl.PwlWrites() == 1 && pwl.table() == dg::Table::Identity(), "PWL");
|
||||
}
|
||||
|
||||
void RampThroughTheTable()
|
||||
{
|
||||
auto ramp10 = D3dRamp10(0.815, 2);
|
||||
dg::Table ramp;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
ramp.entry[k] = { uint16_t(ramp10[k]), uint16_t(ramp10[k]), uint16_t(ramp10[k]) };
|
||||
tone::Lut lut = tone::BuildLut(0, &ramp);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(lut.entry[k][0] == float(ramp10[k] / 1023.0), "the ramp alone, entry %u", k);
|
||||
// The 360 ramp darkens the shadows and brightens the mid-tones.
|
||||
CHECK(Levels(lut.entry[8][0]) < 7.0 && Levels(lut.entry[128][0]) > 132.0, "8 -> %.1f, 128 -> %.1f", Levels(lut.entry[8][0]),
|
||||
Levels(lut.entry[128][0]));
|
||||
// Brightness after it.
|
||||
tone::Lut both = tone::BuildLut(2, &ramp);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(std::abs(both.entry[k][0] - std::pow(ramp10[k] / 1023.0, tone::BrightnessExponent(2))) < 1e-6, "ramp then +2, %u", k);
|
||||
}
|
||||
|
||||
// presenter.cpp's scale shaders: the tone variant renames their one main.
|
||||
void PresenterShaders(const char* path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
std::string src = ss.str();
|
||||
CHECK(!src.empty(), "can't read %s", path);
|
||||
for (const char* name : { "kScaleFs", "kScaleFastFs" })
|
||||
{
|
||||
std::string open = std::string("const char* ") + name + " = R\"(";
|
||||
size_t a = src.find(open);
|
||||
CHECK(a != std::string::npos, "%s not found", name);
|
||||
if (a == std::string::npos)
|
||||
continue;
|
||||
size_t b = src.find(")\";", a);
|
||||
std::string glsl = src.substr(a, b - a);
|
||||
size_t first = glsl.find("void main()");
|
||||
CHECK(first != std::string::npos && glsl.find("void main()", first + 1) == std::string::npos, "%s: one main", name);
|
||||
CHECK(glsl.find("scaleMain") == std::string::npos && glsl.find("toneTable") == std::string::npos,
|
||||
"%s: the plain shader stays as it was", name);
|
||||
}
|
||||
// The plain scale pipeline is still made from those shaders, and the
|
||||
// tone pipeline only from ToneVariant.
|
||||
CHECK(src.find("s_post.fs = PostModule(s_fastScale ? kScaleFastFs : kScaleFs, 1);") != std::string::npos, "the plain scale shader");
|
||||
CHECK(src.find("ToneVariant(s_fastScale ? kScaleFastFs : kScaleFs)") != std::string::npos, "the tone variant's source");
|
||||
CHECK(src.find("tone ? s_tone.pipeline : s_post.scale") != std::string::npos, "the scale pass picks the plain pipeline when neutral");
|
||||
}
|
||||
|
||||
// settings.cpp's option table: Xbox 360 Display Gamma is on by default
|
||||
// (v0.1.4: the picture a real 360 sends to a TV; a settings.toml without
|
||||
// the key, as every earlier version wrote it, gets it on), Brightness 0.
|
||||
void SettingsDefaults(const char* path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
std::string src = ss.str();
|
||||
CHECK(!src.empty(), "can't read %s", path);
|
||||
auto entry = [&](const char* key) {
|
||||
// From the key to the end of its option ("},").
|
||||
size_t a = src.find(std::string(".key = \"") + key + "\"");
|
||||
if (a == std::string::npos)
|
||||
return std::string();
|
||||
size_t b = src.find("},", a);
|
||||
return src.substr(a, b == std::string::npos ? std::string::npos : b - a);
|
||||
};
|
||||
std::string gamma = entry("xbox_display_gamma"), brightness = entry("brightness");
|
||||
CHECK(gamma.find(".defaultValue = 1 ") != std::string::npos, "Xbox 360 Display Gamma defaults to on: %s", gamma.c_str());
|
||||
CHECK(brightness.find(".defaultValue = 0,") != std::string::npos, "Brightness defaults to 0: %s", brightness.c_str());
|
||||
CHECK(gamma.find("Off: the picture as before") == std::string::npos, "the help text no longer calls Off the default");
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
Neutral();
|
||||
BrightnessSteps();
|
||||
HalfTexels();
|
||||
D3dModelMatchesTheDump();
|
||||
DecoderFollowsDirect3d();
|
||||
RampThroughTheTable();
|
||||
if (argc > 1)
|
||||
PresenterShaders(argv[1]);
|
||||
else
|
||||
printf("(presenter.cpp not given: its shader checks skipped)\n");
|
||||
if (argc > 2)
|
||||
SettingsDefaults(argv[2]);
|
||||
else
|
||||
printf("(settings.cpp not given: the defaults' checks skipped)\n");
|
||||
if (s_failures)
|
||||
{
|
||||
fprintf(stderr, "%d failure(s)\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("display_tone_test: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
+356
-101
@@ -1,6 +1,7 @@
|
||||
// Tests for gpu::bars (runtime/gpu/edge_bars.h): which front-end quads are
|
||||
// the police vid-cam's side bars or the intro's black frame, what they fill
|
||||
// past the 16:9 band, the stretched copy's posScale, the band clip's rows,
|
||||
// (a bar past the 16:9 band, a black frame everything outside it at every
|
||||
// shape: GitHub #19), the stretched copy's posScale, the band clip's rows,
|
||||
// and the whole plan the renderer asks for (PlanFill) on the draws as
|
||||
// dumped. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -UNDEBUG -Iruntime tests/edge_bars_test.cpp -o build/edge_bars_test
|
||||
@@ -325,8 +326,13 @@ static void BandOracle(const char* what, float sy, const float ps[4], float host
|
||||
CHECK(wrong == 0, "%s: %d band rows differ from the pixel-centre oracle", what, wrong);
|
||||
}
|
||||
|
||||
// The intro's black frame (the cut at ~95 s; ~57 s: kFrameY57), band units.
|
||||
constexpr float kFrameX = 0.970f, kFrameY = 0.939f, kFrameY57 = 0.920f;
|
||||
constexpr uint32_t kFrameColour = 0xFF000000;
|
||||
|
||||
// Under Vert+ the fills and the band clip's rows split the scissor's rows
|
||||
// with no gap and no overlap: a bar's half and a black frame's whole width.
|
||||
// with no gap and no overlap (a bar's half); and the black frame's fill and
|
||||
// the frame's own pixels split the scissor's pixels the same way.
|
||||
static void Complement(const char* what, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor)
|
||||
{
|
||||
const Rows band = BandRows(-sy, sy, ps[1], ps[3], hostH);
|
||||
@@ -343,22 +349,25 @@ static void Complement(const char* what, float sy, const float ps[4], float host
|
||||
fill = fill || (y >= r[i].y0 && y < r[i].y1);
|
||||
wrong += band == fill;
|
||||
}
|
||||
// The black frame's fill, every column: each pixel of the scissor is in
|
||||
// exactly one of the band's rows and the fill.
|
||||
Rect k[2]{};
|
||||
const uint32_t kn = BlackoutRects(ps, hostW, hostH, scissor, 1.0f, sy, k);
|
||||
// The black frame's fill: each pixel of the scissor is in exactly one of
|
||||
// the frame's own pixels (its box through the same Edge rule as the
|
||||
// rasteriser's pixel centres) and the fill.
|
||||
const Box frame{ -kFrameX, kFrameX, -kFrameY * sy, kFrameY * sy };
|
||||
const Rect own = ClipToHost(frame.x0, frame.x1, frame.y0, frame.y1, ps, hostW, hostH);
|
||||
Rect k[4]{};
|
||||
const uint32_t kn = BlackoutRects(frame, ps, hostW, hostH, scissor, k);
|
||||
int wrongPx = 0;
|
||||
for (int32_t y = scissor.y0; y < scissor.y1; y++)
|
||||
for (int32_t x = scissor.x0; x < scissor.x1; x++)
|
||||
{
|
||||
int in = (y >= top && y < bottom) ? 1 : 0;
|
||||
int in = (x >= own.x0 && x < own.x1 && y >= own.y0 && y < own.y1) ? 1 : 0;
|
||||
for (uint32_t i = 0; i < kn; i++)
|
||||
in += (x >= k[i].x0 && x < k[i].x1 && y >= k[i].y0 && y < k[i].y1) ? 1 : 0;
|
||||
wrongPx += in != 1;
|
||||
}
|
||||
printf("complement %s: band rows [%d,%d), %d row(s) wrong; black frame %d pixel(s) wrong\n", what, top, bottom, wrong, wrongPx);
|
||||
CHECK(wrong == 0, "%s: %d rows both or neither in the band clip and the fill", what, wrong);
|
||||
CHECK(wrongPx == 0, "%s: %d pixels both or neither in the band clip and the black frame's fill", what, wrongPx);
|
||||
CHECK(wrongPx == 0, "%s: %d pixels both or neither in the black frame and its fill", what, wrongPx);
|
||||
}
|
||||
|
||||
static void Cover(const char* what, float bx0, float bx1, float sx, float sy, const float ps[4])
|
||||
@@ -379,10 +388,11 @@ static void Cover(const char* what, float bx0, float bx1, float sx, float sy, co
|
||||
// ---- The intro's black frame (the cut between two shots, ~95 s) ----
|
||||
|
||||
// Its box in band units, as logged (NFSMW_LOG_FRONTEND "inset candidate",
|
||||
// the Deck and 32:9): x +-0.970, y +-0.939, FF000000; and the one at the
|
||||
// intro's start (~57 s): y +-0.920, FE000000.
|
||||
constexpr float kFrameX = 0.970f, kFrameY = 0.939f, kFrameY57 = 0.920f;
|
||||
constexpr uint32_t kFrameColour = 0xFF000000;
|
||||
// the Deck and 32:9): x +-0.970, y +-0.939, FF000000 (kFrameX, kFrameY); and
|
||||
// the one at the intro's start (~57 s): y +-0.920, FE000000 (kFrameY57). The
|
||||
// Blacklist bio's panel (GitHub #19): x +-0.976, y +-0.985, FB000000 then
|
||||
// FF000000, fading in from 11000000.
|
||||
constexpr float kBioX = 0.976f, kBioY = 0.985f;
|
||||
|
||||
static void MatchFrame(const char* what, const Quad& q, float sx, float sy, bool want, const BlackoutRule& rule)
|
||||
{
|
||||
@@ -477,10 +487,11 @@ static void PlantedBlackout(const char* what, const BlackoutRule& wrong)
|
||||
}
|
||||
|
||||
static void FillFrame(const char* what, float sx, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor,
|
||||
uint32_t wantN, const Rect* want)
|
||||
uint32_t wantN, const Rect* want, float fx = kFrameX, float fy = kFrameY)
|
||||
{
|
||||
Rect got[2]{};
|
||||
const uint32_t n = BlackoutRects(ps, hostW, hostH, scissor, sx, sy, got);
|
||||
Rect got[4]{};
|
||||
const Box b{ -fx * sx, fx * sx, -fy * sy, fy * sy };
|
||||
const uint32_t n = BlackoutRects(b, ps, hostW, hostH, scissor, got);
|
||||
printf("%s: %u rect(s)", what, n);
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
printf(" [%d,%d)x[%d,%d)", got[i].x0, got[i].x1, got[i].y0, got[i].y1);
|
||||
@@ -491,13 +502,50 @@ static void FillFrame(const char* what, float sx, float sy, const float ps[4], f
|
||||
got[i].y1, want[i].x0, want[i].x1, want[i].y0, want[i].y1);
|
||||
}
|
||||
|
||||
// The black frame's fill against pixel centres: a pixel of the scissor is
|
||||
// filled exactly when its centre is outside the band (|y| > sy under Vert+,
|
||||
// |x| > sx under Hor+); ties within 1/256 pixel skipped, as above.
|
||||
static void FrameOracle(const char* what, float sx, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor)
|
||||
// v0.1.1-v0.1.5's black frame fill, kept here as a planted fault: only what
|
||||
// lies past the band (the rows above and below it under Vert+, the columns
|
||||
// beside it under Hor+, nothing at 16:9), so the band's thin border round
|
||||
// the frame showed what was behind (GitHub #19's Blacklist bio).
|
||||
static uint32_t PastBandOnly(const Box&, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[4])
|
||||
{
|
||||
Rect r[2]{};
|
||||
const uint32_t n = BlackoutRects(ps, hostW, hostH, scissor, sx, sy, r);
|
||||
uint32_t n = 0;
|
||||
auto add = [&](const Rect& r) {
|
||||
const Rect c = Intersect(r, scissor);
|
||||
if (!Empty(c))
|
||||
out[n++] = c;
|
||||
};
|
||||
const float e = kFillEdge;
|
||||
if (scaleY < 1.0f)
|
||||
{
|
||||
add(ClipToHost(-e, e, scaleY, e, ps, hostW, hostH));
|
||||
add(ClipToHost(-e, e, -e, -scaleY, ps, hostW, hostH));
|
||||
}
|
||||
else if (scaleX < 1.0f)
|
||||
{
|
||||
add(ClipToHost(scaleX, e, -e, e, ps, hostW, hostH));
|
||||
add(ClipToHost(-e, -scaleX, -e, e, ps, hostW, hostH));
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// The black frame's fill against pixel centres: a pixel of the scissor is
|
||||
// filled, exactly once, when its centre lies outside the frame's box (past
|
||||
// the band and in the band's border round it, at every shape, 16:9 too),
|
||||
// never inside it; ties within 1/256 pixel skipped, as above. `rects` makes
|
||||
// the rectangles (BlackoutRects, or a planted fault). Returns the wrong
|
||||
// pixels.
|
||||
using FrameRectsFn = uint32_t (*)(const Box&, const float*, float, float, const Rect&, float, float, Rect*);
|
||||
static uint32_t RingRects(const Box& b, const float* ps, float w, float h, const Rect& sc, float, float, Rect* out)
|
||||
{
|
||||
return BlackoutRects(b, ps, w, h, sc, out);
|
||||
}
|
||||
static int FrameOracle(const char* what, float sx, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor,
|
||||
FrameRectsFn rects = RingRects, bool print = true, float fx = kFrameX, float fy = kFrameY)
|
||||
{
|
||||
const Box b{ -fx * sx, fx * sx, -fy * sy, fy * sy };
|
||||
Rect r[4]{};
|
||||
const uint32_t n = rects(b, ps, hostW, hostH, scissor, sx, sy, r);
|
||||
const float pxX = std::fabs(ps[0]) * hostW * 0.5f, pxY = std::fabs(ps[1]) * hostH * 0.5f;
|
||||
auto tie = [](float d, float px) { return std::fabs(d) * px < 1.0f / 256.0f; };
|
||||
int wrong = 0, ties = 0;
|
||||
@@ -507,19 +555,24 @@ static void FrameOracle(const char* what, float sx, float sy, const float ps[4],
|
||||
for (int32_t x = scissor.x0; x < scissor.x1; x++)
|
||||
{
|
||||
const float cx = ((float(x) + 0.5f) / hostW * 2.0f - 1.0f - ps[2]) / ps[0];
|
||||
const bool want = sy < 1.0f ? std::fabs(cy) > sy : sx < 1.0f ? std::fabs(cx) > sx : false;
|
||||
const bool onEdge = sy < 1.0f ? tie(std::fabs(cy) - sy, pxY) : sx < 1.0f && tie(std::fabs(cx) - sx, pxX);
|
||||
bool got = false;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
got = got || (x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1);
|
||||
if (onEdge)
|
||||
if (tie(cx - b.x0, pxX) || tie(cx - b.x1, pxX) || tie(cy - b.y0, pxY) || tie(cy - b.y1, pxY))
|
||||
{
|
||||
ties++;
|
||||
else
|
||||
wrong += got != want;
|
||||
continue;
|
||||
}
|
||||
const bool want = !(cx > b.x0 && cx < b.x1 && cy > b.y0 && cy < b.y1);
|
||||
int got = 0;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
got += x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1;
|
||||
wrong += got != (want ? 1 : 0);
|
||||
}
|
||||
}
|
||||
printf("oracle black frame %s: %d pixel(s) wrong (%d on an edge, skipped)\n", what, wrong, ties);
|
||||
CHECK(wrong == 0, "black frame %s: %d pixels differ from the pixel-centre oracle", what, wrong);
|
||||
if (print)
|
||||
{
|
||||
printf("oracle black frame %s: %d pixel(s) wrong (%d on an edge, skipped)\n", what, wrong, ties);
|
||||
CHECK(wrong == 0, "black frame %s: %d pixels differ from the pixel-centre oracle", what, wrong);
|
||||
}
|
||||
return wrong;
|
||||
}
|
||||
|
||||
// ---- PlanFill on the draws as the renderer sees them ----
|
||||
@@ -691,17 +744,21 @@ static void PlantedBackdrop(const char* what, const BackdropRule& wrong)
|
||||
}
|
||||
|
||||
// The backdrop's fill against pixel centres: every pixel of the scissor
|
||||
// past the band and outside the box is filled, exactly once; nothing in the
|
||||
// band or the box is (ties within 1/256 pixel skipped). `rects` makes the
|
||||
// rectangles (BackdropRects, or a planted fault). Returns the wrong pixels.
|
||||
// outside the box is filled, exactly once, if it lies past the band or in
|
||||
// a row above or below the box (the band's overscan rows, at every shape,
|
||||
// 16:9 too); nothing in the box is, nor in the band beside it within its
|
||||
// rows (ties within 1/256 pixel skipped). `rects` makes the rectangles
|
||||
// (BackdropRects, or a planted fault). Returns the wrong pixels; the
|
||||
// overscan pixels it checked (in the band, above or below the box) go to
|
||||
// `overscan`.
|
||||
using BackdropRectsFn = uint32_t (*)(const Box&, const float*, float, float, const Rect&, float, float, Rect*);
|
||||
static int BackdropOracle(const char* what, float sx, float sy, const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor,
|
||||
BackdropRectsFn rects, bool print = true)
|
||||
BackdropRectsFn rects, bool print = true, int* overscan = nullptr)
|
||||
{
|
||||
Rect r[6]{};
|
||||
Rect r[8]{};
|
||||
const uint32_t n = rects(b, ps, hostW, hostH, scissor, sx, sy, r);
|
||||
const float pxX = std::fabs(ps[0]) * hostW * 0.5f, pxY = std::fabs(ps[1]) * hostH * 0.5f;
|
||||
int wrong = 0, ties = 0;
|
||||
int wrong = 0, ties = 0, rows = 0;
|
||||
for (int32_t y = scissor.y0; y < scissor.y1; y++)
|
||||
for (int32_t x = scissor.x0; x < scissor.x1; x++)
|
||||
{
|
||||
@@ -719,23 +776,28 @@ static int BackdropOracle(const char* what, float sx, float sy, const Box& b, co
|
||||
// centres the first row's centre is past clip 1, still the band).
|
||||
const bool inBand = (sx >= 1.0f || std::fabs(cx) < sx) && (sy >= 1.0f || std::fabs(cy) < sy);
|
||||
const bool inBox = cx > b.x0 && cx < b.x1 && cy > b.y0 && cy < b.y1;
|
||||
const bool pastRows = cy > b.y1 || cy < b.y0; // above or below the box
|
||||
const bool want = !inBox && (!inBand || pastRows);
|
||||
rows += inBand && pastRows;
|
||||
int filled = 0;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
filled += x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1;
|
||||
if (filled != ((inBand || inBox) ? 0 : 1))
|
||||
if (filled != (want ? 1 : 0))
|
||||
{
|
||||
if (print && wrong < 3 && std::getenv("EDGE_BARS_DEBUG"))
|
||||
printf(" wrong px %d,%d: clip %.5f %.5f, band %d box %d filled %d\n", x, y, cx, cy, inBand, inBox, filled);
|
||||
printf(" wrong px %d,%d: clip %.5f %.5f, band %d box %d rows %d filled %d\n", x, y, cx, cy, inBand, inBox, pastRows, filled);
|
||||
wrong++;
|
||||
}
|
||||
}
|
||||
if (overscan)
|
||||
*overscan = rows;
|
||||
if (print)
|
||||
{
|
||||
printf("backdrop fill %s: %u rect(s)", what, n);
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
printf(" [%d,%d)x[%d,%d)", r[i].x0, r[i].x1, r[i].y0, r[i].y1);
|
||||
printf(", %d pixel(s) wrong (%d on an edge, skipped)\n", wrong, ties);
|
||||
CHECK(wrong == 0, "%s: %d pixels filled wrongly (in the band or the box, twice, or left)", what, wrong);
|
||||
printf(", %d overscan px, %d pixel(s) wrong (%d on an edge, skipped)\n", rows, wrong, ties);
|
||||
CHECK(wrong == 0, "%s: %d pixels filled wrongly (in the box or beside it in the band, twice, or left)", what, wrong);
|
||||
}
|
||||
return wrong;
|
||||
}
|
||||
@@ -801,7 +863,8 @@ static void BackdropTests()
|
||||
};
|
||||
const Case cases[] = { { "2560x1080", 0.75f, 1.0f }, { "2868x1320 (iPhone 18 Pro Max)", 0.818182f, 1.0f }, { "3440x1440", 0.744186f, 1.0f },
|
||||
{ "32:9", 0.5f, 1.0f }, { "1.95:1 (the picture past the screen edge)", 0.912f, 1.0f }, { "1280x800", 1.0f, 0.9f },
|
||||
{ "1024x768", 1.0f, 0.75f }, { "1200x800", 1.0f, 0.84375f } };
|
||||
{ "1024x768", 1.0f, 0.75f }, { "1200x800", 1.0f, 0.84375f }, { "1280x720 (16:9)", 1.0f, 1.0f },
|
||||
{ "2732x2048 (iPad Pro)", 1.0f, 0.750366f }, { "3024x1898 (MacBook Pro 14)", 1.0f, 0.896207f } };
|
||||
int wrong = 0;
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
@@ -822,29 +885,66 @@ static void BackdropTests()
|
||||
wrong += BackdropOracle(what, c.sx, c.sy, results, ps, 1280, 2048, Rect{ 100, 10, 1180, 700 }, BackdropRects);
|
||||
}
|
||||
printf("backdrop fill: %d wrong pixel(s) over %zu shapes\n", wrong, std::size(cases));
|
||||
{
|
||||
// The overscan rows at the 8 screen shapes, 1x and 2x (half-pixel
|
||||
// centres): the results picture leaves rows of the band above and
|
||||
// below it at every shape, 16:9 too, and each of them is filled
|
||||
// once (the oracle above, which also counts them).
|
||||
printf("\nbackdrop overscan rows:\n");
|
||||
int rowsWrong = 0;
|
||||
for (const Shape& sh : kShapes)
|
||||
{
|
||||
const Box results{ -kBackdropX * sh.sx, kBackdropX * sh.sx, -kBackdropY * sh.sy, kBackdropY * sh.sy };
|
||||
char what[160];
|
||||
int px1 = 0, px2 = 0;
|
||||
snprintf(what, sizeof(what), "%s, overscan rows, 1x", sh.name);
|
||||
rowsWrong += BackdropOracle(what, sh.sx, sh.sy, results, ps, 1280, 2048, frame, BackdropRects, true, &px1);
|
||||
snprintf(what, sizeof(what), "%s, overscan rows, 2x, half-pixel", sh.name);
|
||||
rowsWrong += BackdropOracle(what, sh.sx, sh.sy, results, half, 2560, 4096, frame2, BackdropRects, true, &px2);
|
||||
// Rows: 0.019 of the band's half height each, ~7 * sy at 1x
|
||||
// (5 at 4:3), across the band's width.
|
||||
const int rowsMin = int(0.019f * sh.sy * 360.0f) - 1, wide = int(1280.0f * std::min(sh.sx, 1.0f)) - 2;
|
||||
CHECK(px1 >= 2 * rowsMin * wide && px2 >= 4 * 2 * rowsMin * wide, "%s: overscan px %d (1x) %d (2x), want >= %d and %d", sh.name, px1,
|
||||
px2, 2 * rowsMin * wide, 8 * rowsMin * wide);
|
||||
}
|
||||
printf("backdrop overscan rows: %d wrong pixel(s) over %zu shapes x 1x, 2x\n", rowsWrong, std::size(kShapes));
|
||||
}
|
||||
{
|
||||
// The rectangles themselves, 2868x1320 at 1x: columns past the
|
||||
// picture's sides (x +-1.104 band = 0.9033 clip: from column 1218,
|
||||
// to 62), and the corners past the band (0.8182 clip: column 1164)
|
||||
// above and below it (y +-0.981: rows 0..6 and 713..719).
|
||||
Rect r[6]{};
|
||||
// to 62), the corners past the band (0.8182 clip: column 1164)
|
||||
// above and below it (y +-0.981: rows 0..6 and 713..719), and the
|
||||
// overscan rows between them, inside the band (columns 116..1163).
|
||||
Rect r[8]{};
|
||||
const Box results{ -kBackdropX * 0.818182f, kBackdropX * 0.818182f, -kBackdropY, kBackdropY };
|
||||
const uint32_t n = BackdropRects(results, ps, 1280, 2048, frame, 0.818182f, 1.0f, r);
|
||||
const Rect want[6] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
|
||||
{ 62, 713, 116, 720 } };
|
||||
CHECK(n == 6, "iPhone: %u rects, want 6", n);
|
||||
for (uint32_t i = 0; i < n && i < 6; i++)
|
||||
const Rect want[8] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
|
||||
{ 62, 713, 116, 720 }, { 116, 0, 1164, 7 }, { 116, 713, 1164, 720 } };
|
||||
CHECK(n == 8, "iPhone: %u rects, want 8", n);
|
||||
for (uint32_t i = 0; i < n && i < 8; i++)
|
||||
CHECK(Same(r[i], want[i]), "iPhone: rect %u [%d,%d)x[%d,%d), want [%d,%d)x[%d,%d)", i, r[i].x0, r[i].x1, r[i].y0, r[i].y1, want[i].x0,
|
||||
want[i].x1, want[i].y0, want[i].y1);
|
||||
// Vert+: the rows above and below the band, whole (the picture stops
|
||||
// inside it in y and past the screen in x).
|
||||
// inside it in y and past the screen in x), then the band's overscan
|
||||
// rows (y +-0.981 x 0.75 = 0.736: rows 90..94 and 625..629).
|
||||
const Box r43{ -kBackdropX, kBackdropX, -kBackdropY * 0.75f, kBackdropY * 0.75f };
|
||||
const uint32_t n43 = BackdropRects(r43, ps, 1280, 2048, frame, 1.0f, 0.75f, r);
|
||||
CHECK(n43 == 2 && Same(r[0], Rect{ 0, 0, 1280, 90 }) && Same(r[1], Rect{ 0, 630, 1280, 720 }), "4:3: %u rects [%d,%d)x[%d,%d) ...", n43,
|
||||
r[0].x0, r[0].x1, r[0].y0, r[0].y1);
|
||||
// 16:9: nothing.
|
||||
CHECK(n43 == 4 && Same(r[0], Rect{ 0, 0, 1280, 90 }) && Same(r[1], Rect{ 0, 630, 1280, 720 }) && Same(r[2], Rect{ 0, 90, 1280, 95 }) &&
|
||||
Same(r[3], Rect{ 0, 625, 1280, 630 }),
|
||||
"4:3: %u rects [%d,%d)x[%d,%d) [%d,%d)x[%d,%d) ...", n43, r[0].x0, r[0].x1, r[0].y0, r[0].y1, r[2].x0, r[2].x1, r[2].y0, r[2].y1);
|
||||
// 16:9: the overscan rows alone, rows 0..6 and 713..719, the whole
|
||||
// width; none for a picture over the whole band.
|
||||
const Box r169{ -kBackdropX, kBackdropX, -kBackdropY, kBackdropY };
|
||||
CHECK(BackdropRects(r169, ps, 1280, 2048, frame, 1.0f, 1.0f, r) == 0, "16:9: a backdrop fills nothing");
|
||||
const uint32_t n169 = BackdropRects(r169, ps, 1280, 2048, frame, 1.0f, 1.0f, r);
|
||||
CHECK(n169 == 2 && Same(r[0], Rect{ 0, 0, 1280, 7 }) && Same(r[1], Rect{ 0, 713, 1280, 720 }), "16:9: %u rects [%d,%d)x[%d,%d) ...", n169,
|
||||
r[0].x0, r[0].x1, r[0].y0, r[0].y1);
|
||||
const Box whole{ -1.0f, 1.0f, -1.0f, 1.0f };
|
||||
CHECK(BackdropRects(whole, ps, 1280, 2048, frame, 1.0f, 1.0f, r) == 0, "16:9: a picture over the whole band fills nothing at 1x");
|
||||
// ... and at 2x with half-pixel centres only the top row, whose
|
||||
// centre lies past clip 1 (the picture is one host row lower: its
|
||||
// bottom edge is past the frame's).
|
||||
const uint32_t n2 = BackdropRects(whole, half, 2560, 4096, frame2, 1.0f, 1.0f, r);
|
||||
CHECK(n2 == 1 && Same(r[0], Rect{ 0, 0, 2560, 1 }), "16:9 2x: %u rects [%d,%d)x[%d,%d) ...", n2, r[0].x0, r[0].x1, r[0].y0, r[0].y1);
|
||||
}
|
||||
printf("\nplanted, backdrop fill (each must fail):\n");
|
||||
{
|
||||
@@ -859,11 +959,11 @@ static void BackdropTests()
|
||||
printf("planted %s: %d wrong pixel(s)\n", what, bad);
|
||||
CHECK(bad > 0, "planted %s: no wrong pixel", what);
|
||||
};
|
||||
run("the whole strips (the black frame's fill)", [](const Box&, const float* ps, float w, float h, const Rect& sc, float sx, float sy, Rect* out) {
|
||||
return BlackoutRects(ps, w, h, sc, sx, sy, out);
|
||||
run("the whole strips (v0.1.5's black frame fill)", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy, Rect* out) {
|
||||
return PastBandOnly(b, ps, w, h, sc, sx, sy, out);
|
||||
});
|
||||
run("past the picture only (no corners)", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy, Rect* out) {
|
||||
Rect r[6]{};
|
||||
Rect r[8]{};
|
||||
const uint32_t n = BackdropRects(b, ps, w, h, sc, sx, sy, r);
|
||||
uint32_t m = 0;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
@@ -877,6 +977,69 @@ static void BackdropTests()
|
||||
wider.x1 = std::min(b.x1, sx);
|
||||
return BackdropRects(wider, ps, w, h, sc, sx, sy, out);
|
||||
});
|
||||
// The overscan rows, planted: each must fail. runOverscan checks
|
||||
// every shape at 1x and 2x; with `every`, each shape must fail (16:9
|
||||
// included), else at least one (a fault only some shapes have).
|
||||
auto runOverscan = [&](const char* what, BackdropRectsFn f, bool every) {
|
||||
int bad = 0, shapesFailing = 0;
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
const Box results{ -kBackdropX * c.sx, kBackdropX * c.sx, -kBackdropY * c.sy, kBackdropY * c.sy };
|
||||
const int here = BackdropOracle(what, c.sx, c.sy, results, ps, 1280, 2048, frame, f, false) +
|
||||
BackdropOracle(what, c.sx, c.sy, results, half, 2560, 4096, frame2, f, false);
|
||||
bad += here;
|
||||
shapesFailing += here > 0;
|
||||
}
|
||||
printf("planted %s: %d wrong pixel(s), %d of %zu shapes\n", what, bad, shapesFailing, std::size(cases));
|
||||
CHECK(every ? shapesFailing == int(std::size(cases)) : bad > 0, "planted %s: %d of %zu shapes failed", what, shapesFailing,
|
||||
std::size(cases));
|
||||
};
|
||||
// 811cdf2's fill: past the band only, the overscan rows left.
|
||||
runOverscan("no overscan rows (past the band only, as 811cdf2)", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx,
|
||||
float sy, Rect* out) { return PastBandRects(b, ps, w, h, sc, sx, sy, out); }, true);
|
||||
// Rows only where something lies past the band: 16:9 left.
|
||||
runOverscan("overscan rows only on wide and tall screens", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx,
|
||||
float sy, Rect* out) {
|
||||
const uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
return n + (sx < 1.0f || sy < 1.0f ? OverscanRows(b, ps, w, h, sc, sx, sy, out + n) : 0u);
|
||||
}, false);
|
||||
// Rows to clip 1 where the band's edge is the screen's (no
|
||||
// kFillEdge): at 2x the outermost row and column are left.
|
||||
runOverscan("overscan rows to clip 1, not kFillEdge", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy,
|
||||
Rect* out) {
|
||||
uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
const float bx = std::min(sx, 1.0f), by = std::min(sy, 1.0f);
|
||||
for (const Rect& r : { ClipToHost(-bx, bx, b.y1, by, ps, w, h), ClipToHost(-bx, bx, -by, b.y0, ps, w, h) })
|
||||
if (const Rect c = Intersect(r, sc); !Empty(c))
|
||||
out[n++] = c;
|
||||
return n;
|
||||
}, false);
|
||||
// Rows across the whole screen under Hor+: twice where the fill
|
||||
// past the band has them already.
|
||||
runOverscan("overscan rows across the whole screen", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy,
|
||||
Rect* out) {
|
||||
const uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
return n + OverscanRows(b, ps, w, h, sc, 1.0f, sy, out + n);
|
||||
}, false);
|
||||
// Rows from just inside the picture: its own top and bottom rows
|
||||
// blacked.
|
||||
runOverscan("overscan rows from inside the picture", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy,
|
||||
Rect* out) {
|
||||
Box in = b;
|
||||
in.y0 += 0.01f;
|
||||
in.y1 -= 0.01f;
|
||||
const uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
return n + OverscanRows(in, ps, w, h, sc, sx, sy, out + n);
|
||||
}, true);
|
||||
// Rows short of the picture: a row of the race left above it.
|
||||
runOverscan("overscan rows short of the picture", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy,
|
||||
Rect* out) {
|
||||
Box out_ = b;
|
||||
out_.y0 -= 0.01f;
|
||||
out_.y1 += 0.01f;
|
||||
const uint32_t n = PastBandRects(b, ps, w, h, sc, sx, sy, out);
|
||||
return n + OverscanRows(out_, ps, w, h, sc, sx, sy, out + n);
|
||||
}, true);
|
||||
}
|
||||
|
||||
printf("\nbackdrop plan (the draw as the renderer hands it over):\n");
|
||||
@@ -884,15 +1047,57 @@ static void BackdropTests()
|
||||
const Matrix iphone = FrontEndMatrix(0.818182f, 1.0f), deck = FrontEndMatrix(1.0f, 0.9f), m169 = FrontEndMatrix(1.0f, 1.0f);
|
||||
const EPolys results = Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 0.818182f, 1.0f, kBackdropColour), iphone);
|
||||
const Draw d = BackdropDraw(iphone, 0.818182f, 1.0f, ps);
|
||||
const Rect want[6] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
|
||||
{ 62, 713, 116, 720 } };
|
||||
BackdropPlanCase("iPhone results", d, results, Fill::Backdrop, 6, want);
|
||||
const Rect deckWant[2] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 } };
|
||||
const Rect want[8] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
|
||||
{ 62, 713, 116, 720 }, { 116, 0, 1164, 7 }, { 116, 713, 1164, 720 } };
|
||||
BackdropPlanCase("iPhone results", d, results, Fill::Backdrop, 8, want);
|
||||
// The Deck (1280x800): the rows past the band (0..35, 684..719),
|
||||
// then its overscan rows (y 0.981 x 0.9 = 0.883: 36..41, 678..683).
|
||||
const Rect deckWant[4] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 }, { 0, 36, 1280, 42 }, { 0, 678, 1280, 684 } };
|
||||
BackdropPlanCase("Deck results", BackdropDraw(deck, 1.0f, 0.9f, ps),
|
||||
Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 0.9f, kBackdropColour), deck), Fill::Backdrop, 2, deckWant);
|
||||
// Never at 16:9 (nothing past the band to fill).
|
||||
BackdropPlanCase("16:9 results", BackdropDraw(m169, 1.0f, 1.0f, ps),
|
||||
Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 1.0f, kBackdropColour), m169), Fill::None);
|
||||
Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 0.9f, kBackdropColour), deck), Fill::Backdrop, 4, deckWant);
|
||||
// 16:9 too: its overscan rows alone (rows 0..6 and 713..719), also
|
||||
// from a ppc/ without the Vert+ hooks (no front-end y scale).
|
||||
const Draw d169 = BackdropDraw(m169, 1.0f, 1.0f, ps);
|
||||
const EPolys results169 = Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 1.0f, kBackdropColour), m169);
|
||||
const Rect want169[2] = { { 0, 0, 1280, 7 }, { 0, 713, 1280, 720 } };
|
||||
BackdropPlanCase("16:9 results", d169, results169, Fill::Backdrop, 2, want169);
|
||||
Draw old169 = d169;
|
||||
old169.frontEndY = 0.0f;
|
||||
BackdropPlanCase("16:9 results, no Vert+ hooks", old169, results169, Fill::Backdrop, 2, want169);
|
||||
// ... and at 2x with half-pixel centres: the picture one host row
|
||||
// lower (its top at 2 x 6.84 + 1 = 14.7, its bottom at 1427.3):
|
||||
// rows 0..14 and 1427..1439.
|
||||
float half169[4];
|
||||
FrontEndPs(half169, 640.5f, 360.5f);
|
||||
const Rect want169x2[2] = { { 0, 0, 2560, 15 }, { 0, 1427, 2560, 1440 } };
|
||||
BackdropPlanCase("16:9 results, 2x", BackdropDraw(m169, 1.0f, 1.0f, half169, 2560.0f, 4096.0f, Rect{ 0, 0, 2560, 1440 }), results169,
|
||||
Fill::Backdrop, 2, want169x2);
|
||||
// Every register check refuses at 16:9 as elsewhere (a fade, a dim
|
||||
// or the HUD never gets this far: MatchBackdrop's cases above run
|
||||
// at 16:9 too).
|
||||
auto refuse169 = [&](const char* what, auto change) {
|
||||
Draw x = d169;
|
||||
Matrix m = m169;
|
||||
change(x, m);
|
||||
x.c = m.c;
|
||||
char name[96];
|
||||
snprintf(name, sizeof(name), "16:9, %s", what);
|
||||
BackdropPlanCase(name, x, results169, Fill::None);
|
||||
};
|
||||
refuse169("taken by another rule", [](Draw& x, Matrix&) { x.leftAlone = false; });
|
||||
refuse169("not the front-end shader", [](Draw& x, Matrix&) { x.frontEndShader = false; });
|
||||
refuse169("inside an occlusion query", [](Draw& x, Matrix&) { x.occlusion = true; });
|
||||
refuse169("two quads", [](Draw& x, Matrix&) { x.count = 8; });
|
||||
refuse169("additive blend", [](Draw& x, Matrix&) { x.blend0 = 0x01060106; });
|
||||
refuse169("t0 not sampled", [](Draw& x, Matrix&) { x.t0Sampled = false; });
|
||||
refuse169("t0 32x32 (the white texture)", [](Draw& x, Matrix&) { x.t0Width = x.t0Height = 32; });
|
||||
refuse169("x translation c3", [](Draw&, Matrix& m) { m.c[3] = 0.01f; });
|
||||
refuse169("c5 not the front end's y scale", [](Draw& x, Matrix&) { x.frontEndY = 0.83f; });
|
||||
refuse169("VTE x offset off", [](Draw& x, Matrix&) { x.vte = 0x43D; });
|
||||
EPolys faded169 = results169;
|
||||
for (auto& w : faded169.w)
|
||||
w[3] = 0xB87F7F7F;
|
||||
BackdropPlanCase("16:9, alpha B8 (a fade-in)", d169, faded169, Fill::None);
|
||||
// A flat full-band layer over the 32x32 white texture is not a
|
||||
// picture (the black frame's and the fades' kind).
|
||||
Draw flat = d;
|
||||
@@ -922,7 +1127,7 @@ static void BackdropTests()
|
||||
refuse("VTE y offset off", [](Draw& x, Matrix&) { x.vte = 0x437; });
|
||||
Draw opaque = d;
|
||||
opaque.blend0 = 0x00010001;
|
||||
BackdropPlanCase("blending off", opaque, results, Fill::Backdrop, 6, want);
|
||||
BackdropPlanCase("blending off", opaque, results, Fill::Backdrop, 8, want);
|
||||
// PlanFill (bars and black frames) never takes it: the renderer asks
|
||||
// PlanBackdrop only when PlanFill found nothing.
|
||||
CHECK(RunPlan(d, results).fill == Fill::None, "PlanFill took the backdrop");
|
||||
@@ -1140,42 +1345,82 @@ int main()
|
||||
|
||||
printf("\nblack frame fill:\n");
|
||||
{
|
||||
const Rect deck[2] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 } };
|
||||
FillFrame("Deck, 1x", 1.0f, 0.9f, ps, 1280, 2048, frame, 2, deck);
|
||||
FillFrame("Deck, 1x, half-pixel", 1.0f, 0.9f, half, 1280, 2048, frame, 2, deck);
|
||||
const Rect deck2[2] = { { 0, 0, 2560, 72 }, { 0, 1368, 2560, 1440 } };
|
||||
FillFrame("Deck, 2x", 1.0f, 0.9f, ps, 2560, 4096, frame2, 2, deck2);
|
||||
const Rect deck2h[2] = { { 0, 0, 2560, 73 }, { 0, 1369, 2560, 1440 } };
|
||||
FillFrame("Deck, 2x, half-pixel", 1.0f, 0.9f, half, 2560, 4096, frame2, 2, deck2h);
|
||||
const Rect r43[2] = { { 0, 0, 1280, 90 }, { 0, 630, 1280, 720 } };
|
||||
FillFrame("4:3", 1.0f, 0.75f, ps, 1280, 2048, frame, 2, r43);
|
||||
FillFrame("iPad Pro", 1.0f, 0.750366f, ps, 1280, 2048, frame, 2, r43);
|
||||
const Rect r32[2] = { { 0, 0, 1280, 56 }, { 0, 664, 1280, 720 } };
|
||||
FillFrame("3:2", 1.0f, 0.84375f, ps, 1280, 2048, frame, 2, r32);
|
||||
const Rect t[1] = { { 0, 428, 1280, 464 } };
|
||||
FillFrame("Deck, tile at y -256", 1.0f, 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 }, 1, t);
|
||||
const Rect g[2] = { { 100, 10, 1180, 36 }, { 100, 684, 1180, 700 } };
|
||||
// Everything outside the frame: the rows above and below it, then
|
||||
// the columns beside it within its rows (v0.1.5: only past the band,
|
||||
// the Deck's rows 0..35 and 684..719, nothing at 16:9).
|
||||
const Rect deck[4] = { { 0, 0, 1280, 56 }, { 0, 664, 1280, 720 }, { 0, 56, 19, 664 }, { 1261, 56, 1280, 664 } };
|
||||
FillFrame("Deck, 1x", 1.0f, 0.9f, ps, 1280, 2048, frame, 4, deck);
|
||||
const Rect deckh[4] = { { 0, 0, 1280, 56 }, { 0, 665, 1280, 720 }, { 0, 56, 20, 665 }, { 1261, 56, 1280, 665 } };
|
||||
FillFrame("Deck, 1x, half-pixel", 1.0f, 0.9f, half, 1280, 2048, frame, 4, deckh);
|
||||
const Rect deck2[4] = { { 0, 0, 2560, 112 }, { 0, 1328, 2560, 1440 }, { 0, 112, 38, 1328 }, { 2522, 112, 2560, 1328 } };
|
||||
FillFrame("Deck, 2x", 1.0f, 0.9f, ps, 2560, 4096, frame2, 4, deck2);
|
||||
const Rect deck2h[4] = { { 0, 0, 2560, 113 }, { 0, 1329, 2560, 1440 }, { 0, 113, 39, 1329 }, { 2523, 113, 2560, 1329 } };
|
||||
FillFrame("Deck, 2x, half-pixel", 1.0f, 0.9f, half, 2560, 4096, frame2, 4, deck2h);
|
||||
const Rect r43[4] = { { 0, 0, 1280, 106 }, { 0, 614, 1280, 720 }, { 0, 106, 19, 614 }, { 1261, 106, 1280, 614 } };
|
||||
FillFrame("4:3", 1.0f, 0.75f, ps, 1280, 2048, frame, 4, r43);
|
||||
FillFrame("iPad Pro", 1.0f, 0.750366f, ps, 1280, 2048, frame, 4, r43);
|
||||
const Rect r32[4] = { { 0, 0, 1280, 75 }, { 0, 645, 1280, 720 }, { 0, 75, 19, 645 }, { 1261, 75, 1280, 645 } };
|
||||
FillFrame("3:2", 1.0f, 0.84375f, ps, 1280, 2048, frame, 4, r32);
|
||||
const Rect t[3] = { { 0, 408, 1280, 464 }, { 0, 0, 19, 408 }, { 1261, 0, 1280, 408 } };
|
||||
FillFrame("Deck, tile at y -256", 1.0f, 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 }, 3, t);
|
||||
const Rect g[2] = { { 100, 10, 1180, 56 }, { 100, 664, 1180, 700 } };
|
||||
FillFrame("Deck, game scissor", 1.0f, 0.9f, ps, 1280, 2048, Rect{ 100, 10, 1180, 700 }, 2, g);
|
||||
const Rect fl[2] = { { 0, 684, 1280, 720 }, { 0, 0, 1280, 36 } };
|
||||
FillFrame("Deck, y flipped", 1.0f, 0.9f, flipped, 1280, 2048, frame, 2, fl);
|
||||
const Rect w329[2] = { { 1920, 0, 2560, 1440 }, { 0, 0, 640, 1440 } };
|
||||
FillFrame("32:9, 2x", 0.5f, 1.0f, ps, 2560, 4096, frame2, 2, w329);
|
||||
const Rect w329h[2] = { { 1921, 0, 2560, 1440 }, { 0, 0, 641, 1440 } };
|
||||
FillFrame("32:9, 2x, half-pixel", 0.5f, 1.0f, half, 2560, 4096, frame2, 2, w329h);
|
||||
const Rect uw[2] = { { 1120, 0, 1280, 720 }, { 0, 0, 160, 720 } };
|
||||
FillFrame("21:9", 0.75f, 1.0f, ps, 1280, 2048, frame, 2, uw);
|
||||
const Rect u3440[2] = { { 1116, 0, 1280, 720 }, { 0, 0, 164, 720 } };
|
||||
FillFrame("3440x1440", 0.744186f, 1.0f, ps, 1280, 2048, frame, 2, u3440);
|
||||
FillFrame("16:9", 1.0f, 1.0f, ps, 1280, 2048, frame, 0, nullptr);
|
||||
const Rect fl[4] = { { 0, 664, 1280, 720 }, { 0, 0, 1280, 56 }, { 0, 56, 19, 664 }, { 1261, 56, 1280, 664 } };
|
||||
FillFrame("Deck, y flipped", 1.0f, 0.9f, flipped, 1280, 2048, frame, 4, fl);
|
||||
const Rect w329[4] = { { 0, 0, 2560, 44 }, { 0, 1396, 2560, 1440 }, { 0, 44, 659, 1396 }, { 1901, 44, 2560, 1396 } };
|
||||
FillFrame("32:9, 2x", 0.5f, 1.0f, ps, 2560, 4096, frame2, 4, w329);
|
||||
const Rect w329h[4] = { { 0, 0, 2560, 45 }, { 0, 1397, 2560, 1440 }, { 0, 45, 660, 1397 }, { 1902, 45, 2560, 1397 } };
|
||||
FillFrame("32:9, 2x, half-pixel", 0.5f, 1.0f, half, 2560, 4096, frame2, 4, w329h);
|
||||
const Rect uw[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 174, 698 }, { 1106, 22, 1280, 698 } };
|
||||
FillFrame("21:9", 0.75f, 1.0f, ps, 1280, 2048, frame, 4, uw);
|
||||
const Rect u3440[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 178, 698 }, { 1102, 22, 1280, 698 } };
|
||||
FillFrame("3440x1440", 0.744186f, 1.0f, ps, 1280, 2048, frame, 4, u3440);
|
||||
const Rect r169[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 19, 698 }, { 1261, 22, 1280, 698 } };
|
||||
FillFrame("16:9", 1.0f, 1.0f, ps, 1280, 2048, frame, 4, r169);
|
||||
const Rect r169h[4] = { { 0, 0, 2560, 45 }, { 0, 1397, 2560, 1440 }, { 0, 45, 39, 1397 }, { 2523, 45, 2560, 1397 } };
|
||||
FillFrame("16:9, 2x, half-pixel", 1.0f, 1.0f, half, 2560, 4096, frame2, 4, r169h);
|
||||
}
|
||||
FrameOracle("Deck 1x", 1.0f, 0.9f, ps, 1280, 2048, frame);
|
||||
FrameOracle("Deck 3x, half-pixel", 1.0f, 0.9f, half, 3840, 6144, frame3);
|
||||
FrameOracle("4:3 2x, y flipped, half-pixel", 1.0f, 0.75f, flippedHalf, 2560, 4096, frame2);
|
||||
FrameOracle("Deck, tile", 1.0f, 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 });
|
||||
FrameOracle("Deck, game scissor", 1.0f, 0.9f, ps, 1280, 2048, Rect{ 100, 10, 1180, 700 });
|
||||
FrameOracle("32:9 2x, half-pixel", 0.5f, 1.0f, half, 2560, 4096, frame2);
|
||||
FrameOracle("21:9 1x", 0.75f, 1.0f, ps, 1280, 2048, frame);
|
||||
FrameOracle("3440x1440 2x", 0.744186f, 1.0f, ps, 2560, 4096, frame2);
|
||||
FrameOracle("16:9", 1.0f, 1.0f, ps, 1280, 2048, frame);
|
||||
FrameOracle("16:9 2x, half-pixel", 1.0f, 1.0f, half, 2560, 4096, frame2);
|
||||
FrameOracle("~57 s frame, Deck", 1.0f, 0.9f, ps, 1280, 2048, frame, RingRects, true, kFrameX, kFrameY57);
|
||||
FrameOracle("Blacklist bio panel, 16:9", 1.0f, 1.0f, ps, 1280, 2048, frame, RingRects, true, kBioX, kBioY);
|
||||
FrameOracle("Blacklist bio panel, Deck 2x, half-pixel", 1.0f, 0.9f, half, 2560, 4096, frame2, RingRects, true, kBioX, kBioY);
|
||||
// Planted: the oracle catches v0.1.5's fill (past the band only) at
|
||||
// every shape, 16:9 included, and a ring that leaves the frame's own
|
||||
// rows' sides out.
|
||||
{
|
||||
struct Shape2
|
||||
{
|
||||
const char* name;
|
||||
float sx, sy;
|
||||
};
|
||||
const Shape2 shapes[] = { { "Deck", 1.0f, 0.9f }, { "4:3", 1.0f, 0.75f }, { "16:9", 1.0f, 1.0f }, { "21:9", 0.75f, 1.0f }, { "32:9", 0.5f, 1.0f } };
|
||||
int failing = 0;
|
||||
for (const Shape2& sh : shapes)
|
||||
failing += FrameOracle(sh.name, sh.sx, sh.sy, ps, 1280, 2048, frame, PastBandOnly, false) > 0;
|
||||
printf("planted black frame fill 'past the band only (v0.1.5)': %d of %zu shapes fail\n", failing, std::size(shapes));
|
||||
CHECK(failing == int(std::size(shapes)), "planted 'past the band only': %d of %zu shapes fail", failing, std::size(shapes));
|
||||
const int rowsOnly = FrameOracle("rows only", 1.0f, 0.9f, ps, 1280, 2048, frame,
|
||||
[](const Box& b, const float* q, float w, float h, const Rect& sc, float, float, Rect* out) {
|
||||
Rect r[4]{};
|
||||
const uint32_t n = BlackoutRects(b, q, w, h, sc, r);
|
||||
uint32_t m = 0;
|
||||
for (uint32_t i = 0; i < n && i < 2; i++)
|
||||
out[m++] = r[i];
|
||||
return m;
|
||||
},
|
||||
false);
|
||||
printf("planted black frame fill 'rows above and below only': %d wrong pixel(s)\n", rowsOnly);
|
||||
CHECK(rowsOnly > 0, "planted 'rows above and below only' passed");
|
||||
}
|
||||
Cover("black frame, Deck", -kFrameX, kFrameX, 1.0f, 0.9f, ps);
|
||||
Cover("black frame, 32:9, half-pixel", -kFrameX, kFrameX, 0.5f, 1.0f, half);
|
||||
|
||||
@@ -1190,8 +1435,14 @@ int main()
|
||||
const Rect right[2] = { { 640, 0, 1280, 36 }, { 640, 684, 1280, 720 } }, left[2] = { { 0, 0, 640, 36 }, { 0, 684, 640, 720 } };
|
||||
PlanCase("Deck right bar", d, rightDeck, Fill::RightBar, 2, right);
|
||||
PlanCase("Deck left bar", d, leftDeck, Fill::LeftBar, 2, left);
|
||||
const Rect black[2] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 } };
|
||||
PlanCase("Deck black frame", d, frameDeck, Fill::Blackout, 2, black);
|
||||
const Rect black[4] = { { 0, 0, 1280, 56 }, { 0, 664, 1280, 720 }, { 0, 56, 19, 664 }, { 1261, 56, 1280, 664 } };
|
||||
PlanCase("Deck black frame", d, frameDeck, Fill::Blackout, 4, black);
|
||||
// The Blacklist bio's panel (GitHub #19), as logged on the Deck:
|
||||
// "black frame x -0.976..0.976 y -0.887..0.887 colour FB000000".
|
||||
const Rect bio[4] = { { 0, 0, 1280, 41 }, { 0, 679, 1280, 720 }, { 0, 41, 15, 679 }, { 1265, 41, 1280, 679 } };
|
||||
PlanCase("Deck Blacklist bio panel", d, Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, 1.0f, 0.9f, 0xFB000000), deck), Fill::Blackout, 4, bio);
|
||||
// ... fading in (11000000): left as it is, as before.
|
||||
PlanCase("Deck Blacklist bio panel fading in", d, Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, 1.0f, 0.9f, 0x11000000), deck), Fill::None);
|
||||
{
|
||||
const Plan p = RunPlan(d, rightDeck);
|
||||
CHECK(std::fabs(p.box.x0 - 0.651f) < 1e-5f && std::fabs(p.box.x1 - 1.406f) < 1e-5f && std::fabs(p.box.y0 + 1.024f * 0.9f) < 1e-5f &&
|
||||
@@ -1205,22 +1456,26 @@ int main()
|
||||
const EPolys right329 = Polys(Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 0.5f, 1.0f, 0xFD000000), w329);
|
||||
const EPolys frame329 = Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 0.5f, 1.0f, kFrameColour), w329);
|
||||
const Draw d329 = S2Draw(w329, 0.5f, 1.0f, ps, 2560, 4096, frame2);
|
||||
const Rect r329[1] = { { 1920, 0, 2560, 1440 } }, f329[2] = { { 1920, 0, 2560, 1440 }, { 0, 0, 640, 1440 } };
|
||||
const Rect r329[1] = { { 1920, 0, 2560, 1440 } };
|
||||
const Rect f329[4] = { { 0, 0, 2560, 44 }, { 0, 1396, 2560, 1440 }, { 0, 44, 659, 1396 }, { 1901, 44, 2560, 1396 } };
|
||||
PlanCase("32:9 right bar", d329, right329, Fill::RightBar, 1, r329);
|
||||
PlanCase("32:9 black frame", d329, frame329, Fill::Blackout, 2, f329);
|
||||
PlanCase("32:9 black frame", d329, frame329, Fill::Blackout, 4, f329);
|
||||
// 21:9: the bars reach the screen edge (nothing to fill), the black
|
||||
// frame does not.
|
||||
const Matrix uw = FrontEndMatrix(0.75f, 1.0f);
|
||||
const Draw duw = S2Draw(uw, 0.75f, 1.0f, ps);
|
||||
PlanCase("21:9 right bar", duw, Polys(Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 0.75f, 1.0f, 0xFD000000), uw), Fill::RightBar, 0, nullptr);
|
||||
const Rect fuw[2] = { { 1120, 0, 1280, 720 }, { 0, 0, 160, 720 } };
|
||||
PlanCase("21:9 black frame", duw, Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 0.75f, 1.0f, kFrameColour), uw), Fill::Blackout, 2,
|
||||
const Rect fuw[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 174, 698 }, { 1106, 22, 1280, 698 } };
|
||||
PlanCase("21:9 black frame", duw, Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 0.75f, 1.0f, kFrameColour), uw), Fill::Blackout, 4,
|
||||
fuw);
|
||||
// 16:9 never gets here (the renderer's block needs a view scale
|
||||
// below 1), and a plan there fills nothing.
|
||||
// 16:9 (the renderer asks PlanFill there too): the thin border round
|
||||
// the frame; a vid-cam bar there fills nothing.
|
||||
const Matrix m169 = FrontEndMatrix(1.0f, 1.0f);
|
||||
const Rect f169[4] = { { 0, 0, 1280, 22 }, { 0, 698, 1280, 720 }, { 0, 22, 19, 698 }, { 1261, 22, 1280, 698 } };
|
||||
PlanCase("16:9 black frame", S2Draw(m169, 1.0f, 1.0f, ps),
|
||||
Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 1.0f, 1.0f, kFrameColour), m169), Fill::Blackout, 0, nullptr);
|
||||
Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 1.0f, 1.0f, kFrameColour), m169), Fill::Blackout, 4, f169);
|
||||
PlanCase("16:9 right bar", S2Draw(m169, 1.0f, 1.0f, ps), Polys(Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 1.0f, 1.0f, 0xFD000000), m169),
|
||||
Fill::RightBar, 0, nullptr);
|
||||
// A ppc/ from before the Vert+ hooks reports no front-end y scale
|
||||
// (0): Hor+ still fills (c5 the unnarrowed 0.92), a narrowed view
|
||||
// can't happen there and is refused.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The front end's matrix after the window changes shape (game/
|
||||
// front_end_matrix.h): the "EVENT HANDLER" label a player saw above the
|
||||
// boot PSA after pressing the green button mid-movie (2026-10-12). A model
|
||||
// of the game's pointer-keyed cache in sub_82453768 and of the front-end
|
||||
// hook, then the renderer's front-end recognition (the matrix the hook left
|
||||
// last) and its band clip, on the label's own numbers.
|
||||
//
|
||||
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/front_end_matrix_test.cpp
|
||||
#include <game/front_end_matrix.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
|
||||
using game::front_end::MatrixTracker;
|
||||
|
||||
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)
|
||||
|
||||
// The front-end builder's matrix for a 16:9 frame (sub_82440D80's
|
||||
// widescreen case): x 0.92 x 0.75, y 0.92.
|
||||
constexpr float kBaseX = 0.92f * 0.75f, kBaseY = 0.92f;
|
||||
// Its three pointers: the view entry, the builder's stack copy (the same
|
||||
// address every frame), the object.
|
||||
constexpr uint32_t kView = 0x82C3F590, kStack = 0x7000FF60, kObject = 0x40012340;
|
||||
// The "EVENT HANDLER" label, 13 quads parked above the frame: clip y
|
||||
// 1.039..1.092 under the 16:9 matrix (c5 = 0.92), x 0.298..0.647
|
||||
// (NFSMW_LOG_FRONTEND / a diagnostic log of its draw, 2026-10-12).
|
||||
constexpr float kLabelLo = 1.039f / kBaseY, kLabelHi = 1.092f / kBaseY; // front-end units
|
||||
|
||||
// sub_82453768: returns at once when called with the same view, matrix
|
||||
// and object pointers as last time (its cache at 0x82A2D190, +4, +8).
|
||||
struct GuestSetMatrix
|
||||
{
|
||||
uint32_t view = 0, matrix = 0, object = 0;
|
||||
float x = 0.0f, y = 0.0f; // what the front end's draws get (c0, c5)
|
||||
int uploads = 0;
|
||||
void Call(uint32_t v, uint32_t m, uint32_t o, float mx, float my)
|
||||
{
|
||||
const bool same = v == view && m == matrix && o == object;
|
||||
view = v;
|
||||
matrix = m;
|
||||
object = o;
|
||||
if (same)
|
||||
return;
|
||||
x = mx;
|
||||
y = my;
|
||||
uploads++;
|
||||
}
|
||||
};
|
||||
|
||||
// The game's front end for one frame: the builder, the hook (narrowing
|
||||
// by the view scales; with `tracker`, clearing the cached matrix pointer
|
||||
// when the matrix changed, as game/ultrawide.cpp does), the upload. With
|
||||
// `otherView`, a 3D view went through the cache before it (menus, races).
|
||||
struct Game
|
||||
{
|
||||
GuestSetMatrix set;
|
||||
MatrixTracker tracker;
|
||||
bool resend = true;
|
||||
float hookX = 0.0f, hookY = 0.0f; // game::FrontEndScaleX/Y
|
||||
void Frame(float scaleX, float scaleY, bool otherView = false)
|
||||
{
|
||||
if (otherView)
|
||||
set.Call(0x82C3F710, 0x7000FE00, kObject, 1.0f, 1.0f);
|
||||
hookX = kBaseX * scaleX;
|
||||
hookY = kBaseY * scaleY;
|
||||
if (resend && tracker.Changed(hookX, hookY))
|
||||
set.matrix = 0;
|
||||
set.Call(kView, kStack, kObject, hookX, hookY);
|
||||
}
|
||||
};
|
||||
|
||||
// The renderer (gpu/renderer.cpp): a front-end draw is recognised when
|
||||
// its c0 and c5 are the matrix the hook left last; then under Vert+ a
|
||||
// primitive wholly outside the band (+-scaleY) is clipped away.
|
||||
struct Outcome
|
||||
{
|
||||
bool matched;
|
||||
float labelLo, labelHi; // the label's clip y
|
||||
bool labelShows; // on screen: inside clip +-1 and not clipped
|
||||
};
|
||||
Outcome Render(const Game& g, float scaleY)
|
||||
{
|
||||
Outcome o{};
|
||||
o.matched = std::fabs(g.set.x - g.hookX) <= g.hookX * 1e-5f && std::fabs(g.set.y - g.hookY) <= g.hookY * 1e-5f;
|
||||
o.labelLo = kLabelLo * g.set.y;
|
||||
o.labelHi = kLabelHi * g.set.y;
|
||||
const bool outsideBand = o.labelLo > scaleY;
|
||||
const bool clipped = o.matched && scaleY < 1.0f && outsideBand;
|
||||
o.labelShows = !clipped && o.labelLo < 1.0f;
|
||||
return o;
|
||||
}
|
||||
|
||||
void Tracker()
|
||||
{
|
||||
MatrixTracker t;
|
||||
CHECK(t.Changed(kBaseX, kBaseY), "the first matrix is sent");
|
||||
CHECK(!t.Changed(kBaseX, kBaseY), "the same one again is not");
|
||||
CHECK(t.Changed(kBaseX, kBaseY * 0.9f), "a new y scale is");
|
||||
CHECK(!t.Changed(kBaseX, kBaseY * 0.9f), "and only once");
|
||||
CHECK(t.Changed(kBaseX * 0.744f, kBaseY * 0.9f), "a new x scale is");
|
||||
CHECK(t.Changed(kBaseX, kBaseY), "back to 16:9 is");
|
||||
}
|
||||
|
||||
// The reported case: the PSA starts in a 1512x921-point window (Vert+
|
||||
// 0.9235), the green button takes it to the 3024x1900 fullscreen Space
|
||||
// (0.8953) mid-movie; nothing else uses the cache during the movies.
|
||||
void GreenButtonMidMovie()
|
||||
{
|
||||
for (bool resend : { true, false })
|
||||
{
|
||||
Game g;
|
||||
g.resend = resend;
|
||||
for (int f = 0; f < 30; f++)
|
||||
g.Frame(1.0f, 0.9235f);
|
||||
Outcome before = Render(g, 0.9235f);
|
||||
CHECK(before.matched && !before.labelShows, "%s, before the change: matched %d, label at %.3f..%.3f shows %d",
|
||||
resend ? "fixed" : "as v0.1.3", before.matched, before.labelLo, before.labelHi, before.labelShows);
|
||||
for (int f = 0; f < 30; f++)
|
||||
g.Frame(1.0f, 0.8953f);
|
||||
Outcome after = Render(g, 0.8953f);
|
||||
if (resend)
|
||||
{
|
||||
CHECK(std::fabs(g.set.y - kBaseY * 0.8953f) < 1e-6f, "the new matrix is uploaded: c5 %.4f", g.set.y);
|
||||
CHECK(after.matched, "the front end's draws are recognised again");
|
||||
CHECK(!after.labelShows, "the label stays hidden: clip y %.3f..%.3f, clipped above the band 0.8953", after.labelLo,
|
||||
after.labelHi);
|
||||
CHECK(g.set.uploads == 2, "one upload per matrix: %d", g.set.uploads);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The model reproduces the report: the old matrix stays, the
|
||||
// draws aren't recognised, and the label lands in the screen's
|
||||
// top rows (clip 0.959..1.009, its top cut off).
|
||||
CHECK(std::fabs(g.set.y - kBaseY * 0.9235f) < 1e-6f, "as v0.1.3 the old matrix stays: c5 %.4f", g.set.y);
|
||||
CHECK(!after.matched && after.labelShows && after.labelLo > 0.95f && after.labelLo < 0.97f,
|
||||
"as v0.1.3 the label shows at clip %.3f..%.3f", after.labelLo, after.labelHi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Menus and races: a 3D view goes through the cache every frame, so the
|
||||
// front end's call is never a repeat and a new matrix arrives either way.
|
||||
void MenusRefreshAnyway()
|
||||
{
|
||||
for (bool resend : { true, false })
|
||||
{
|
||||
Game g;
|
||||
g.resend = resend;
|
||||
for (int f = 0; f < 10; f++)
|
||||
g.Frame(1.0f, 0.9235f, true);
|
||||
for (int f = 0; f < 10; f++)
|
||||
g.Frame(1.0f, 0.8953f, true);
|
||||
CHECK(Render(g, 0.8953f).matched, "%s: menus get the new matrix", resend ? "fixed" : "as v0.1.3");
|
||||
}
|
||||
}
|
||||
|
||||
// A session that never changes shape: the front end's matrix is sent
|
||||
// once, as before (the cache still skips every repeat).
|
||||
void NoChangeNoResend()
|
||||
{
|
||||
Game g;
|
||||
for (int f = 0; f < 600; f++)
|
||||
g.Frame(1.0f, 0.8953f);
|
||||
CHECK(g.set.uploads == 1, "one upload in 600 frames: %d", g.set.uploads);
|
||||
Game wide;
|
||||
for (int f = 0; f < 600; f++)
|
||||
wide.Frame(0.744f, 1.0f);
|
||||
CHECK(wide.set.uploads == 1 && Render(wide, 1.0f).matched, "Hor+ the same: %d", wide.set.uploads);
|
||||
}
|
||||
|
||||
// The other way: from a tall window to a 16:9 one mid-movie (Vert+ off,
|
||||
// so nothing is clipped): the old narrowed matrix would put the label at
|
||||
// clip 0.930..0.978, on a 16:9 screen; the new one puts it past the
|
||||
// frame's top, where a 360 has it.
|
||||
void TallTo169()
|
||||
{
|
||||
for (bool resend : { true, false })
|
||||
{
|
||||
Game g;
|
||||
g.resend = resend;
|
||||
for (int f = 0; f < 30; f++)
|
||||
g.Frame(1.0f, 0.8953f);
|
||||
for (int f = 0; f < 30; f++)
|
||||
g.Frame(1.0f, 1.0f);
|
||||
Outcome o = Render(g, 1.0f);
|
||||
if (resend)
|
||||
CHECK(!o.labelShows && o.labelLo > 1.0f, "fixed: the label past the frame (clip %.3f)", o.labelLo);
|
||||
else
|
||||
CHECK(o.labelShows, "as v0.1.3 the label shows on the 16:9 screen (clip %.3f..%.3f)", o.labelLo, o.labelHi);
|
||||
}
|
||||
}
|
||||
|
||||
// Ultrawide to 16:9 mid-movie: the x scale alone changes (Hor+), and the
|
||||
// hook's x-only overload (a ppc/ from before Vert+) passes y 0.
|
||||
void HorPlusChange()
|
||||
{
|
||||
Game g;
|
||||
for (int f = 0; f < 10; f++)
|
||||
g.Frame(0.744f, 1.0f);
|
||||
for (int f = 0; f < 10; f++)
|
||||
g.Frame(1.0f, 1.0f);
|
||||
CHECK(std::fabs(g.set.x - kBaseX) < 1e-6f && Render(g, 1.0f).matched, "the 16:9 matrix is uploaded: c0 %.4f", g.set.x);
|
||||
MatrixTracker xOnly;
|
||||
CHECK(xOnly.Changed(kBaseX * 0.744f, 0.0f) && !xOnly.Changed(kBaseX * 0.744f, 0.0f) && xOnly.Changed(kBaseX, 0.0f),
|
||||
"the x-only hook's changes");
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
Tracker();
|
||||
GreenButtonMidMovie();
|
||||
MenusRefreshAnyway();
|
||||
NoChangeNoResend();
|
||||
TallTo169();
|
||||
HorPlusChange();
|
||||
if (s_failures)
|
||||
{
|
||||
fprintf(stderr, "%d failure(s)\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("front_end_matrix_test: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Graphics > Upscaling's FSR 1: which pass draws a frame
|
||||
// (video/upscale.h, on video::FitPicture's pictures), Sharpening as RCAS's
|
||||
// strength, the shaders' constants against AMD's formulas, and their GLSL
|
||||
// (video/fsr1.cpp): AMD's two headers compiled in byte for byte, one main
|
||||
// in RCAS's (the tone variant renames it), and the band rules of
|
||||
// video/band_filter.h in their code.
|
||||
// Arguments: runtime/thirdparty/fsr1 (the vendored headers), runtime/video
|
||||
// (presenter.cpp and fsr1.cpp read, and ../user/settings.cpp: FSR 1 isn't
|
||||
// offered on iPhone and iPad, and no FSR 1 shader is compiled unless a
|
||||
// frame's pass is FSR 1), and a folder the GLSL is written to
|
||||
// (fsr1_glsl_check, when glslangValidator is installed, compiles it).
|
||||
//
|
||||
// Build by hand (from the repo root, after a CMake configure has written
|
||||
// build/main/runtime/generated/fsr1_headers.inc):
|
||||
// c++ -std=c++20 -O2 -UNDEBUG -I runtime -I build/main/runtime/generated tests/fsr1_test.cpp \
|
||||
// runtime/video/fsr1.cpp runtime/video/picture_fit.cpp -o build/fsr1_test
|
||||
// build/fsr1_test runtime/thirdparty/fsr1 runtime/video build/fsr1_glsl
|
||||
#include <user/settings.h>
|
||||
#include <video/fsr1.h>
|
||||
#include <video/picture_fit.h>
|
||||
#include <video/upscale.h>
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failures = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
s_failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fprintf(stderr, "\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using video::upscale::Choose;
|
||||
using video::upscale::Mode;
|
||||
using video::upscale::Pass;
|
||||
|
||||
std::string Read(const std::filesystem::path& path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::stringstream text;
|
||||
text << in.rdbuf();
|
||||
return text.str();
|
||||
}
|
||||
|
||||
float Float(uint32_t bits)
|
||||
{
|
||||
float f;
|
||||
memcpy(&f, &bits, 4);
|
||||
return f;
|
||||
}
|
||||
|
||||
size_t Count(const std::string& text, const std::string& what)
|
||||
{
|
||||
size_t n = 0;
|
||||
for (size_t at = text.find(what); at != std::string::npos; at = text.find(what, at + 1))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// The pass for a 1280x720-shaped frame of imageW x imageH on a screen,
|
||||
// where the presenter puts it (Aspect Ratio Auto, the hooks present).
|
||||
Pass PassOn(Mode mode, uint32_t imageW, uint32_t imageH, uint32_t screenW, uint32_t screenH, int32_t* dw = nullptr, int32_t* dh = nullptr)
|
||||
{
|
||||
const video::PictureFit fit = video::FitPicture(screenW, screenH, 1280.0f, 720.0f, true, true);
|
||||
if (dw)
|
||||
*dw = fit.width, *dh = fit.height;
|
||||
return Choose(int32_t(mode), imageW, imageH, fit.width, fit.height);
|
||||
}
|
||||
|
||||
void PassSelection()
|
||||
{
|
||||
static_assert(int32_t(Mode::Bicubic) == int32_t(settings::ScalingMode::Bicubic));
|
||||
static_assert(int32_t(Mode::Linear) == int32_t(settings::ScalingMode::Linear));
|
||||
static_assert(int32_t(Mode::Fsr1) == int32_t(settings::ScalingMode::Fsr1));
|
||||
struct Case
|
||||
{
|
||||
const char* what;
|
||||
uint32_t imageW, imageH, screenW, screenH;
|
||||
Pass fsr; // with Upscaling on FSR 1
|
||||
};
|
||||
const Case cases[] = {
|
||||
{ "1x on 1080p", 1280, 720, 1920, 1080, Pass::Fsr1 },
|
||||
{ "1x on 1440p", 1280, 720, 2560, 1440, Pass::Fsr1 },
|
||||
{ "1x on a MacBook Pro 14 (Vert+)", 1280, 720, 3024, 1898, Pass::Fsr1 },
|
||||
{ "1.5x on 4K", 1920, 1080, 3840, 2160, Pass::Fsr1 },
|
||||
{ "1x on the Steam Deck (720 rows stretched to 800)", 1280, 720, 1280, 800, Pass::Fsr1 },
|
||||
{ "2x on a MacBook Pro 14 (Auto on an M1 Pro)", 2560, 1440, 3024, 1898, Pass::Fsr1 },
|
||||
{ "1x on an ultrawide (Hor+)", 1280, 720, 3440, 1440, Pass::Fsr1 },
|
||||
{ "1x in a 1280x720 window (1:1)", 1280, 720, 1280, 720, Pass::Bicubic },
|
||||
{ "2x on 1080p (shrunk)", 2560, 1440, 1920, 1080, Pass::Bicubic },
|
||||
{ "2x on 1440p (1:1)", 2560, 1440, 2560, 1440, Pass::Bicubic },
|
||||
{ "3x2 on an ultrawide (columns shrunk)", 3840, 1440, 3440, 1440, Pass::Bicubic },
|
||||
{ "1x in a small window (shrunk)", 1280, 720, 960, 540, Pass::Bicubic },
|
||||
{ "1x2 on 1080p (columns enlarged, rows shrunk)", 1280, 1440, 1920, 1080, Pass::Bicubic },
|
||||
};
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
int32_t dw = 0, dh = 0;
|
||||
const Pass fsr = PassOn(Mode::Fsr1, c.imageW, c.imageH, c.screenW, c.screenH, &dw, &dh);
|
||||
CHECK(fsr == c.fsr, "%s (%ux%u -> %dx%d): FSR 1 setting gives %s, want %s", c.what, c.imageW, c.imageH, dw, dh,
|
||||
video::upscale::PassName(fsr), video::upscale::PassName(c.fsr));
|
||||
// The other settings draw as they always did, enlarged or not.
|
||||
CHECK(PassOn(Mode::Bicubic, c.imageW, c.imageH, c.screenW, c.screenH) == Pass::Bicubic, "%s: bicubic", c.what);
|
||||
CHECK(PassOn(Mode::Linear, c.imageW, c.imageH, c.screenW, c.screenH) == Pass::Linear, "%s: bilinear", c.what);
|
||||
printf(" %-50s %4ux%-4u -> %4dx%-4d %s\n", c.what, c.imageW, c.imageH, dw, dh, video::upscale::PassName(fsr));
|
||||
}
|
||||
// No picture, or a value the menu never stores: bicubic.
|
||||
CHECK(Choose(int32_t(Mode::Fsr1), 0, 0, 1920, 1080) == Pass::Bicubic, "no frame");
|
||||
CHECK(Choose(int32_t(Mode::Fsr1), 1280, 720, 0, 0) == Pass::Bicubic, "no picture");
|
||||
CHECK(Choose(7, 1280, 720, 1920, 1080) == Pass::Bicubic, "an unknown mode");
|
||||
// Enlarged along one axis by a single pixel counts; along neither doesn't.
|
||||
CHECK(video::upscale::Enlarged(1280, 720, 1281, 720) && !video::upscale::Enlarged(1280, 720, 1280, 720), "one pixel");
|
||||
}
|
||||
|
||||
void Sharpness()
|
||||
{
|
||||
using video::upscale::RcasAmount;
|
||||
using video::upscale::RcasStops;
|
||||
CHECK(RcasAmount(0.0f) == 0.0f && std::isinf(RcasStops(0.0f)), "0 turns RCAS off");
|
||||
CHECK(RcasAmount(-1.0f) == 0.0f && RcasAmount(NAN) == 0.0f, "below 0, NaN: off");
|
||||
CHECK(RcasAmount(1.0f) == 1.0f && RcasStops(1.0f) == 0.0f, "1 is RCAS's maximum (0 stops)");
|
||||
CHECK(RcasAmount(2.0f) == 1.0f, "above 1: the maximum");
|
||||
CHECK(std::fabs(RcasStops(0.5f) - 1.0f) < 1e-6f, "0.5: one stop below");
|
||||
float last = -1.0f;
|
||||
for (int i = 0; i <= 20; i++)
|
||||
{
|
||||
const float a = RcasAmount(float(i) / 20.0f);
|
||||
CHECK(a > last || (i == 0 && a == 0.0f), "monotonic at %d/20", i);
|
||||
last = a;
|
||||
}
|
||||
// What RCAS gets: FsrRcasCon's con.x is the linear factor 2^-stops.
|
||||
for (float s : { 0.05f, 0.3f, 0.5f, 1.0f })
|
||||
{
|
||||
const auto con = video::fsr1::RcasConstants(RcasStops(s));
|
||||
CHECK(std::fabs(Float(con[0]) - RcasAmount(s)) < 1e-5f, "Sharpening %.2f: con.x %.6f, want %.6f", s, Float(con[0]), RcasAmount(s));
|
||||
}
|
||||
const auto off = video::fsr1::RcasConstants(RcasStops(0.0f));
|
||||
CHECK(off[0] == 0 && off[1] == 0, "off: con 0 (the shader copies EASU's picture)");
|
||||
}
|
||||
|
||||
void EasuConstants()
|
||||
{
|
||||
// FsrEasuCon's formulas (ffx_fsr1.h), for the image's whole size.
|
||||
const std::array<uint32_t, 4> sizes[] = { { 1280, 720, 1920, 1080 }, { 1280, 720, 1280, 800 }, { 1920, 1080, 3840, 2160 },
|
||||
{ 1280, 720, 3024, 1898 } };
|
||||
for (auto [sw, sh, dw, dh] : sizes)
|
||||
{
|
||||
const auto c = video::fsr1::EasuConstants(sw, sh, dw, dh);
|
||||
const float x = float(sw) / float(dw), y = float(sh) / float(dh);
|
||||
CHECK(std::fabs(Float(c[0]) - x) < 1e-6f && std::fabs(Float(c[1]) - y) < 1e-6f, "%ux%u -> %ux%u: con0.xy", sw, sh, dw, dh);
|
||||
CHECK(std::fabs(Float(c[2]) - (0.5f * x - 0.5f)) < 1e-6f && std::fabs(Float(c[3]) - (0.5f * y - 0.5f)) < 1e-6f, "con0.zw");
|
||||
CHECK(Float(c[4]) == 1.0f / float(sw) && Float(c[5]) == 1.0f / float(sh), "con1.xy");
|
||||
CHECK(Float(c[6]) == 1.0f / float(sw) && Float(c[7]) == -1.0f / float(sh), "con1.zw");
|
||||
CHECK(Float(c[8]) == -1.0f / float(sw) && Float(c[9]) == 2.0f / float(sh), "con2.xy");
|
||||
CHECK(Float(c[10]) == 1.0f / float(sw) && Float(c[11]) == 2.0f / float(sh), "con2.zw");
|
||||
CHECK(Float(c[12]) == 0.0f && Float(c[13]) == 4.0f / float(sh) && c[14] == 0 && c[15] == 0, "con3");
|
||||
}
|
||||
}
|
||||
|
||||
void Glsl(const std::filesystem::path& fsrDir, const std::filesystem::path& videoDir, const std::filesystem::path& out)
|
||||
{
|
||||
const std::string easu = video::fsr1::EasuGlsl(), rcas = video::fsr1::RcasGlsl();
|
||||
// AMD's headers inside, byte for byte as vendored (unmodified).
|
||||
size_t halfInHeaders = 0;
|
||||
for (const char* name : { "ffx_a.h", "ffx_fsr1.h" })
|
||||
{
|
||||
const std::string header = Read(fsrDir / name);
|
||||
CHECK(header.size() > 10000, "%s read (%zu bytes)", name, header.size());
|
||||
CHECK(easu.find(header) != std::string::npos && rcas.find(header) != std::string::npos, "%s compiled in unchanged", name);
|
||||
halfInHeaders += Count(header, "#define A_HALF"); // their usage examples, in comments
|
||||
}
|
||||
CHECK(Count(easu, "#define FSR_EASU_F 1") == 1 && Count(rcas, "#define FSR_RCAS_F 1") == 1, "the 32-bit paths");
|
||||
CHECK(Count(easu, "#define A_HALF") == halfInHeaders && Count(rcas, "#define A_HALF") == halfInHeaders,
|
||||
"no 16-bit path of ours (MoltenVK, Intel): %zu, %zu, %zu in the headers' comments", Count(easu, "#define A_HALF"),
|
||||
Count(rcas, "#define A_HALF"), halfInHeaders);
|
||||
CHECK(Count(rcas, "void main()") == 1, "RCAS has one main (the tone variant renames it)");
|
||||
// band_filter.h's rules, as the GLSL writes them.
|
||||
CHECK(easu.find("vec2 pos = (vec2(ip) + 0.5) * vec2(p.srcSize) / vec2(p.outSize);") != std::string::npos, "EASU: SourceOf");
|
||||
CHECK(easu.find("return t < float(lo) ? ivec2(0, lo) : t < float(hi) ? ivec2(lo, hi) : ivec2(hi, size);") != std::string::npos, "EASU: SpanOf");
|
||||
CHECK(easu.find("texelFetch(src, clamp(t, spanLo, spanHi), 0)") != std::string::npos, "EASU: ClampTexel");
|
||||
CHECK(easu.find("p.bandOn == 0u ? textureGather(src, q, 0) : quadIn(q, 0)") != std::string::npos, "EASU: gathers without a band");
|
||||
CHECK(rcas.find("float t = (float(i) + 0.5) * float(srcSize) / float(outSize);") != std::string::npos, "RCAS: SourceOf");
|
||||
CHECK(rcas.find("return t < float(lo) ? 0 : t < float(hi) ? 1 : 2;") != std::string::npos, "RCAS: SpanIndex");
|
||||
CHECK(Count(rcas, "!= centreSpan.") == 2 && rcas.find("t.x = centre.x;") != std::string::npos && rcas.find("t.y = centre.y;") != std::string::npos,
|
||||
"RCAS: RcasNeighbour on both axes");
|
||||
// The presenter: the pass from Choose, RCAS's tone variant from ToneVariant, EASU before the swapchain's pass.
|
||||
const std::string presenter = Read(videoDir / "presenter.cpp");
|
||||
CHECK(presenter.find("upscale::Choose(settings::GetInt(settings::Id::Scaling), w, h, dw, dh)") != std::string::npos, "ChoosePass");
|
||||
CHECK(presenter.find("ToneVariant(fsr1::RcasGlsl().c_str())") != std::string::npos, "RCAS's tone variant");
|
||||
const size_t easuAt = presenter.find("RecordEasu(cmd, scaleInput, w, h, dw, dh, band, bandOn);");
|
||||
const size_t passAt = presenter.find("color.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // black bars");
|
||||
CHECK(easuAt != std::string::npos && passAt != std::string::npos && easuAt < passAt, "EASU before the swapchain image's pass");
|
||||
// No FSR 1 shader or pipeline unless a frame's pass is FSR 1: the
|
||||
// GLSL is compiled only in EnsureFsr and RcasPipeline, and every
|
||||
// EnsureFsr() call is behind "pass == upscale::Pass::Fsr1".
|
||||
const size_t ensureAt = presenter.find("bool EnsureFsr()"), imageAt = presenter.find("void EnsureFsrImage(");
|
||||
CHECK(ensureAt != std::string::npos && imageAt != std::string::npos && ensureAt < imageAt, "EnsureFsr, RcasPipeline, EnsureFsrImage in order");
|
||||
size_t glslAt = 0, glslCount = 0;
|
||||
while ((glslAt = presenter.find("fsr1::", glslAt)) != std::string::npos)
|
||||
{
|
||||
if (presenter.compare(glslAt, 15, "fsr1::EasuGlsl(") == 0 || presenter.compare(glslAt, 15, "fsr1::RcasGlsl(") == 0)
|
||||
{
|
||||
glslCount++;
|
||||
CHECK(glslAt > ensureAt && glslAt < imageAt, "FSR 1 GLSL compiled outside EnsureFsr/RcasPipeline (offset %zu)", glslAt);
|
||||
}
|
||||
glslAt += 6;
|
||||
}
|
||||
CHECK(glslCount == 3, "FSR 1 GLSL compiled in 3 places (EASU, RCAS, RCAS's tone variant): %zu", glslCount);
|
||||
size_t calls = 0;
|
||||
for (size_t callAt = presenter.find("EnsureFsr()"); callAt != std::string::npos; callAt = presenter.find("EnsureFsr()", callAt + 11))
|
||||
{
|
||||
if (callAt == ensureAt + 5)
|
||||
continue; // the definition
|
||||
calls++;
|
||||
const size_t lineAt = presenter.rfind('\n', callAt) + 1;
|
||||
CHECK(presenter.substr(lineAt, callAt - lineAt).find("pass == upscale::Pass::Fsr1 && ") != std::string::npos,
|
||||
"EnsureFsr() called without the FSR 1 pass: %s", presenter.substr(lineAt, presenter.find('\n', callAt) - lineAt).c_str());
|
||||
}
|
||||
CHECK(calls == 2, "EnsureFsr() called from ChoosePass and NFSMW_UPSCALE_COMPARE only: %zu", calls);
|
||||
// iPhone and iPad: no FSR 1 in the menu (user/settings.cpp: its entry
|
||||
// compiled out there, so "fsr1" reads as unknown = bicubic).
|
||||
const std::string settingsCpp = Read(videoDir / ".." / "user" / "settings.cpp");
|
||||
const size_t entryAt = settingsCpp.find("{ int32_t(ScalingMode::Fsr1), \"fsr1\", \"FSR 1\" },");
|
||||
const size_t ifAt = settingsCpp.rfind("#if !(defined(__APPLE__) && TARGET_OS_IOS)", entryAt);
|
||||
const size_t endifAt = settingsCpp.find("#endif", ifAt == std::string::npos ? 0 : ifAt);
|
||||
CHECK(entryAt != std::string::npos && ifAt != std::string::npos && endifAt > entryAt
|
||||
&& settingsCpp.substr(ifAt, entryAt - ifAt).find("#endif") == std::string::npos,
|
||||
"the FSR 1 Upscaling entry is compiled out on iPhone and iPad");
|
||||
// For fsr1_glsl_check: the shaders as glslang gets them (the tone
|
||||
// variant as ToneVariant makes it).
|
||||
std::filesystem::create_directories(out);
|
||||
std::ofstream(out / "easu.comp") << easu;
|
||||
std::ofstream(out / "rcas.frag") << rcas;
|
||||
std::string tone = rcas;
|
||||
tone.replace(tone.find("void main()"), 11, "void scaleMain()");
|
||||
tone += "\nlayout(set = 0, binding = 1) uniform sampler2D toneTable;\nvoid main()\n{\n scaleMain();\n"
|
||||
" color = vec4(textureLod(toneTable, vec2(color.r, 0.5), 0.0).r, color.gb, color.a);\n}\n";
|
||||
std::ofstream(out / "rcas_tone.frag") << tone;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
printf("the pass with Upscaling on FSR 1:\n");
|
||||
PassSelection();
|
||||
Sharpness();
|
||||
EasuConstants();
|
||||
if (argc >= 4)
|
||||
Glsl(argv[1], argv[2], argv[3]);
|
||||
else
|
||||
printf("(no folders given: the GLSL checks skipped)\n");
|
||||
if (s_failures)
|
||||
{
|
||||
printf("%d check(s) FAILED\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Display > Fullscreen against the window's own state
|
||||
// (video/fullscreen_policy.h): a change the system makes (the green button,
|
||||
// Window > Enter Full Screen, a window manager, a macOS fullscreen Space that
|
||||
// fails to open at launch) never changes the saved setting; the menu does,
|
||||
// and puts the window in that state. The optional argument is
|
||||
// runtime/video/presenter.cpp: its window events must not write the setting.
|
||||
//
|
||||
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/fullscreen_policy_test.cpp
|
||||
#include <video/fullscreen_policy.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
using video::FullscreenPolicy;
|
||||
using Change = FullscreenPolicy::Change;
|
||||
|
||||
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)
|
||||
|
||||
// One session as the presenter runs it: settings.toml's value (what a
|
||||
// save writes and the next launch reads), the policy, and SDL's window.
|
||||
struct Session
|
||||
{
|
||||
bool saved; // video.fullscreen in settings.toml
|
||||
bool locked = false;
|
||||
FullscreenPolicy policy;
|
||||
bool sdlWindow = false; // what SDL has the window at
|
||||
int requests = 0; // SDL_SetWindowFullscreen calls
|
||||
int systemLines = 0; // "[video] fullscreen ... by the system" lines
|
||||
|
||||
explicit Session(bool fullscreen, bool lockedScreen = false) : saved(fullscreen), locked(lockedScreen)
|
||||
{
|
||||
sdlWindow = policy.Launch(saved, locked); // SDL_CreateWindow(... SDL_WINDOW_FULLSCREEN)
|
||||
Event(sdlWindow); // SDL reports the window it made
|
||||
}
|
||||
// SDL_EVENT_WINDOW_ENTER/LEAVE_FULLSCREEN: the presenter's handler.
|
||||
void Event(bool fullscreen)
|
||||
{
|
||||
sdlWindow = fullscreen;
|
||||
if (policy.WindowIs(fullscreen) == Change::System)
|
||||
systemLines++;
|
||||
}
|
||||
// The system changes the window (the green button): SDL then reports it.
|
||||
void System(bool fullscreen) { Event(fullscreen); }
|
||||
// The menu's Fullscreen row (or Reset page): the setting changes, the
|
||||
// presenter's ApplySettings asks SDL, SDL reports.
|
||||
void Menu(bool fullscreen)
|
||||
{
|
||||
saved = fullscreen;
|
||||
if (std::optional<bool> want = policy.SettingIs(saved))
|
||||
{
|
||||
requests++;
|
||||
Event(*want);
|
||||
}
|
||||
}
|
||||
// The next launch reads what this one left in settings.toml.
|
||||
Session Next() const { return Session(saved, locked); }
|
||||
};
|
||||
|
||||
// Start Fullscreen Off, take the green button, quit: the file still says
|
||||
// off and the next launch is windowed (v0.1.3 saved On here).
|
||||
void GreenButtonOn()
|
||||
{
|
||||
Session s(false);
|
||||
s.System(true);
|
||||
CHECK(!s.saved && s.policy.Window() && s.policy.WindowDiffers(), "the window is fullscreen for now, the setting stays Off");
|
||||
CHECK(s.systemLines == 1, "one line for the system's change: %d", s.systemLines);
|
||||
Session next = s.Next();
|
||||
CHECK(!next.sdlWindow && !next.policy.Window(), "the next launch is windowed");
|
||||
}
|
||||
|
||||
// Start Fullscreen On, leave it with the green button, quit: the next
|
||||
// launch is fullscreen again.
|
||||
void GreenButtonOff()
|
||||
{
|
||||
Session s(true);
|
||||
CHECK(s.sdlWindow && s.systemLines == 0, "fullscreen from the launch, the game's own request");
|
||||
s.System(false);
|
||||
CHECK(s.saved && !s.policy.Window() && s.policy.WindowDiffers(), "windowed for now, the setting stays On");
|
||||
CHECK(s.Next().sdlWindow, "the next launch is fullscreen");
|
||||
}
|
||||
|
||||
// A macOS fullscreen Space that fails to open at launch: SDL reports the
|
||||
// window leaving fullscreen (SDL_cocoawindow.m windowDidExitFullScreen
|
||||
// before windowDidFailToEnterFullScreen). It must not turn the setting off.
|
||||
void FailedSpaceAtLaunch()
|
||||
{
|
||||
Session s(true);
|
||||
s.Event(false);
|
||||
CHECK(s.saved && s.systemLines == 1, "the setting stays On; one line");
|
||||
CHECK(s.Next().sdlWindow, "the next launch tries fullscreen again");
|
||||
}
|
||||
|
||||
// The menu saves, both ways, and moves the window.
|
||||
void MenuSaves()
|
||||
{
|
||||
Session s(false);
|
||||
s.Menu(true);
|
||||
CHECK(s.saved && s.sdlWindow && s.requests == 1 && s.systemLines == 0, "On: saved, the window goes fullscreen");
|
||||
CHECK(!s.policy.WindowDiffers(), "row and window agree");
|
||||
s.Event(true); // SDL may report it again when the Space settles
|
||||
CHECK(s.systemLines == 0, "a repeat of the requested state is not the system's");
|
||||
s.Menu(false);
|
||||
CHECK(!s.saved && !s.sdlWindow && s.requests == 2 && s.systemLines == 0, "Off: saved, the window leaves fullscreen");
|
||||
CHECK(!s.Next().sdlWindow, "the next launch follows the menu");
|
||||
}
|
||||
|
||||
// The menu while the system has the window the other way: the row shows
|
||||
// the saved choice; choosing the state the window is in already saves it
|
||||
// and moves nothing; choosing again moves the window.
|
||||
void MenuAfterSystemChange()
|
||||
{
|
||||
Session s(false);
|
||||
s.System(true); // the green button
|
||||
s.Menu(true);
|
||||
CHECK(s.saved && s.sdlWindow && s.requests == 0 && !s.policy.WindowDiffers(), "On: saved; the window was fullscreen already");
|
||||
s.Menu(false);
|
||||
CHECK(!s.saved && !s.sdlWindow && s.requests == 1, "Off: the window leaves fullscreen");
|
||||
|
||||
Session t(true);
|
||||
t.System(false); // the green button out of fullscreen
|
||||
t.Menu(false);
|
||||
CHECK(!t.saved && !t.sdlWindow && t.requests == 0 && !t.policy.WindowDiffers(), "Off: saved; the window was windowed already");
|
||||
CHECK(!t.Next().sdlWindow, "the next launch is windowed");
|
||||
}
|
||||
|
||||
// SteamOS Game Mode / iOS: always fullscreen, the setting kept as it is.
|
||||
void Locked()
|
||||
{
|
||||
Session s(false, true);
|
||||
CHECK(s.sdlWindow, "fullscreen while locked");
|
||||
s.Menu(true);
|
||||
s.Menu(false);
|
||||
CHECK(s.requests == 0 && s.sdlWindow && !s.policy.WindowDiffers(), "the menu doesn't move a locked window, and no note");
|
||||
s.System(false);
|
||||
CHECK(!s.policy.WindowDiffers(), "no note while locked");
|
||||
}
|
||||
|
||||
// presenter.cpp: the window events go through the policy and never write
|
||||
// the setting (v0.1.3 did: settings::Set(settings::Id::Fullscreen, ...)).
|
||||
void Presenter(const char* path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
const std::string src = ss.str();
|
||||
CHECK(!src.empty(), "can't read %s", path);
|
||||
CHECK(src.find("settings::Set(settings::Id::Fullscreen") == std::string::npos, "presenter.cpp writes the Fullscreen setting");
|
||||
const size_t handler = src.find("e.type == SDL_EVENT_WINDOW_ENTER_FULLSCREEN || e.type == SDL_EVENT_WINDOW_LEAVE_FULLSCREEN");
|
||||
CHECK(handler != std::string::npos, "the window's fullscreen events are handled");
|
||||
if (handler != std::string::npos)
|
||||
{
|
||||
const std::string body = src.substr(handler, src.find("\n }", handler) - handler);
|
||||
CHECK(body.find("s_fullscreen.WindowIs(") != std::string::npos && body.find("settings::Set") == std::string::npos,
|
||||
"the handler only tells the policy: %s", body.c_str());
|
||||
}
|
||||
CHECK(src.find("s_fullscreen.Launch(settings::GetBool(settings::Id::Fullscreen), FullscreenLocked())") != std::string::npos,
|
||||
"the window starts as the setting says");
|
||||
CHECK(src.find("s_fullscreen.SettingIs(settings::GetBool(settings::Id::Fullscreen))") != std::string::npos,
|
||||
"the setting moves the window");
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
GreenButtonOn();
|
||||
GreenButtonOff();
|
||||
FailedSpaceAtLaunch();
|
||||
MenuSaves();
|
||||
MenuAfterSystemChange();
|
||||
Locked();
|
||||
if (argc > 1)
|
||||
Presenter(argv[1]);
|
||||
else
|
||||
printf("(presenter.cpp not given: its checks skipped)\n");
|
||||
if (s_failures)
|
||||
{
|
||||
fprintf(stderr, "%d failure(s)\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("fullscreen_policy_test: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Tests for hid/key_map.h: the 24 keyboard-driven controls, their two keys each, and how held keys
|
||||
// become buttons, triggers and stick axes. Header only; from the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/key_map_test.cpp -o build/key_map_test && build/key_map_test
|
||||
#include <hid/key_map.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <initializer_list>
|
||||
#include <set>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
using namespace hid::keymap;
|
||||
|
||||
static Resolved Press(std::initializer_list<Control> held)
|
||||
{
|
||||
bool pressed[kControls] = {};
|
||||
for (Control c : held)
|
||||
pressed[size_t(c)] = true;
|
||||
return Resolve(pressed);
|
||||
}
|
||||
|
||||
static bool NoAxis(const Resolved& r)
|
||||
{
|
||||
for (size_t a = 0; a < size_t(Axis::Count); a++)
|
||||
if (r.axisSet[a] || r.axis[a] != 0.0f)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
// The table: every control once, in order, each with its own setting
|
||||
// among the Key options.
|
||||
CHECK(kControls == 24, "%zu controls", kControls);
|
||||
std::set<settings::Id> ids;
|
||||
for (size_t i = 0; i < kControls; i++)
|
||||
{
|
||||
CHECK(size_t(kEntries[i].control) == i, "entry %zu is its control", i);
|
||||
CHECK(ids.insert(kEntries[i].id).second && ids.insert(kEntries[i].id2).second, "entry %zu: its own two settings", i);
|
||||
CHECK(kEntries[i].id == settings::Id(size_t(settings::Id::KeyRightTrigger) + 2 * i), "entry %zu: in the options' order", i);
|
||||
CHECK(kEntries[i].id2 == settings::Id(size_t(kEntries[i].id) + 1), "entry %zu: its second key is the next option", i);
|
||||
CHECK(kEntries[i].id2 <= settings::Id::KeyRightStickClick2, "entry %zu: a Key option", i);
|
||||
if (kEntries[i].role == Role::Button)
|
||||
CHECK(kEntries[i].button != 0 && (kEntries[i].button & (kEntries[i].button - 1)) == 0, "entry %zu: one bit", i);
|
||||
else
|
||||
CHECK(kEntries[i].button == 0, "entry %zu: no bit", i);
|
||||
}
|
||||
CHECK(kEntries[size_t(Control::RightTrigger)].id == settings::Id::KeyRightTrigger &&
|
||||
kEntries[size_t(Control::RightStickClick)].id2 == settings::Id::KeyRightStickClick2, "first and last");
|
||||
// Either key's option finds its control.
|
||||
CHECK(&EntryOf(settings::Id::KeyRightTrigger) == &kEntries[0] && &EntryOf(settings::Id::KeyRightTrigger2) == &kEntries[0] &&
|
||||
&EntryOf(settings::Id::KeyLeftStickLeft2) == &kEntries[size_t(Control::LeftStickLeft)] &&
|
||||
&EntryOf(settings::Id::KeyRightStickClick2) == &kEntries[kControls - 1], "EntryOf");
|
||||
// The bits are XInput's (xbox.h's XAMINPUT_GAMEPAD_*; input.cpp asserts the same).
|
||||
CHECK(button::DpadUp == 0x0001 && button::DpadDown == 0x0002 && button::DpadLeft == 0x0004 && button::DpadRight == 0x0008 &&
|
||||
button::Start == 0x0010 && button::Back == 0x0020 && button::LeftThumb == 0x0040 && button::RightThumb == 0x0080 &&
|
||||
button::LeftShoulder == 0x0100 && button::RightShoulder == 0x0200 &&
|
||||
button::A == 0x1000 && button::B == 0x2000 && button::X == 0x4000 && button::Y == 0x8000, "XInput's bits");
|
||||
|
||||
// Nothing held: nothing.
|
||||
Resolved r = Press({});
|
||||
CHECK(r.buttons == 0 && !r.leftTrigger && !r.rightTrigger && NoAxis(r), "idle");
|
||||
|
||||
// A button key sets its bit and nothing else.
|
||||
r = Press({ Control::A });
|
||||
CHECK(r.buttons == button::A && !r.leftTrigger && !r.rightTrigger && NoAxis(r), "A: 0x%04x", r.buttons);
|
||||
CHECK(Press({ Control::B }).buttons == button::B && Press({ Control::X }).buttons == button::X && Press({ Control::Y }).buttons == button::Y, "B X Y");
|
||||
CHECK(Press({ Control::LB }).buttons == button::LeftShoulder && Press({ Control::RB }).buttons == button::RightShoulder, "LB RB");
|
||||
CHECK(Press({ Control::DpadUp }).buttons == button::DpadUp && Press({ Control::DpadDown }).buttons == button::DpadDown &&
|
||||
Press({ Control::DpadLeft }).buttons == button::DpadLeft && Press({ Control::DpadRight }).buttons == button::DpadRight, "D-pad");
|
||||
CHECK(Press({ Control::Start }).buttons == button::Start && Press({ Control::Back }).buttons == button::Back, "Start Back");
|
||||
CHECK(Press({ Control::LeftStickClick }).buttons == button::LeftThumb && Press({ Control::RightStickClick }).buttons == button::RightThumb, "clicks");
|
||||
r = Press({ Control::DpadLeft, Control::Back, Control::X });
|
||||
CHECK(r.buttons == (button::DpadLeft | button::Back | button::X), "several: 0x%04x", r.buttons);
|
||||
|
||||
// A trigger key is that trigger at full.
|
||||
r = Press({ Control::RightTrigger });
|
||||
CHECK(r.rightTrigger && !r.leftTrigger && r.buttons == 0 && NoAxis(r), "right trigger");
|
||||
r = Press({ Control::LeftTrigger });
|
||||
CHECK(r.leftTrigger && !r.rightTrigger, "left trigger");
|
||||
|
||||
// An axis is the difference of its keys, set while either is down, so
|
||||
// both keys centre the stick and no key leaves the controller's value.
|
||||
const size_t LX = size_t(Axis::LX), LY = size_t(Axis::LY), RX = size_t(Axis::RX), RY = size_t(Axis::RY);
|
||||
r = Press({ Control::LeftStickRight });
|
||||
CHECK(r.axisSet[LX] && r.axis[LX] == 1.0f && !r.axisSet[LY] && !r.axisSet[RX] && !r.axisSet[RY] && r.buttons == 0, "right: +1");
|
||||
r = Press({ Control::LeftStickLeft });
|
||||
CHECK(r.axisSet[LX] && r.axis[LX] == -1.0f, "left: -1");
|
||||
r = Press({ Control::LeftStickLeft, Control::LeftStickRight });
|
||||
CHECK(r.axisSet[LX] && r.axis[LX] == 0.0f, "both: set, centred");
|
||||
r = Press({ Control::LeftStickUp });
|
||||
CHECK(r.axisSet[LY] && r.axis[LY] == 1.0f && !r.axisSet[LX], "up is positive");
|
||||
r = Press({ Control::LeftStickDown });
|
||||
CHECK(r.axisSet[LY] && r.axis[LY] == -1.0f, "down is negative");
|
||||
r = Press({ Control::RightStickUp });
|
||||
CHECK(r.axisSet[RY] && r.axis[RY] == 1.0f && !r.axisSet[RX] && !r.axisSet[LY], "right stick up");
|
||||
r = Press({ Control::RightStickDown });
|
||||
CHECK(r.axisSet[RY] && r.axis[RY] == -1.0f, "right stick down");
|
||||
r = Press({ Control::RightStickLeft });
|
||||
CHECK(r.axisSet[RX] && r.axis[RX] == -1.0f && !r.axisSet[RY], "right stick left");
|
||||
r = Press({ Control::RightStickRight });
|
||||
CHECK(r.axisSet[RX] && r.axis[RX] == 1.0f, "right stick right");
|
||||
|
||||
// One key on a button and an axis (the default Up): both fire.
|
||||
r = Press({ Control::DpadUp, Control::LeftStickUp });
|
||||
CHECK(r.buttons == button::DpadUp && r.axisSet[LY] && r.axis[LY] == 1.0f, "Up: D-pad and stick");
|
||||
|
||||
// Everything at once is still each thing.
|
||||
bool all[kControls];
|
||||
for (bool& b : all)
|
||||
b = true;
|
||||
r = Resolve(all);
|
||||
CHECK(r.buttons == 0xF3FF && r.leftTrigger && r.rightTrigger, "all buttons: 0x%04x", r.buttons);
|
||||
for (size_t a = 0; a < size_t(Axis::Count); a++)
|
||||
CHECK(r.axisSet[a] && r.axis[a] == 0.0f, "axis %zu: both keys, centred", a);
|
||||
|
||||
// Usable at compile time (constexpr), so the table is checked when it's built.
|
||||
constexpr bool kNone[kControls] = {};
|
||||
static_assert(Resolve(kNone).buttons == 0, "constexpr");
|
||||
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Tests for user/key_names.h: the keys a Controls row can hold and their
|
||||
// names in settings.toml. Links SDL3 to check every code against SDL's name
|
||||
// for that scancode (no SDL_Init: the names are SDL's built-in ones, before
|
||||
// a platform renames any). From the repo root, with the deps in build/deps:
|
||||
// clang++ -std=c++20 -O2 -Iruntime -Ibuild/deps/include tests/key_names_test.cpp -Lbuild/deps/lib -lSDL3 \
|
||||
// -Wl,-rpath,build/deps/lib -o build/key_names_test && build/key_names_test
|
||||
#include <user/key_names.h>
|
||||
|
||||
#include <SDL3/SDL_keyboard.h>
|
||||
#include <SDL3/SDL_scancode.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
using namespace settings::keynames;
|
||||
|
||||
int main()
|
||||
{
|
||||
CHECK(kKeyCount == 123, "%zu keys", kKeyCount);
|
||||
|
||||
// Tokens: lower-case letters, digits and underscores, each once. Codes:
|
||||
// scancodes, ascending, each once. And both ways round.
|
||||
std::set<std::string_view> tokens;
|
||||
uint16_t last = 0;
|
||||
for (const Key& k : kKeys)
|
||||
{
|
||||
bool wellFormed = k.token && k.token[0];
|
||||
for (const char* c = k.token; wellFormed && *c; c++)
|
||||
wellFormed = (*c >= 'a' && *c <= 'z') || (*c >= '0' && *c <= '9') || *c == '_';
|
||||
CHECK(wellFormed, "%s: a token is [a-z0-9_]+", k.token ? k.token : "(null)");
|
||||
CHECK(tokens.insert(k.token).second, "%s: once", k.token);
|
||||
CHECK(k.code > last, "%s (%d): codes ascend", k.token, k.code);
|
||||
last = k.code;
|
||||
CHECK(k.code != kNone && k.code != kUnknown && k.code < SDL_SCANCODE_COUNT, "%s: a scancode", k.token);
|
||||
CHECK(Token(k.code) == k.token, "%s: Token(code)", k.token);
|
||||
CHECK(Code(k.token) == k.code, "%s: Code(token)", k.token);
|
||||
CHECK(Known(k.code), "%s: Known", k.token);
|
||||
}
|
||||
CHECK(!tokens.count("none"), "\"none\" is not a row of the table");
|
||||
|
||||
// none, any case, and what isn't a name.
|
||||
CHECK(std::string_view(Token(kNone)) == "none" && Code("none") == kNone && Code("NONE") == kNone && Known(kNone), "none is 0");
|
||||
CHECK(Code("Left_Shift") == SDL_SCANCODE_LSHIFT && Code("W") == SDL_SCANCODE_W && Code("w") == SDL_SCANCODE_W, "any case");
|
||||
CHECK(Code("") == kUnknown && Code("Left Shift") == kUnknown && Code("left_shif") == kUnknown &&
|
||||
Code("left_shiftx") == kUnknown && Code("none ") == kUnknown, "not a token");
|
||||
CHECK(Token(1) == nullptr && Token(SDL_SCANCODE_LGUI) == nullptr && Token(kUnknown) == nullptr && Token(512) == nullptr, "not a key");
|
||||
CHECK(!Known(1) && !Known(SDL_SCANCODE_F1) && !Known(SDL_SCANCODE_LGUI) && !Known(SDL_SCANCODE_RGUI) && !Known(kUnknown),
|
||||
"F1, Cmd / Windows and nameless codes are out");
|
||||
CHECK(Code("f1") == kUnknown && Code("left_gui") == kUnknown && Code("right_gui") == kUnknown, "...by name too");
|
||||
CHECK(Code("escape") == SDL_SCANCODE_ESCAPE && Known(SDL_SCANCODE_ESCAPE), "Esc is a key (Back's default)");
|
||||
|
||||
// SDL agrees with every row: its name for the scancode, lowered with
|
||||
// spaces as underscores, is the token, bar the keys SDL names by their
|
||||
// symbol or runs together.
|
||||
const std::map<uint16_t, std::string_view> exceptions = {
|
||||
{ SDL_SCANCODE_MINUS, "minus" }, { SDL_SCANCODE_EQUALS, "equals" }, { SDL_SCANCODE_LEFTBRACKET, "left_bracket" },
|
||||
{ SDL_SCANCODE_RIGHTBRACKET, "right_bracket" }, { SDL_SCANCODE_BACKSLASH, "backslash" }, { SDL_SCANCODE_NONUSHASH, "non_us_hash" },
|
||||
{ SDL_SCANCODE_SEMICOLON, "semicolon" }, { SDL_SCANCODE_APOSTROPHE, "apostrophe" }, { SDL_SCANCODE_GRAVE, "grave" },
|
||||
{ SDL_SCANCODE_COMMA, "comma" }, { SDL_SCANCODE_PERIOD, "period" }, { SDL_SCANCODE_SLASH, "slash" },
|
||||
{ SDL_SCANCODE_CAPSLOCK, "caps_lock" }, { SDL_SCANCODE_PRINTSCREEN, "print_screen" }, { SDL_SCANCODE_SCROLLLOCK, "scroll_lock" },
|
||||
{ SDL_SCANCODE_PAGEUP, "page_up" }, { SDL_SCANCODE_PAGEDOWN, "page_down" }, { SDL_SCANCODE_NUMLOCKCLEAR, "num_lock" },
|
||||
{ SDL_SCANCODE_KP_DIVIDE, "keypad_divide" }, { SDL_SCANCODE_KP_MULTIPLY, "keypad_multiply" }, { SDL_SCANCODE_KP_MINUS, "keypad_minus" },
|
||||
{ SDL_SCANCODE_KP_PLUS, "keypad_plus" }, { SDL_SCANCODE_KP_PERIOD, "keypad_period" }, { SDL_SCANCODE_KP_EQUALS, "keypad_equals" },
|
||||
{ SDL_SCANCODE_KP_COMMA, "keypad_comma" }, { SDL_SCANCODE_NONUSBACKSLASH, "non_us_backslash" },
|
||||
};
|
||||
for (const Key& k : kKeys)
|
||||
{
|
||||
std::string expected;
|
||||
if (auto e = exceptions.find(k.code); e != exceptions.end())
|
||||
expected = e->second;
|
||||
else
|
||||
for (const char* c = SDL_GetScancodeName(SDL_Scancode(k.code)); *c; c++)
|
||||
expected += *c == ' ' ? '_' : char(std::tolower(uint8_t(*c)));
|
||||
CHECK(expected == k.token, "%d: SDL calls it \"%s\", the table \"%s\"", k.code, SDL_GetScancodeName(SDL_Scancode(k.code)), k.token);
|
||||
}
|
||||
|
||||
// Usable at compile time (the option table's defaults are checked when it's built).
|
||||
static_assert(Code("w") == 26 && Token(26)[0] == 'w' && Known(0) && !Known(227) && Code("None") == kNone, "constexpr");
|
||||
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
// Tests for GitHub #19, "rectangular borders in specific places in menus"
|
||||
// (gpu/edge_bars.h, and gpu/renderer.cpp using it), at every screen shape:
|
||||
// - the Showcase (the car lot's, customize shop's and Blacklist rival cars'
|
||||
// "Rotate / Zoom" viewer) darkens the 3D garage with one front-end quad,
|
||||
// centred, x +-0.970 and y +-0.958 of the 16:9 band, its 256x256 texture
|
||||
// clear in the middle and dark toward its edges (MatchVignette,
|
||||
// TexelOffset32, PlanVignette): it is drawn stretched to the screen's
|
||||
// edges;
|
||||
// - the Blacklist rival bio (SafeHouseRivalBio.fng) sets its text and
|
||||
// picture on a black panel, x +-0.976 and y +-0.985 (a black frame,
|
||||
// PlanFill): its copies now fill everything outside it, the band's thin
|
||||
// border too, at 16:9 as well (v0.1.5: only past the band).
|
||||
// Both stopped short of every screen edge: a thin border of what was behind
|
||||
// at 16:9, as on a real 360's own output (a period TV's overscan hid it);
|
||||
// under Vert+ the Showcase's garage above and below the band showed
|
||||
// undimmed too, a bright rectangular frame.
|
||||
// From the repo root:
|
||||
// clang++ -std=c++20 -O2 -UNDEBUG -Iruntime tests/menu_borders_test.cpp -o build/menu_borders_test
|
||||
// build/menu_borders_test runtime/gpu/renderer.cpp
|
||||
// Planted faults: the match cases run again under deliberately wrong
|
||||
// thresholds (each must fail a case), the texel and register checks are
|
||||
// broken one at a time (each must refuse the plan), the pixel oracles run on
|
||||
// wrong fills (each must fail), and the renderer checks run on copies of
|
||||
// renderer.cpp with the wiring cut (each must fail).
|
||||
#include <gpu/edge_bars.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <utility>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace gpu::bars;
|
||||
|
||||
static int g_failures = 0;
|
||||
static bool g_quiet = false;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; if (!g_quiet) { printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } } while (0)
|
||||
|
||||
// The screen shapes (view scale x, y), as video/picture_fit gives them, and
|
||||
// the host size each is drawn at (Auto internal resolution: 2x on the big
|
||||
// ones; posScale covers it).
|
||||
struct Shape
|
||||
{
|
||||
const char* name;
|
||||
float sx, sy;
|
||||
};
|
||||
static const Shape kShapes[] = {
|
||||
{ "1280x720 (16:9)", 1.0f, 1.0f },
|
||||
{ "1280x800 (Deck)", 1.0f, 0.9f },
|
||||
{ "3024x1898 (MacBook Pro 14)", 1.0f, 0.896207f },
|
||||
{ "3600x2338 less the notch (the report's MacBook, 3600x2260)", 1.0f, 0.896f },
|
||||
{ "1200x800 (3:2)", 1.0f, 0.84375f },
|
||||
{ "1024x768 (4:3)", 1.0f, 0.75f },
|
||||
{ "2732x2048 (iPad Pro)", 1.0f, 0.750366f },
|
||||
{ "2868x1320 (iPhone 18 Pro Max)", 0.818182f, 1.0f },
|
||||
{ "2560x1080 (21:9)", 0.75f, 1.0f },
|
||||
{ "3440x1440", 0.744186f, 1.0f },
|
||||
{ "2880x810 (32:9)", 0.5f, 1.0f },
|
||||
};
|
||||
|
||||
// The vignette as logged (NFSMW_LOG_FRONTEND, "inset candidate": band x
|
||||
// -0.970..0.970 y -0.958..0.958, t0 14749000 256x256 f6, blend 07060706,
|
||||
// colour 047F7F7F fading in to FE7F7F7F) at every shape.
|
||||
constexpr float kVigX = 0.970f, kVigY = 0.958f;
|
||||
constexpr uint32_t kVigColour = 0xFE7F7F7F;
|
||||
|
||||
struct Quad
|
||||
{
|
||||
Vertex v[4];
|
||||
};
|
||||
static Quad Rectangle(float bx0, float bx1, float by0, float by1, float sx, float sy, uint32_t argb)
|
||||
{
|
||||
const float x0 = bx0 * sx, x1 = bx1 * sx, y0 = by0 * sy, y1 = by1 * sy;
|
||||
return { { { x0, y0, argb }, { x1, y0, argb }, { x1, y1, argb }, { x0, y1, argb } } };
|
||||
}
|
||||
|
||||
// ---- The shape ----
|
||||
|
||||
static void Match(const char* what, const Quad& q, float sx, float sy, bool want, const VignetteRule& rule)
|
||||
{
|
||||
Box box{};
|
||||
const bool got = MatchVignette(q.v, sx, sy, rule, &box);
|
||||
CHECK(got == want, "%s at x %.4f y %.4f: %s, want %s", what, sx, sy, got ? "vignette" : "none", want ? "vignette" : "none");
|
||||
if (got && want)
|
||||
{
|
||||
const float x0 = std::min(q.v[0].x, q.v[2].x), x1 = std::max(q.v[0].x, q.v[2].x);
|
||||
const float y0 = std::min(q.v[0].y, q.v[2].y), y1 = std::max(q.v[0].y, q.v[2].y);
|
||||
CHECK(box.x0 == x0 && box.x1 == x1 && box.y0 == y0 && box.y1 == y1, "%s: box %.4f..%.4f %.4f..%.4f", what, box.x0, box.x1, box.y0,
|
||||
box.y1);
|
||||
}
|
||||
}
|
||||
|
||||
// Every shape case. Returns the failures it added (for the planted rules).
|
||||
static int VignetteCases(const VignetteRule& rule)
|
||||
{
|
||||
const int before = g_failures;
|
||||
for (const Shape& s : kShapes)
|
||||
{
|
||||
auto at = [&](float x0, float x1, float y0, float y1, uint32_t argb) { return Rectangle(x0, x1, y0, y1, s.sx, s.sy, argb); };
|
||||
Match("Showcase vignette", at(-kVigX, kVigX, -kVigY, kVigY, kVigColour), s.sx, s.sy, true, rule);
|
||||
Match("Showcase vignette fading in (alpha 04)", at(-kVigX, kVigX, -kVigY, kVigY, 0x047F7F7F), s.sx, s.sy, true, rule);
|
||||
Match("an overlay 0.92 of the band", at(-0.92f, 0.92f, -0.92f, 0.92f, kVigColour), s.sx, s.sy, true, rule);
|
||||
Match("an overlay 0.985 of the band", at(-0.985f, 0.985f, -0.985f, 0.985f, kVigColour), s.sx, s.sy, true, rule);
|
||||
// Its vertices in another order around it (y flipped).
|
||||
Quad flip = at(-kVigX, kVigX, kVigY, -kVigY, kVigColour);
|
||||
Match("Showcase vignette, y flipped", flip, s.sx, s.sy, true, rule);
|
||||
// What must keep its own shape (or isn't this overlay).
|
||||
Match("title key art / results picture", at(-1.104f, 1.104f, -0.981f, 0.981f, 0xFF7F7F7F), s.sx, s.sy, false, rule);
|
||||
Match("a picture over the whole band", at(-1.0f, 1.0f, -1.0f, 1.0f, 0xFF808080), s.sx, s.sy, false, rule);
|
||||
Match("an overlay 0.995 of the band", at(-0.995f, 0.995f, -0.995f, 0.995f, kVigColour), s.sx, s.sy, false, rule);
|
||||
Match("an overlay 0.88 of the band", at(-0.88f, 0.88f, -0.88f, 0.88f, kVigColour), s.sx, s.sy, false, rule);
|
||||
Match("vignette width, half the height", at(-kVigX, kVigX, -0.5f, 0.5f, kVigColour), s.sx, s.sy, false, rule);
|
||||
Match("vignette height, the whole band's width", at(-1.0f, 1.0f, -kVigY, kVigY, kVigColour), s.sx, s.sy, false, rule);
|
||||
Match("fade", at(-1.29f, 1.29f, -1.30f, 1.30f, 0xFF000000), s.sx, s.sy, false, rule);
|
||||
Match("vid cam noise overlay", at(-0.692f, 0.709f, -1.058f, 1.050f, 0x3C727272), s.sx, s.sy, false, rule);
|
||||
Match("vid cam dark overlay", at(-0.712f, 0.668f, -0.893f, 0.908f, 0x3C000000), s.sx, s.sy, false, rule);
|
||||
Match("pause dim centre", at(-0.53f, 0.57f, -1.91f, 1.91f, 0xC8000000), s.sx, s.sy, false, rule);
|
||||
Match("cam dialog box", at(-0.578f, 0.586f, -0.167f, 0.569f, 0xFD000000), s.sx, s.sy, false, rule);
|
||||
Match("vignette off-centre by 0.03 in x", at(-kVigX + 0.03f, kVigX + 0.03f, -kVigY, kVigY, kVigColour), s.sx, s.sy, false, rule);
|
||||
Match("vignette off-centre by 0.03 in y", at(-kVigX, kVigX, -kVigY + 0.03f, kVigY + 0.03f, kVigColour), s.sx, s.sy, false, rule);
|
||||
Quad grad = at(-kVigX, kVigX, -kVigY, kVigY, kVigColour);
|
||||
grad.v[1].argb = grad.v[2].argb = 0x007F7F7F; // a gradient, not one colour
|
||||
Match("a gradient of the vignette's shape", grad, s.sx, s.sy, false, rule);
|
||||
Quad bent = at(-kVigX, kVigX, -kVigY, kVigY, kVigColour);
|
||||
bent.v[2].x -= 0.05f;
|
||||
Match("a bent quad", bent, s.sx, s.sy, false, rule);
|
||||
}
|
||||
return g_failures - before;
|
||||
}
|
||||
|
||||
static void Planted(const char* what, const VignetteRule& wrong)
|
||||
{
|
||||
const int saved = g_failures;
|
||||
g_quiet = true;
|
||||
const int failed = VignetteCases(wrong);
|
||||
g_quiet = false;
|
||||
g_failures = saved;
|
||||
printf("planted %s: %d case(s) failed\n", what, failed);
|
||||
CHECK(failed > 0, "planted %s passed every vignette case", what);
|
||||
}
|
||||
|
||||
// ---- Texel addressing ----
|
||||
|
||||
static void TexelTests()
|
||||
{
|
||||
// Reference offsets: gpu/renderer.cpp's GLSL tiled2D, as a separate
|
||||
// Python port computed them (that port untiled the vignette's texture
|
||||
// dumped from guest memory into the clear-middle picture).
|
||||
struct Ref
|
||||
{
|
||||
uint32_t x, y, pitch, offset;
|
||||
};
|
||||
const Ref refs[] = { { 0, 0, 256, 0x0u }, { 1, 0, 256, 0x4u }, { 0, 1, 256, 0x10u }, { 7, 0, 256, 0x2Cu }, { 8, 0, 256, 0x40u },
|
||||
{ 0, 8, 256, 0x480u }, { 0, 16, 256, 0x800u }, { 31, 31, 256, 0xF7Cu }, { 32, 0, 256, 0x1000u }, { 0, 32, 256, 0x8000u },
|
||||
{ 128, 128, 256, 0x24000u }, { 255, 255, 256, 0x3FF7Cu }, { 5, 3, 256, 0x134u }, { 100, 200, 256, 0x334A0u },
|
||||
{ 13, 77, 128, 0x86F4u } };
|
||||
for (const Ref& r : refs)
|
||||
CHECK(TexelOffset32(r.x, r.y, r.pitch, true) == r.offset, "tiled (%u,%u) pitch %u: %X, want %X", r.x, r.y, r.pitch,
|
||||
TexelOffset32(r.x, r.y, r.pitch, true), r.offset);
|
||||
CHECK(TexelOffset32(5, 3, 256, false) == (3u * 256u + 5u) * 4u, "linear (5,3) pitch 256");
|
||||
// A 256x256 tiled texture: every texel at its own word, all of them
|
||||
// within the 256 KB.
|
||||
std::vector<uint8_t> seen(256 * 256, 0);
|
||||
int bad = 0;
|
||||
for (uint32_t y = 0; y < 256; y++)
|
||||
for (uint32_t x = 0; x < 256; x++)
|
||||
{
|
||||
const uint32_t o = TexelOffset32(x, y, 256, true);
|
||||
if (o % 4 || o / 4 >= seen.size() || seen[o / 4]++)
|
||||
bad++;
|
||||
}
|
||||
CHECK(bad == 0, "tiled 256x256: %d texel(s) outside, unaligned or on another's word", bad);
|
||||
printf("texel addressing: %zu reference offsets, 65536 texels each on its own word\n", std::size(refs));
|
||||
}
|
||||
|
||||
// ---- The plan on the draw as the renderer sees it ----
|
||||
|
||||
// The front end's matrix at a shape (edge_bars_test's): c0 0.69 x scaleX,
|
||||
// c5 0.92 x scaleY, the rest 0.
|
||||
struct Matrix
|
||||
{
|
||||
float c[8];
|
||||
};
|
||||
static Matrix FrontEndMatrix(float sx, float sy)
|
||||
{
|
||||
return { { 0.69f * sx, 0.0f, 0.0f, 0.0f, 0.0f, 0.92f * sy, 0.0f, 0.0f } };
|
||||
}
|
||||
|
||||
struct EPolys
|
||||
{
|
||||
uint32_t w[4][4];
|
||||
};
|
||||
static EPolys Polys(const Quad& q, const Matrix& m)
|
||||
{
|
||||
EPolys e{};
|
||||
for (int k = 0; k < 4; k++)
|
||||
{
|
||||
const float p[3] = { q.v[k].x / m.c[0], q.v[k].y / m.c[5], 0.0f };
|
||||
std::memcpy(e.w[k], p, sizeof(p));
|
||||
e.w[k][3] = q.v[k].argb;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// The front end's posScale (renderer.cpp: 2 sx / w, 2 sy / h, 2 ox / w - 1,
|
||||
// 2 oy / h - 1): viewport s(640,-360) o(640,360) in the 1280x2048 target, as
|
||||
// edge_bars_test's; `ox`, `oy` (D3D's half-pixel centres: 640.5, 360.5) and
|
||||
// `vy` (flipped: +360) move it. The screen is the target's top 720 rows.
|
||||
static void FrontEndPs(float ps[4], float ox = 640.0f, float oy = 360.0f, float vy = -360.0f)
|
||||
{
|
||||
ps[0] = 2.0f * 640.0f / 1280.0f;
|
||||
ps[1] = 2.0f * vy / 2048.0f;
|
||||
ps[2] = 2.0f * ox / 1280.0f - 1.0f;
|
||||
ps[3] = 2.0f * oy / 2048.0f - 1.0f;
|
||||
}
|
||||
|
||||
// The vignette's draw as dumped: blend 07060706, t0 256x256 format 6.
|
||||
static Draw ShowcaseDraw(const Matrix& m, float sx, float sy, const float ps[4], float hostW, float hostH)
|
||||
{
|
||||
return Draw{ .prim = 13,
|
||||
.count = 4,
|
||||
.c = m.c,
|
||||
.frontEndY = std::fabs(m.c[5]),
|
||||
.scaleX = sx,
|
||||
.scaleY = sy,
|
||||
.frontEndShader = true,
|
||||
.leftAlone = true,
|
||||
.occlusion = false,
|
||||
.vte = 0x43F,
|
||||
.blend0 = 0x07060706,
|
||||
.t0Sampled = true,
|
||||
.t0Width = 256,
|
||||
.t0Height = 256,
|
||||
.t0Format = 6,
|
||||
.posScale = { ps[0], ps[1], ps[2], ps[3] },
|
||||
.hostW = hostW,
|
||||
.hostH = hostH,
|
||||
.scissor = { 0, 0, int32_t(hostW), int32_t(hostH) } };
|
||||
}
|
||||
|
||||
// A 256x256 tiled k_8_8_8_8 texture in guest byte order: the vignette's
|
||||
// (black, alpha 0 inside an ellipse, rising to the edges; its dumped texture
|
||||
// is all 00000000 in the middle) or a picture's (opaque everywhere).
|
||||
struct Texture
|
||||
{
|
||||
uint32_t w = 256, h = 256;
|
||||
std::vector<uint32_t> words = std::vector<uint32_t>(256 * 256, 0);
|
||||
void Set(uint32_t x, uint32_t y, uint32_t v) { words[TexelOffset32(x, y, w, true) / 4] = v; }
|
||||
uint32_t Get(uint32_t x, uint32_t y) const { return words[TexelOffset32(x, y, w, true) / 4]; }
|
||||
};
|
||||
static Texture VignetteTexture()
|
||||
{
|
||||
Texture t;
|
||||
for (uint32_t y = 0; y < t.h; y++)
|
||||
for (uint32_t x = 0; x < t.w; x++)
|
||||
{
|
||||
const float u = (float(x) + 0.5f) / t.w * 2.0f - 1.0f, v = (float(y) + 0.5f) / t.h * 2.0f - 1.0f;
|
||||
const float r = std::sqrt(u * u * 1.1f + v * v);
|
||||
const uint32_t a = r < 0.8f ? 0u : uint32_t(std::min(255.0f, (r - 0.8f) * 600.0f));
|
||||
t.Set(x, y, a); // A in byte 0 as stored (big-endian ARGB word), RGB 0
|
||||
}
|
||||
return t;
|
||||
}
|
||||
static Texture PictureTexture()
|
||||
{
|
||||
Texture t;
|
||||
for (uint32_t y = 0; y < t.h; y++)
|
||||
for (uint32_t x = 0; x < t.w; x++)
|
||||
t.Set(x, y, 0xFF000000u | (x << 16) | (y << 8) | 0x40u);
|
||||
return t;
|
||||
}
|
||||
|
||||
static Plan RunPlan(const Draw& d, const EPolys& e, const Texture& t, uint32_t vertexLimit = 4, bool texelsReadable = true)
|
||||
{
|
||||
return PlanVignette(
|
||||
d,
|
||||
[&](uint32_t k, uint32_t j, uint32_t& word) {
|
||||
word = e.w[k & 3][j & 3];
|
||||
return k < vertexLimit;
|
||||
},
|
||||
[&](uint32_t x, uint32_t y, uint32_t& word) {
|
||||
if (!texelsReadable || x >= t.w || y >= t.h)
|
||||
return false;
|
||||
word = t.Get(x, y);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
static const char* Name(Fill f)
|
||||
{
|
||||
return f == Fill::Vignette ? "vignette" : f == Fill::None ? "none" : "another fill";
|
||||
}
|
||||
|
||||
// The stretched quad against pixel centres: every host pixel of the screen
|
||||
// (the target's top `screenH` rows) has its centre inside the quad as the
|
||||
// new posScale draws it, and the box lands at +-kFillEdge of the viewport
|
||||
// (clip space through the new posScale = the old one's at +-kFillEdge).
|
||||
static void CoverOracle(const char* what, const Plan& p, const float ps[4], float hostW, float hostH, float screenH)
|
||||
{
|
||||
const float* q = p.posScale;
|
||||
// The box's corners through the new posScale, and clip +-kFillEdge
|
||||
// through the draw's own: the same NDC.
|
||||
const float nx0 = p.box.x0 * q[0] + q[2], nx1 = p.box.x1 * q[0] + q[2];
|
||||
const float ny0 = p.box.y0 * q[1] + q[3], ny1 = p.box.y1 * q[1] + q[3];
|
||||
const float ex0 = -kFillEdge * ps[0] + ps[2], ex1 = kFillEdge * ps[0] + ps[2];
|
||||
const float ey0 = -kFillEdge * ps[1] + ps[3], ey1 = kFillEdge * ps[1] + ps[3];
|
||||
CHECK(std::fabs(nx0 - ex0) < 1e-4f && std::fabs(nx1 - ex1) < 1e-4f && std::fabs(ny0 - ey0) < 1e-4f && std::fabs(ny1 - ey1) < 1e-4f,
|
||||
"%s: the box lands at NDC x %.5f..%.5f y %.5f..%.5f, want x %.5f..%.5f y %.5f..%.5f", what, nx0, nx1, ny0, ny1, ex0, ex1, ey0, ey1);
|
||||
const float lx = std::min(nx0, nx1), hx = std::max(nx0, nx1), ly = std::min(ny0, ny1), hy = std::max(ny0, ny1);
|
||||
int outside = 0;
|
||||
for (int32_t y = 0; y < int32_t(screenH); y++)
|
||||
for (int32_t x = 0; x < int32_t(hostW); x++)
|
||||
{
|
||||
const float ndcX = (float(x) + 0.5f) / hostW * 2.0f - 1.0f, ndcY = (float(y) + 0.5f) / hostH * 2.0f - 1.0f;
|
||||
outside += !(ndcX > lx && ndcX < hx && ndcY > ly && ndcY < hy);
|
||||
}
|
||||
CHECK(outside == 0, "%s: %d host pixel(s) left outside the stretched vignette", what, outside);
|
||||
}
|
||||
|
||||
static void PlanTests()
|
||||
{
|
||||
const Texture vignette = VignetteTexture(), picture = PictureTexture();
|
||||
struct Host
|
||||
{
|
||||
const char* name;
|
||||
float scale, ox, oy;
|
||||
};
|
||||
// 1x, and 2x with D3D's half-pixel centres (as edge_bars_test's: the
|
||||
// viewport offset 640.5, 360.5).
|
||||
const Host hosts[] = { { "1x", 1.0f, 640.0f, 360.0f }, { "2x half-pixel", 2.0f, 640.5f, 360.5f } };
|
||||
int planned = 0;
|
||||
for (const Shape& s : kShapes)
|
||||
for (const Host& h : hosts)
|
||||
for (int flipped = 0; flipped < 2; flipped++)
|
||||
{
|
||||
float ps[4];
|
||||
// Flipped: the viewport's y scale positive, its offset the
|
||||
// band's far edge (the screen rows stay 0..720).
|
||||
FrontEndPs(ps, h.ox, flipped ? 360.0f - (h.oy - 360.0f) : h.oy, flipped ? 360.0f : -360.0f);
|
||||
const float hostW = 1280.0f * h.scale, hostH = 2048.0f * h.scale, screenH = 720.0f * h.scale;
|
||||
const Matrix m = FrontEndMatrix(s.sx, s.sy);
|
||||
const Quad q = Rectangle(-kVigX, kVigX, -kVigY, kVigY, s.sx, s.sy, kVigColour);
|
||||
const EPolys e = Polys(q, m);
|
||||
const Draw d = ShowcaseDraw(m, s.sx, s.sy, ps, hostW, hostH);
|
||||
const Plan p = RunPlan(d, e, vignette);
|
||||
char what[160];
|
||||
snprintf(what, sizeof(what), "%s, %s%s", s.name, h.name, flipped ? ", y flipped" : "");
|
||||
CHECK(p.fill == Fill::Vignette, "%s: %s, want vignette", what, Name(p.fill));
|
||||
if (p.fill != Fill::Vignette)
|
||||
continue;
|
||||
CHECK(p.rectCount == 0, "%s: %u rects, want none (the draw itself is stretched)", what, p.rectCount);
|
||||
CHECK(p.argb == kVigColour, "%s: colour %08X", what, p.argb);
|
||||
float want[4];
|
||||
CoverFrame(p.box, d.posScale, want);
|
||||
CHECK(std::memcmp(want, p.posScale, sizeof(want)) == 0, "%s: posScale %.5f %.5f %.5f %.5f, want CoverFrame's", what, p.posScale[0],
|
||||
p.posScale[1], p.posScale[2], p.posScale[3]);
|
||||
CoverOracle(what, p, ps, hostW, hostH, screenH);
|
||||
planned++;
|
||||
}
|
||||
printf("plan: the Showcase's draw at %zu shapes x 1x/2x x y-flipped: %d stretched, every host pixel covered\n", std::size(kShapes), planned);
|
||||
// Planted: the oracle catches a vignette left as the game draws it, and
|
||||
// one stretched only to clip +-1 at 2x (D3D's half-pixel centres: the
|
||||
// outermost column's centre lies past it).
|
||||
{
|
||||
const Shape deck = kShapes[1];
|
||||
float ps[4];
|
||||
FrontEndPs(ps, 640.5f, 360.5f);
|
||||
const Matrix m = FrontEndMatrix(deck.sx, deck.sy);
|
||||
const Draw d = ShowcaseDraw(m, deck.sx, deck.sy, ps, 2560.0f, 4096.0f);
|
||||
const Plan good = RunPlan(d, Polys(Rectangle(-kVigX, kVigX, -kVigY, kVigY, deck.sx, deck.sy, kVigColour), m), vignette);
|
||||
Plan unstretched = good, toOne = good;
|
||||
std::memcpy(unstretched.posScale, d.posScale, sizeof(unstretched.posScale));
|
||||
CoverFrame(good.box, d.posScale, toOne.posScale, 1.0f);
|
||||
for (const auto& [what, plan] : { std::pair{ "left as the game draws it", unstretched }, std::pair{ "stretched to clip +-1 only", toOne } })
|
||||
{
|
||||
const int saved = g_failures;
|
||||
g_quiet = true;
|
||||
CoverOracle(what, plan, ps, 2560.0f, 4096.0f, 1440.0f);
|
||||
g_quiet = false;
|
||||
const int failed = g_failures - saved;
|
||||
g_failures = saved;
|
||||
printf("planted oracle '%s' (Deck, 2x): %d check(s) failed\n", what, failed);
|
||||
CHECK(failed > 0, "planted oracle '%s' passed", what);
|
||||
}
|
||||
}
|
||||
|
||||
// A ppc/ without the Vert+ hooks (front-end y scale 0) at 16:9.
|
||||
{
|
||||
float ps[4];
|
||||
FrontEndPs(ps);
|
||||
const Matrix m = FrontEndMatrix(1.0f, 1.0f);
|
||||
Draw d = ShowcaseDraw(m, 1.0f, 1.0f, ps, 1280.0f, 2048.0f);
|
||||
d.frontEndY = 0.0f;
|
||||
const Plan p = RunPlan(d, Polys(Rectangle(-kVigX, kVigX, -kVigY, kVigY, 1.0f, 1.0f, kVigColour), m), vignette);
|
||||
CHECK(p.fill == Fill::Vignette, "16:9 with an old ppc/: %s", Name(p.fill));
|
||||
}
|
||||
|
||||
// What must be left as it is: each breaks one condition of the
|
||||
// vignette's draw (planted register and texel faults).
|
||||
float ps[4];
|
||||
FrontEndPs(ps);
|
||||
const Shape deck = kShapes[1];
|
||||
const Matrix m = FrontEndMatrix(deck.sx, deck.sy);
|
||||
const EPolys e = Polys(Rectangle(-kVigX, kVigX, -kVigY, kVigY, deck.sx, deck.sy, kVigColour), m);
|
||||
const Draw base = ShowcaseDraw(m, deck.sx, deck.sy, ps, 1280.0f, 2048.0f);
|
||||
struct Case
|
||||
{
|
||||
const char* what;
|
||||
void (*change)(Draw&);
|
||||
};
|
||||
const Case refuse[] = {
|
||||
{ "opaque, no blending (00010001)", [](Draw& d) { d.blend0 = 0x00010001; } },
|
||||
{ "additive blending", [](Draw& d) { d.blend0 = 0x07010701; } },
|
||||
{ "the 32x32 white texture", [](Draw& d) { d.t0Width = d.t0Height = 32; } },
|
||||
{ "a 64x256 texture", [](Draw& d) { d.t0Width = 64; } },
|
||||
{ "a DXT1 texture (format 18)", [](Draw& d) { d.t0Format = 18; } },
|
||||
{ "no texture sampled", [](Draw& d) { d.t0Sampled = false; } },
|
||||
{ "inside an occlusion query", [](Draw& d) { d.occlusion = true; } },
|
||||
{ "widened, covered or clipped by the other rules", [](Draw& d) { d.leftAlone = false; } },
|
||||
{ "not the front-end shader", [](Draw& d) { d.frontEndShader = false; } },
|
||||
{ "two quads", [](Draw& d) { d.count = 8; } },
|
||||
{ "a triangle list", [](Draw& d) { d.prim = 4; } },
|
||||
{ "no viewport transform", [](Draw& d) { d.vte = 0x300; } },
|
||||
{ "c5 not the front end's y scale", [](Draw& d) { d.frontEndY = 0.5f; } },
|
||||
};
|
||||
for (const Case& c : refuse)
|
||||
{
|
||||
Draw d = base;
|
||||
c.change(d);
|
||||
const Plan p = RunPlan(d, e, vignette);
|
||||
CHECK(p.fill == Fill::None, "%s: %s, want none", c.what, Name(p.fill));
|
||||
}
|
||||
// A translation in the matrix (c3 / c7).
|
||||
{
|
||||
Matrix t = m;
|
||||
t.c[3] = 0.1f;
|
||||
const Plan p = RunPlan(ShowcaseDraw(t, deck.sx, deck.sy, ps, 1280.0f, 2048.0f), e, vignette);
|
||||
CHECK(p.fill == Fill::None, "x translation: %s, want none", Name(p.fill));
|
||||
}
|
||||
// The texture: an opaque picture of the same shape, a vignette with any
|
||||
// one of its middle samples dark, an unreadable texture, a vertex past
|
||||
// the buffer.
|
||||
CHECK(RunPlan(base, e, picture).fill == Fill::None, "an opaque picture of the vignette's shape was stretched");
|
||||
for (uint32_t i = 0; i < kClearSamples; i++)
|
||||
{
|
||||
Texture t = VignetteTexture();
|
||||
uint32_t x, y;
|
||||
ClearSample(i, t.w, t.h, x, y);
|
||||
CHECK(t.Get(x, y) == 0, "the test vignette is clear at sample %u", i);
|
||||
t.Set(x, y, 0x01000000u);
|
||||
CHECK(RunPlan(base, e, t).fill == Fill::None, "middle sample %u (%u,%u) not clear: still stretched", i, x, y);
|
||||
}
|
||||
CHECK(RunPlan(base, e, vignette, 4, false).fill == Fill::None, "unreadable texels: stretched anyway");
|
||||
CHECK(RunPlan(base, e, vignette, 2).fill == Fill::None, "a vertex past the buffer: stretched anyway");
|
||||
// And it is the samples that decide: a vignette whose middle is clear
|
||||
// only between the samples is refused, one clear only at them is taken.
|
||||
{
|
||||
Texture t = PictureTexture();
|
||||
for (uint32_t i = 0; i < kClearSamples; i++)
|
||||
{
|
||||
uint32_t x, y;
|
||||
ClearSample(i, t.w, t.h, x, y);
|
||||
t.Set(x, y, 0);
|
||||
}
|
||||
CHECK(RunPlan(base, e, t).fill == Fill::Vignette, "the five samples clear: not taken");
|
||||
}
|
||||
printf("plan: %zu register faults, an opaque picture, %u dark samples, unreadable texels and a short vertex buffer refused\n",
|
||||
std::size(refuse) + 1, kClearSamples);
|
||||
}
|
||||
|
||||
// ---- The Blacklist bio's panel (a black frame, PlanFill) ----
|
||||
|
||||
// As logged on the Deck (NFSMW_LOG_FRONTEND, the bio pushed from the main
|
||||
// menu by a test-only hook): "black frame x -0.976..0.976 y -0.887..0.887
|
||||
// colour FB000000 blend 07060706 t0 1AD6E000 32x32", fading in from
|
||||
// 11000000 over ~15 frames; the picture inset on it (x +-0.970, y +-0.920,
|
||||
// blend 01060106) and the text are drawn after it.
|
||||
constexpr float kBioX = 0.976f, kBioY = 0.985f;
|
||||
|
||||
static Draw PanelDraw(const Matrix& m, float sx, float sy, const float ps[4], float hostW, float hostH)
|
||||
{
|
||||
Draw d = ShowcaseDraw(m, sx, sy, ps, hostW, hostH);
|
||||
d.t0Width = d.t0Height = 32; // the game's white texture
|
||||
d.t0Format = 20;
|
||||
return d;
|
||||
}
|
||||
|
||||
static Plan RunPanel(const Draw& d, const EPolys& e)
|
||||
{
|
||||
return PlanFill(d, [&](uint32_t k, uint32_t j, uint32_t& word) {
|
||||
word = e.w[k & 3][j & 3];
|
||||
return k < 4;
|
||||
});
|
||||
}
|
||||
|
||||
// The panel's fill against pixel centres, over the screen (the target's top
|
||||
// `screenH` rows): each pixel is in exactly one of the panel (its centre
|
||||
// inside the box) and the fill rectangles; ties within 1/256 pixel skipped.
|
||||
// Returns the wrong pixels.
|
||||
static int PanelOracle(const Box& b, const Rect* r, uint32_t n, const float ps[4], float hostW, float hostH, float screenH)
|
||||
{
|
||||
const float pxX = std::fabs(ps[0]) * hostW * 0.5f, pxY = std::fabs(ps[1]) * hostH * 0.5f;
|
||||
auto tie = [](float d, float px) { return std::fabs(d) * px < 1.0f / 256.0f; };
|
||||
int wrong = 0;
|
||||
for (int32_t y = 0; y < int32_t(screenH); y++)
|
||||
{
|
||||
const float cy = ((float(y) + 0.5f) / hostH * 2.0f - 1.0f - ps[3]) / ps[1];
|
||||
for (int32_t x = 0; x < int32_t(hostW); x++)
|
||||
{
|
||||
const float cx = ((float(x) + 0.5f) / hostW * 2.0f - 1.0f - ps[2]) / ps[0];
|
||||
if (tie(cx - b.x0, pxX) || tie(cx - b.x1, pxX) || tie(cy - b.y0, pxY) || tie(cy - b.y1, pxY))
|
||||
continue;
|
||||
int in = cx > b.x0 && cx < b.x1 && cy > b.y0 && cy < b.y1;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
in += x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1;
|
||||
wrong += in != 1;
|
||||
}
|
||||
}
|
||||
return wrong;
|
||||
}
|
||||
|
||||
// v0.1.1-v0.1.5's fill (a planted fault): only past the band.
|
||||
static uint32_t PastBandOnly(const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY, Rect out[4])
|
||||
{
|
||||
uint32_t n = 0;
|
||||
auto add = [&](const Rect& r) {
|
||||
const Rect c = Intersect(r, scissor);
|
||||
if (!Empty(c))
|
||||
out[n++] = c;
|
||||
};
|
||||
const float e = kFillEdge;
|
||||
if (scaleY < 1.0f)
|
||||
{
|
||||
add(ClipToHost(-e, e, scaleY, e, ps, hostW, hostH));
|
||||
add(ClipToHost(-e, e, -e, -scaleY, ps, hostW, hostH));
|
||||
}
|
||||
else if (scaleX < 1.0f)
|
||||
{
|
||||
add(ClipToHost(scaleX, e, -e, e, ps, hostW, hostH));
|
||||
add(ClipToHost(-e, -scaleX, -e, e, ps, hostW, hostH));
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static void BioTests()
|
||||
{
|
||||
struct Host
|
||||
{
|
||||
const char* name;
|
||||
float scale, ox, oy;
|
||||
};
|
||||
const Host hosts[] = { { "1x", 1.0f, 640.0f, 360.0f }, { "2x half-pixel", 2.0f, 640.5f, 360.5f } };
|
||||
int planned = 0, oldFails = 0, cases = 0;
|
||||
for (const Shape& s : kShapes)
|
||||
for (const Host& h : hosts)
|
||||
for (int flipped = 0; flipped < 2; flipped++)
|
||||
{
|
||||
float ps[4];
|
||||
FrontEndPs(ps, h.ox, flipped ? 360.0f - (h.oy - 360.0f) : h.oy, flipped ? 360.0f : -360.0f);
|
||||
const float hostW = 1280.0f * h.scale, hostH = 2048.0f * h.scale, screenH = 720.0f * h.scale;
|
||||
const Matrix m = FrontEndMatrix(s.sx, s.sy);
|
||||
const EPolys e = Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, s.sx, s.sy, 0xFF000000), m);
|
||||
const Draw d = PanelDraw(m, s.sx, s.sy, ps, hostW, hostH);
|
||||
const Plan p = RunPanel(d, e);
|
||||
char what[160];
|
||||
snprintf(what, sizeof(what), "bio panel, %s, %s%s", s.name, h.name, flipped ? ", y flipped" : "");
|
||||
CHECK(p.fill == Fill::Blackout, "%s: %s, want a black frame", what, Name(p.fill));
|
||||
if (p.fill != Fill::Blackout)
|
||||
continue;
|
||||
float want[4];
|
||||
CoverFrame(p.box, d.posScale, want);
|
||||
CHECK(std::memcmp(want, p.posScale, sizeof(want)) == 0, "%s: the copies' posScale is not CoverFrame's", what);
|
||||
const int wrong = PanelOracle(p.box, p.rects, p.rectCount, ps, hostW, hostH, screenH);
|
||||
CHECK(wrong == 0, "%s: %d pixel(s) in both or neither of the panel and its fill", what, wrong);
|
||||
Rect old[4]{};
|
||||
const uint32_t n = PastBandOnly(ps, hostW, hostH, d.scissor, s.sx, s.sy, old);
|
||||
oldFails += PanelOracle(p.box, old, n, ps, hostW, hostH, screenH) > 0;
|
||||
cases++;
|
||||
planned++;
|
||||
}
|
||||
printf("bio panel: %d plans at %zu shapes x 1x/2x x y-flipped, the panel and its fill split every pixel once\n", planned, std::size(kShapes));
|
||||
printf("planted bio panel fill 'past the band only (v0.1.5)': %d of %d cases fail\n", oldFails, cases);
|
||||
CHECK(oldFails == cases && cases > 0, "planted 'past the band only' passed %d of %d cases", cases - oldFails, cases);
|
||||
// Fading in (11000000): below the black frame's alpha, left alone (as
|
||||
// before); over a picture (a large t0), never a black frame, and not a
|
||||
// vignette (opaque in the middle).
|
||||
float ps[4];
|
||||
FrontEndPs(ps);
|
||||
const Matrix m = FrontEndMatrix(1.0f, 0.9f);
|
||||
const Draw d = PanelDraw(m, 1.0f, 0.9f, ps, 1280.0f, 2048.0f);
|
||||
CHECK(RunPanel(d, Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, 1.0f, 0.9f, 0x11000000), m)).fill == Fill::None, "the panel fading in was filled");
|
||||
const EPolys picture = Polys(Rectangle(-kBioX, kBioX, -kBioY, kBioY, 1.0f, 0.9f, 0xFF000000), m);
|
||||
Draw big = d;
|
||||
big.t0Width = big.t0Height = 256;
|
||||
big.t0Format = 6;
|
||||
CHECK(RunPanel(big, picture).fill == Fill::None, "a black quad over a large picture taken for a black frame");
|
||||
CHECK(RunPlan(big, picture, PictureTexture()).fill == Fill::None, "a picture of the panel's shape taken for a vignette");
|
||||
}
|
||||
|
||||
// ---- gpu/renderer.cpp uses it ----
|
||||
|
||||
static std::string Squash(const std::string& source)
|
||||
{
|
||||
std::string code, r;
|
||||
std::istringstream in(source);
|
||||
for (std::string line; std::getline(in, line);)
|
||||
{
|
||||
size_t c = line.find("//");
|
||||
code += (c == std::string::npos ? line : line.substr(0, c)) + "\n";
|
||||
}
|
||||
for (char c : code)
|
||||
if (c != ' ' && c != '\n' && c != '\t')
|
||||
r += c;
|
||||
return r;
|
||||
}
|
||||
|
||||
static size_t Count(const std::string& s, const std::string& what)
|
||||
{
|
||||
size_t n = 0;
|
||||
for (size_t at = s.find(what); at != std::string::npos; at = s.find(what, at + 1))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// The renderer's wiring; returns the failures (quiet: for the planted copies).
|
||||
static int RendererChecks(const std::string& source, bool quiet)
|
||||
{
|
||||
const int before = g_failures;
|
||||
const bool was = g_quiet;
|
||||
g_quiet = quiet;
|
||||
const std::string all = Squash(source);
|
||||
// planEdgeFills: bars and black frames, then the backdrop, then the
|
||||
// vignette, each only when nothing earlier took the draw.
|
||||
const size_t fill = all.find("gpu::bars::Planplan=gpu::bars::PlanFill(draw,read);");
|
||||
const size_t backdrop = all.find("if(plan.fill==gpu::bars::Fill::None)plan=gpu::bars::PlanBackdrop(draw,read);");
|
||||
const size_t vignette = all.find("plan=gpu::bars::PlanVignette(draw,read,texel);");
|
||||
CHECK(fill != std::string::npos && backdrop != std::string::npos && vignette != std::string::npos && fill < backdrop &&
|
||||
backdrop < vignette,
|
||||
"planEdgeFills asks PlanFill, then PlanBackdrop, then PlanVignette");
|
||||
// Both paths (wide or tall screens, and 16:9) ask it: the black frame's
|
||||
// fill and the vignette at every shape.
|
||||
CHECK(Count(all, "barPlan=planEdgeFills(c,") == 2, "planEdgeFills asked on both paths: %zu", Count(all, "barPlan=planEdgeFills(c,"));
|
||||
CHECK(vignette != std::string::npos && all.rfind("if(plan.fill==gpu::bars::Fill::None){", vignette) != std::string::npos &&
|
||||
vignette - all.rfind("if(plan.fill==gpu::bars::Fill::None){", vignette) < 600,
|
||||
"PlanVignette only for a draw nothing else took");
|
||||
// Its texture as the GPU reads it.
|
||||
CHECK(all.find(".t0Format=uint32_t(t0.format),") != std::string::npos, "the draw's t0 format is handed over");
|
||||
CHECK(all.find("word=GpuSwap(LoadPhysical(t0Base+gpu::bars::TexelOffset32(x,y,t0Pitch,t0.tiled!=0)),uint32_t(t0.endianness));") !=
|
||||
std::string::npos,
|
||||
"texels read through TexelOffset32 at t0's base, pitch, tiling and endian");
|
||||
// The plan is applied to the draw itself on both paths (wide or tall
|
||||
// screens, and 16:9), before its constants are pushed.
|
||||
const std::string apply = "if(barPlan.fill==gpu::bars::Fill::Vignette)memcpy(pc.posScale,barPlan.posScale,sizeof(pc.posScale));";
|
||||
CHECK(Count(all, apply) == 2, "the vignette's posScale applied on both paths: %zu", Count(all, apply));
|
||||
const size_t lastApply = all.rfind(apply);
|
||||
const size_t push = lastApply == std::string::npos ? lastApply : all.find("pushConstants(pc);", lastApply);
|
||||
const size_t draw = lastApply == std::string::npos ? lastApply : all.find("vkCmdDraw", lastApply);
|
||||
CHECK(push != std::string::npos && draw != std::string::npos && push < draw,
|
||||
"the posScale is set before the draw's constants are pushed and it is drawn");
|
||||
// Both behind the A/B switch, as every edge fill.
|
||||
CHECK(all.find("staticconstboolbarFill=[]{constchar*v=std::getenv(\"NFSMW_BAR_FILL\");return!(v&&v[0]=='0');}();") != std::string::npos,
|
||||
"NFSMW_BAR_FILL=0 still turns the edge fills off");
|
||||
g_quiet = was;
|
||||
return g_failures - before;
|
||||
}
|
||||
|
||||
static void RendererTests(const char* path)
|
||||
{
|
||||
std::ifstream f(path);
|
||||
CHECK(f.good(), "cannot read %s", path);
|
||||
if (!f.good())
|
||||
return;
|
||||
std::stringstream ss;
|
||||
ss << f.rdbuf();
|
||||
const std::string source = ss.str();
|
||||
CHECK(RendererChecks(source, false) == 0, "renderer.cpp's vignette wiring");
|
||||
// Planted: copies with the wiring cut, each must fail.
|
||||
struct Cut
|
||||
{
|
||||
const char* what;
|
||||
const char* from;
|
||||
const char* to;
|
||||
bool all;
|
||||
};
|
||||
const Cut cuts[] = {
|
||||
{ "PlanVignette never asked", "plan = gpu::bars::PlanVignette(draw, read, texel);", "(void)texel;", false },
|
||||
{ "PlanFill only on wide or tall screens (v0.1.5)", "gpu::bars::Plan plan = gpu::bars::PlanFill(draw, read);",
|
||||
"gpu::bars::Plan plan = aspectX < 1.0f || aspectY < 1.0f ? gpu::bars::PlanFill(draw, read) : gpu::bars::Plan{};", false },
|
||||
{ "the 16:9 path never plans", "barPlan = planEdgeFills(c, game::FrontEndScaleY(), s, sv, feShader, true);", ";", false },
|
||||
{ "the 16:9 path doesn't apply it", " if (barPlan.fill == gpu::bars::Fill::Vignette)\n memcpy(pc.posScale, barPlan.posScale, sizeof(pc.posScale));\n }\n }\n }", " }\n }\n }", false },
|
||||
{ "no posScale applied at all", "memcpy(pc.posScale, barPlan.posScale, sizeof(pc.posScale));", ";", true },
|
||||
{ "texels read linear", "t0.tiled != 0", "false", false },
|
||||
{ "texels read without the endian", "uint32_t(t0.endianness)", "0u", false },
|
||||
};
|
||||
for (const Cut& c : cuts)
|
||||
{
|
||||
std::string copy = source;
|
||||
size_t at = copy.find(c.from), n = 0;
|
||||
while (at != std::string::npos)
|
||||
{
|
||||
copy.replace(at, std::strlen(c.from), c.to);
|
||||
n++;
|
||||
if (!c.all)
|
||||
break;
|
||||
at = copy.find(c.from, at + std::strlen(c.to));
|
||||
}
|
||||
CHECK(n > 0, "planted '%s': its text is not in renderer.cpp", c.what);
|
||||
const int saved = g_failures;
|
||||
const int failed = RendererChecks(copy, true);
|
||||
g_failures = saved;
|
||||
printf("planted renderer '%s': %d check(s) failed\n", c.what, failed);
|
||||
CHECK(failed > 0, "planted renderer '%s' passed every check", c.what);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
printf("menu borders (GitHub #19): the Showcase's vignette\n");
|
||||
VignetteCases(VignetteRule{});
|
||||
printf("shape cases at %zu screen shapes checked\n", std::size(kShapes));
|
||||
printf("\nplanted, shape (each must fail a case):\n");
|
||||
auto plant = [](const char* what, auto set) { VignetteRule r; set(r); Planted(what, r); };
|
||||
plant("centreMax 0.05", [](VignetteRule& r) { r.centreMax = 0.05f; });
|
||||
plant("extentMin 0.96", [](VignetteRule& r) { r.extentMin = 0.96f; });
|
||||
plant("extentMin 0.85", [](VignetteRule& r) { r.extentMin = 0.85f; });
|
||||
plant("extentMax 0.96", [](VignetteRule& r) { r.extentMax = 0.96f; });
|
||||
plant("extentMax 1.0", [](VignetteRule& r) { r.extentMax = 1.0f; });
|
||||
plant("cornerTolerance 0.1", [](VignetteRule& r) { r.cornerTolerance = 0.1f; });
|
||||
printf("\n");
|
||||
TexelTests();
|
||||
printf("\n");
|
||||
PlanTests();
|
||||
printf("\n");
|
||||
BioTests();
|
||||
printf("\n");
|
||||
if (argc > 1)
|
||||
RendererTests(argv[1]);
|
||||
else
|
||||
printf("(no renderer.cpp given: its checks skipped)\n");
|
||||
if (g_failures)
|
||||
{
|
||||
printf("menu_borders_test: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("menu_borders_test: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// Tests for video::PresentSchedule (runtime/video/present_schedule.h): when the
|
||||
// Mac shows a new frame at a set time after its vblank, and the watchdog that
|
||||
// turns it off. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/present_schedule_test.cpp -o build/present_schedule_test
|
||||
// build/present_schedule_test
|
||||
#include <video/present_schedule.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
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 video::PresentSchedule;
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int64_t kMs = 1'000'000;
|
||||
|
||||
void Gate()
|
||||
{
|
||||
printf("when frames are shown at a time:\n");
|
||||
struct Row
|
||||
{
|
||||
const char* name;
|
||||
PresentSchedule::Mode mode;
|
||||
double hz;
|
||||
bool locked, want;
|
||||
};
|
||||
const Row rows[] = {
|
||||
{ "ProMotion 120 Hz, not locked (measured: 99.9% at 16.67 ms)", PresentSchedule::Mode::Auto, 120.0, false, true },
|
||||
{ "ProMotion 120 Hz, the lock took it", PresentSchedule::Mode::Auto, 120.0, true, false },
|
||||
{ "a 60 Hz display, not locked yet (the macOS 15 VM: black)", PresentSchedule::Mode::Auto, 60.0, false, false },
|
||||
{ "a 60 Hz display, locked", PresentSchedule::Mode::Auto, 60.0, true, false },
|
||||
{ "a 59.94 Hz TV", PresentSchedule::Mode::Auto, 59.94, false, false },
|
||||
{ "a 144 Hz monitor (not measured)", PresentSchedule::Mode::Auto, 144.0, false, false },
|
||||
{ "a 90 Hz display (not measured)", PresentSchedule::Mode::Auto, 90.0, false, false },
|
||||
{ "119.88 Hz", PresentSchedule::Mode::Auto, 119.88, false, true },
|
||||
{ "the display's rate unknown", PresentSchedule::Mode::Auto, 0.0, false, false },
|
||||
{ "NFSMW_PRESENT_AT=<ms>, 60 Hz, locked", PresentSchedule::Mode::On, 60.0, true, true },
|
||||
{ "NFSMW_PRESENT_AT=0, ProMotion", PresentSchedule::Mode::Off, 120.0, false, false },
|
||||
};
|
||||
for (const Row& r : rows)
|
||||
{
|
||||
PresentSchedule s(r.mode);
|
||||
s.SetDisplayHz(r.hz);
|
||||
CHECK(s.Wanted(r.locked) == r.want, "%s: %s", r.name, r.want ? "at a time" : "when ready");
|
||||
}
|
||||
// The display changes under the game (the window moves to another one).
|
||||
PresentSchedule s;
|
||||
s.SetDisplayHz(120.0);
|
||||
CHECK(s.Wanted(false), "on the MacBook's panel: at a time");
|
||||
s.SetDisplayHz(60.0);
|
||||
CHECK(!s.Wanted(false), "moved to a 60 Hz monitor: when ready");
|
||||
s.SetDisplayHz(120.0);
|
||||
CHECK(s.Wanted(false), "and back: at a time");
|
||||
}
|
||||
|
||||
void Watchdog()
|
||||
{
|
||||
printf("the watchdog:\n");
|
||||
// ProMotion as measured: shown 0-8 ms after the time asked for, now
|
||||
// and then a frame a step or two late (its GPU work not done): never trips.
|
||||
{
|
||||
PresentSchedule s;
|
||||
s.SetDisplayHz(120.0);
|
||||
bool tripped = false;
|
||||
for (int i = 0; i < 20000; i++)
|
||||
{
|
||||
tripped |= s.Presented();
|
||||
const int64_t late = i % 50 == 0 ? 25 * kMs : (i % 3) * 4 * kMs;
|
||||
tripped |= s.Reported(true, late);
|
||||
}
|
||||
CHECK(!tripped && !s.Tripped() && s.Wanted(false), "frames shown at their time, some late: never trips");
|
||||
}
|
||||
// Reports a present behind (the driver reports a frame after its
|
||||
// wait returned): fine.
|
||||
{
|
||||
PresentSchedule s(PresentSchedule::Mode::On);
|
||||
bool tripped = s.Presented();
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
tripped |= s.Presented();
|
||||
tripped |= s.Reported(true, 2 * kMs);
|
||||
}
|
||||
CHECK(!tripped, "reports one present behind: fine");
|
||||
}
|
||||
// The macOS 15 VM's black screen: every frame reported shown ~9 ms
|
||||
// before its time (3-14 ms; the time not kept). Trips at the 60th.
|
||||
{
|
||||
PresentSchedule s(PresentSchedule::Mode::On);
|
||||
int at = -1;
|
||||
for (int i = 0; i < 1000 && at < 0; i++)
|
||||
{
|
||||
s.Presented();
|
||||
if (s.Reported(true, -(3 + i % 12) * kMs))
|
||||
at = i;
|
||||
}
|
||||
CHECK(at == int(PresentSchedule::kBadOfWindow) - 1 && s.GetReason() == PresentSchedule::Reason::NotKept && s.Early() == 60,
|
||||
"shown 3-14 ms early: trips at the %uth (%d)", PresentSchedule::kBadOfWindow, at + 1);
|
||||
CHECK(!s.Wanted(false) && !s.Wanted(true), "then when ready, forced on or not");
|
||||
}
|
||||
// The VM's first seconds with frames shown at a time: each reported
|
||||
// ~100 ms after its present (88 ms after the time asked for), until
|
||||
// the display froze. Trips at the 60th.
|
||||
{
|
||||
PresentSchedule s(PresentSchedule::Mode::On);
|
||||
int at = -1;
|
||||
for (int i = 0; i < 1000 && at < 0; i++)
|
||||
if (s.Reported(true, (80 + i % 20) * kMs))
|
||||
at = i;
|
||||
CHECK(at == int(PresentSchedule::kBadOfWindow) - 1, "80-99 ms late: trips at the %uth (%d)", PresentSchedule::kBadOfWindow, at + 1);
|
||||
}
|
||||
// ProMotion as measured: up to 21 ms late (a frame's GPU work not
|
||||
// done in time), never more than 1.2 ms early (0 of 46,000).
|
||||
{
|
||||
PresentSchedule s;
|
||||
s.SetDisplayHz(120.0);
|
||||
bool tripped = false;
|
||||
for (int i = 0; i < 46000; i++)
|
||||
tripped |= s.Reported(true, (i % 5 == 0 ? 21 : i % 9) * kMs);
|
||||
CHECK(!tripped, "up to 21 ms late: no trip");
|
||||
}
|
||||
// ProMotion as measured: never more than 1.2 ms early (0 of 46,000).
|
||||
{
|
||||
PresentSchedule s;
|
||||
s.SetDisplayHz(120.0);
|
||||
bool tripped = false;
|
||||
for (int i = 0; i < 46000; i++)
|
||||
tripped |= s.Reported(true, (i % 7 == 0 ? -1'200'000 : (i % 9) * kMs));
|
||||
CHECK(!tripped, "1.2 ms early now and then: no trip");
|
||||
}
|
||||
// Never shown (Metal's presentedTime 0): trips within kWindow reports.
|
||||
{
|
||||
PresentSchedule s;
|
||||
s.SetDisplayHz(120.0);
|
||||
int at = -1;
|
||||
for (int i = 0; i < 1000 && at < 0; i++)
|
||||
{
|
||||
s.Presented();
|
||||
if (s.Reported(false, 0))
|
||||
at = i;
|
||||
}
|
||||
CHECK(at == int(PresentSchedule::kBadOfWindow) - 1, "never shown: trips at the %uth (%d)", PresentSchedule::kBadOfWindow, at + 1);
|
||||
CHECK(s.Tripped() && !s.Wanted(false) && s.GetReason() == PresentSchedule::Reason::NotKept && s.NotShown() == 60,
|
||||
"then when ready, for good");
|
||||
CHECK(!s.Reported(true, 0) && !s.Presented(), "a trip is reported once");
|
||||
s.SetDisplayHz(60.0);
|
||||
s.SetDisplayHz(120.0);
|
||||
CHECK(!s.Wanted(false), "still off after a display change (the session)");
|
||||
}
|
||||
// Shown, but far from the time asked for (a clock taken for another).
|
||||
{
|
||||
PresentSchedule s(PresentSchedule::Mode::On);
|
||||
bool tripped = false;
|
||||
for (int i = 0; i < 200 && !tripped; i++)
|
||||
tripped = s.Reported(true, 2'000 * kMs);
|
||||
CHECK(tripped && !s.Wanted(true), "shown 2 s late: trips (even when forced on)");
|
||||
}
|
||||
// Half of them never shown: trips; fewer: doesn't.
|
||||
{
|
||||
PresentSchedule a, b;
|
||||
a.SetDisplayHz(120.0);
|
||||
b.SetDisplayHz(120.0);
|
||||
bool ta = false, tb = false;
|
||||
for (int i = 0; i < 2000; i++)
|
||||
{
|
||||
ta |= a.Reported(i % 2 == 0, 0);
|
||||
tb |= b.Reported(i % 3 != 0, 0);
|
||||
}
|
||||
CHECK(ta, "every other frame never shown: trips");
|
||||
CHECK(!tb, "one in three never shown: doesn't (%u of %u bad)", b.BadInWindow(), b.ReportedInWindow());
|
||||
}
|
||||
// Presented, never reported: trips after kUnreported.
|
||||
{
|
||||
PresentSchedule s;
|
||||
s.SetDisplayHz(120.0);
|
||||
int at = -1;
|
||||
for (int i = 0; i < 1000 && at < 0; i++)
|
||||
if (s.Presented())
|
||||
at = i;
|
||||
CHECK(at == int(PresentSchedule::kUnreported) - 1 && s.GetReason() == PresentSchedule::Reason::Unreported,
|
||||
"no reports: trips at the %uth (%d)", PresentSchedule::kUnreported, at + 1);
|
||||
}
|
||||
// A burst of bad reports after a long good run (a load's hitch, a
|
||||
// fullscreen switch): kBadOfWindow is needed within kWindow.
|
||||
{
|
||||
PresentSchedule s;
|
||||
s.SetDisplayHz(120.0);
|
||||
bool tripped = false;
|
||||
for (int i = 0; i < 5000; i++)
|
||||
tripped |= s.Reported(true, 3 * kMs);
|
||||
for (int i = 0; i < int(PresentSchedule::kBadOfWindow) - 1; i++)
|
||||
tripped |= s.Reported(false, 0);
|
||||
for (int i = 0; i < 5000; i++)
|
||||
tripped |= s.Reported(true, 3 * kMs);
|
||||
CHECK(!tripped, "%u bad in a row among good ones: no trip", PresentSchedule::kBadOfWindow - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
Gate();
|
||||
Watchdog();
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Tests for Settings > Quit Game (runtime/ui/quit_game.h): where it is
|
||||
// offered, and its confirmation. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/quit_game_test.cpp -o build/quit_game_test
|
||||
// build/quit_game_test runtime
|
||||
// With the runtime folder it also reads the code around it: the button and
|
||||
// its prompt compiled only where SB_QUIT_GAME is on (not iPhone or iPad), the
|
||||
// prompt's text, Quit before Cancel with the focus on Cancel, B and Escape
|
||||
// cancelling, the menu closing cancelling, the quit sent as SDL_EVENT_QUIT
|
||||
// (no exit of its own), the presenter's RunFrame ending on that event, and
|
||||
// main()'s one way out after it (settings saved, renderer and window shut
|
||||
// down, exit code 0).
|
||||
#include <ui/quit_game.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
namespace
|
||||
{
|
||||
using ui::quit::Button;
|
||||
using ui::quit::Outcome;
|
||||
using ui::quit::Prompt;
|
||||
using ui::quit::System;
|
||||
|
||||
void Platforms()
|
||||
{
|
||||
CHECK(ui::quit::OfferedOn(System::Mac), "macOS offers Quit Game");
|
||||
CHECK(ui::quit::OfferedOn(System::Linux), "Linux and SteamOS (x86-64, the Frame's arm64) offer it");
|
||||
CHECK(ui::quit::OfferedOn(System::Windows), "Windows offers it");
|
||||
CHECK(ui::quit::OfferedOn(System::Other), "another desktop system offers it");
|
||||
CHECK(!ui::quit::OfferedOn(System::IPhoneIPad), "iPhone and iPad don't: the player closes apps there");
|
||||
// The system this test is built for (a Mac, Linux or Windows: the
|
||||
// ones the tests run on) is the one the header names.
|
||||
#if defined(__APPLE__) && TARGET_OS_IPHONE
|
||||
CHECK(ui::quit::kSystem == System::IPhoneIPad && !ui::quit::kOffered && SB_QUIT_GAME == 0, "an iOS build: no Quit Game");
|
||||
#elif defined(__APPLE__)
|
||||
CHECK(ui::quit::kSystem == System::Mac, "built for macOS: System::Mac");
|
||||
#elif defined(_WIN32)
|
||||
CHECK(ui::quit::kSystem == System::Windows, "built for Windows: System::Windows");
|
||||
#elif defined(__linux__)
|
||||
CHECK(ui::quit::kSystem == System::Linux, "built for Linux: System::Linux");
|
||||
#endif
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IPHONE)
|
||||
CHECK(ui::quit::kOffered && SB_QUIT_GAME == 1, "offered on this desktop system");
|
||||
#endif
|
||||
CHECK(ui::quit::kOffered == ui::quit::OfferedOn(ui::quit::kSystem), "the macro and the table agree");
|
||||
}
|
||||
|
||||
void Steps()
|
||||
{
|
||||
// Nothing open: the prompt's buttons and B do nothing.
|
||||
{
|
||||
Prompt p;
|
||||
CHECK(!p.Asking() && !p.Quitting(), "starts closed");
|
||||
CHECK(p.Press(Button::Quit) == Outcome::None, "Quit without the prompt: nothing");
|
||||
CHECK(p.Press(Button::Cancel) == Outcome::None && p.Back() == Outcome::None && p.Dismiss() == Outcome::None,
|
||||
"Cancel, B, the menu closing without the prompt: nothing");
|
||||
CHECK(!p.TakeFocus(), "no focus to put anywhere");
|
||||
}
|
||||
// Quit Game asks, with the focus on Cancel, once.
|
||||
{
|
||||
Prompt p;
|
||||
CHECK(p.Choose() == Outcome::Asking && p.Asking(), "Quit Game opens the prompt");
|
||||
CHECK(Prompt::kDefaultFocus == Button::Cancel, "the focus starts on Cancel");
|
||||
p.Shown();
|
||||
CHECK(p.TakeFocus(), "the first frame puts the focus on Cancel");
|
||||
CHECK(!p.TakeFocus(), "...and only the first: the player's moves stay theirs");
|
||||
CHECK(p.Choose() == Outcome::None, "Quit Game again while asking: nothing new");
|
||||
}
|
||||
// Quit takes a press only once the prompt has been up a frame.
|
||||
{
|
||||
Prompt p;
|
||||
p.Choose();
|
||||
p.Shown();
|
||||
CHECK(p.Press(Button::Quit) == Outcome::None && p.Asking(), "Quit on the prompt's first frame: ignored, still asking");
|
||||
p.Shown();
|
||||
CHECK(p.Press(Button::Quit) == Outcome::Quit && p.Quitting(), "Quit a frame later: quits");
|
||||
}
|
||||
// Cancel, B / Escape, the menu closing: no quit, and asking again
|
||||
// starts over (the focus on Cancel again).
|
||||
for (int how = 0; how < 3; how++)
|
||||
{
|
||||
Prompt p;
|
||||
p.Choose();
|
||||
p.Shown();
|
||||
p.TakeFocus();
|
||||
p.Shown();
|
||||
Outcome o = how == 0 ? p.Press(Button::Cancel) : how == 1 ? p.Back() : p.Dismiss();
|
||||
CHECK(o == Outcome::Cancelled && !p.Asking() && !p.Quitting(), "cancel path %d: cancelled", how);
|
||||
CHECK(p.Press(Button::Quit) == Outcome::None, "cancel path %d: Quit after it does nothing", how);
|
||||
CHECK(p.Choose() == Outcome::Asking, "cancel path %d: Quit Game asks again", how);
|
||||
p.Shown();
|
||||
CHECK(p.TakeFocus(), "cancel path %d: the focus back on Cancel", how);
|
||||
CHECK(p.Press(Button::Quit) == Outcome::None, "cancel path %d: the new prompt's first frame ignores Quit too", how);
|
||||
}
|
||||
// Cancelled on its first frame (B at once): fine, no focus left pending.
|
||||
{
|
||||
Prompt p;
|
||||
p.Choose();
|
||||
CHECK(p.Back() == Outcome::Cancelled && !p.TakeFocus(), "B before the first frame: cancelled, no focus to take");
|
||||
}
|
||||
// Quitting is final: one SDL_EVENT_QUIT, whatever comes after.
|
||||
{
|
||||
Prompt p;
|
||||
p.Choose();
|
||||
p.Shown();
|
||||
p.Shown();
|
||||
CHECK(p.Press(Button::Quit) == Outcome::Quit, "quits");
|
||||
CHECK(p.Press(Button::Quit) == Outcome::None && p.Press(Button::Cancel) == Outcome::None, "no second quit, no cancel");
|
||||
CHECK(p.Back() == Outcome::None && p.Dismiss() == Outcome::None, "B or the menu closing (main() shutting the UI down) don't cancel it");
|
||||
CHECK(p.Choose() == Outcome::None && p.Quitting(), "Quit Game can't ask again while quitting");
|
||||
// SDL refused the event: start over.
|
||||
p.QuitFailed();
|
||||
CHECK(!p.Quitting() && !p.Asking() && p.Choose() == Outcome::Asking, "a quit that couldn't be sent: Quit Game works again");
|
||||
}
|
||||
}
|
||||
|
||||
// The player, as ImGui gives the prompt their input: the footer's focus
|
||||
// on Quit Game (the hardest case: one press from the prompt), one input
|
||||
// a frame. A presses the focused button, B (or Escape) goes back, Left
|
||||
// and Right move between Quit (left) and Cancel (right).
|
||||
enum Input { kA, kB, kLeft, kRight, kInputs };
|
||||
const char* const kInputNames[] = { "A", "B", "Left", "Right" };
|
||||
|
||||
// How many of the inputs it took to quit (0: they didn't).
|
||||
size_t QuitAfter(const std::vector<int>& inputs)
|
||||
{
|
||||
Prompt p;
|
||||
Button focus = Button::Cancel;
|
||||
bool menuOpen = true;
|
||||
for (size_t i = 0; i < inputs.size(); i++)
|
||||
{
|
||||
const int input = inputs[i];
|
||||
if (!menuOpen)
|
||||
return 0;
|
||||
if (!p.Asking())
|
||||
{
|
||||
// The footer: A presses Quit Game (the prompt draws in the
|
||||
// same frame); B closes the menu; Left and Right would move
|
||||
// off Quit Game (kept on it here: never easier to quit).
|
||||
if (input == kA && p.Choose() == Outcome::Asking)
|
||||
{
|
||||
p.Shown();
|
||||
if (p.TakeFocus())
|
||||
focus = Prompt::kDefaultFocus;
|
||||
}
|
||||
else if (input == kB)
|
||||
menuOpen = false;
|
||||
continue;
|
||||
}
|
||||
p.Shown();
|
||||
Outcome o = Outcome::None;
|
||||
if (input == kA)
|
||||
o = p.Press(focus);
|
||||
else if (input == kB)
|
||||
o = p.Back();
|
||||
else
|
||||
focus = input == kLeft ? Button::Quit : Button::Cancel;
|
||||
if (o == Outcome::Quit)
|
||||
return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool Quits(const std::vector<int>& inputs)
|
||||
{
|
||||
return QuitAfter(inputs) > 0;
|
||||
}
|
||||
|
||||
void NoAccidentalQuit()
|
||||
{
|
||||
CHECK(Quits({ kA, kLeft, kA }), "A, Left, A quits (Quit Game, to Quit, press)");
|
||||
CHECK(!Quits({ kA, kA }), "A, A: the second press is Cancel's");
|
||||
CHECK(!Quits({ kA, kB }) && !Quits({ kA, kLeft, kB }), "B cancels, from either button");
|
||||
CHECK(!Quits({ kA, kLeft, kRight, kA }), "back to Cancel, then A: cancelled");
|
||||
// Every input sequence up to 7 long.
|
||||
int sequences = 0, quitting = 0;
|
||||
for (int length = 1; length <= 7; length++)
|
||||
{
|
||||
int total = 1;
|
||||
for (int i = 0; i < length; i++)
|
||||
total *= kInputs;
|
||||
for (int code = 0; code < total; code++)
|
||||
{
|
||||
std::vector<int> seq;
|
||||
for (int i = 0, c = code; i < length; i++, c /= kInputs)
|
||||
seq.push_back(c % kInputs);
|
||||
const size_t after = QuitAfter(seq);
|
||||
sequences++;
|
||||
quitting += after > 0;
|
||||
if (!after)
|
||||
continue;
|
||||
seq.resize(after); // the inputs up to the quit
|
||||
std::string how;
|
||||
for (int input : seq)
|
||||
how += std::string(kInputNames[input]) + " ";
|
||||
bool oneButton = true;
|
||||
for (int input : seq)
|
||||
oneButton &= input == seq[0];
|
||||
CHECK(!oneButton, "one button pressed over and over quit: %s", how.c_str());
|
||||
CHECK(seq.size() >= 3, "fewer than 3 presses quit: %s", how.c_str());
|
||||
// A quitting sequence ends with A, pressed with the focus moved
|
||||
// to Quit by a Left after the prompt opened.
|
||||
CHECK(seq.back() == kA, "a quit not on A: %s", how.c_str());
|
||||
size_t open = 0;
|
||||
while (open < seq.size() && seq[open] != kA)
|
||||
open++;
|
||||
bool left = false;
|
||||
for (size_t i = open + 1; i + 1 < seq.size(); i++)
|
||||
left |= seq[i] == kLeft;
|
||||
CHECK(left, "a quit without a Left in the prompt: %s", how.c_str());
|
||||
}
|
||||
}
|
||||
CHECK(quitting > 0 && quitting < sequences, "some sequences quit (%d of %d)", quitting, sequences);
|
||||
}
|
||||
|
||||
// ---- The code around it (with the runtime folder's path) ----
|
||||
|
||||
std::vector<std::string> Lines(const std::string& path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
std::vector<std::string> lines;
|
||||
for (std::string line; std::getline(in, line);)
|
||||
lines.push_back(line);
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::string Trim(const std::string& s)
|
||||
{
|
||||
size_t a = s.find_first_not_of(" \t");
|
||||
return a == std::string::npos ? std::string() : s.substr(a);
|
||||
}
|
||||
|
||||
bool Has(const std::string& line, const char* text)
|
||||
{
|
||||
return line.find(text) != std::string::npos;
|
||||
}
|
||||
|
||||
// For each line: inside the true branch of an `#if SB_QUIT_GAME`.
|
||||
std::vector<bool> QuitGated(const std::vector<std::string>& lines)
|
||||
{
|
||||
struct Level { bool quit; bool inElse; };
|
||||
std::vector<Level> stack;
|
||||
std::vector<bool> gated;
|
||||
for (const std::string& raw : lines)
|
||||
{
|
||||
std::string line = Trim(raw);
|
||||
if (line.rfind("#if", 0) == 0)
|
||||
stack.push_back({ Trim(line.substr(line.find_first_of(" \t") == std::string::npos ? line.size() : line.find_first_of(" \t"))) == "SB_QUIT_GAME", false });
|
||||
else if (line.rfind("#elif", 0) == 0 && !stack.empty())
|
||||
stack.back() = { false, true };
|
||||
else if (line.rfind("#else", 0) == 0 && !stack.empty())
|
||||
stack.back().inElse = true;
|
||||
else if (line.rfind("#endif", 0) == 0 && !stack.empty())
|
||||
stack.pop_back();
|
||||
bool in = false;
|
||||
for (const Level& l : stack)
|
||||
in |= l.quit && !l.inElse;
|
||||
gated.push_back(in);
|
||||
}
|
||||
return gated;
|
||||
}
|
||||
|
||||
void SettingsMenu(const std::string& runtime)
|
||||
{
|
||||
const std::vector<std::string> lines = Lines(runtime + "/ui/settings_menu.cpp");
|
||||
CHECK(lines.size() > 100, "read ui/settings_menu.cpp (%zu lines)", lines.size());
|
||||
const std::vector<bool> gated = QuitGated(lines);
|
||||
int button = -1, text = -1, quitButton = -1, cancelButton = -1, focus = -1, defaultFocus = -1, push = -1, back = -1, close = -1;
|
||||
for (size_t i = 0; i < lines.size(); i++)
|
||||
{
|
||||
const std::string& l = lines[i];
|
||||
// Everything of Quit Game's is compiled only where it's offered.
|
||||
for (const char* name : { "\"Quit Game\"", "SDL_EVENT_QUIT", "SDL_PushEvent", "s_quit", "kQuitPopup", "Quit SpeedBreaker" })
|
||||
if (Has(l, name))
|
||||
CHECK(gated[i], "settings_menu.cpp:%zu: %s outside #if SB_QUIT_GAME: %s", i + 1, name, Trim(l).c_str());
|
||||
// No way out of its own.
|
||||
for (const char* name : { "exit(", "_Exit(", "abort(", "SDL_Quit(", "terminate(" })
|
||||
CHECK(!Has(l, name), "settings_menu.cpp:%zu: %s: %s", i + 1, name, Trim(l).c_str());
|
||||
if (Has(l, "ImGui::Button(\"Quit Game\")"))
|
||||
button = int(i);
|
||||
if (Has(l, "\"Quit SpeedBreaker? Progress since your last save will be lost.\""))
|
||||
text = int(i);
|
||||
if (Has(l, "ImGui::Button(\"Quit\")"))
|
||||
quitButton = int(i);
|
||||
if (Has(l, "ImGui::Button(\"Cancel\")") && quitButton >= 0 && cancelButton < 0 && int(i) > quitButton)
|
||||
cancelButton = int(i);
|
||||
if (Has(l, "s_quit.TakeFocus()"))
|
||||
focus = int(i);
|
||||
if (Has(l, "SetItemDefaultFocus") && cancelButton >= 0 && defaultFocus < 0)
|
||||
defaultFocus = int(i);
|
||||
if (Has(l, "SDL_PushEvent"))
|
||||
push = int(i);
|
||||
if (Has(l, "s_quit.Back()"))
|
||||
back = int(i);
|
||||
if (Has(l, "ImGui::Button(\"Close\")"))
|
||||
close = int(i);
|
||||
}
|
||||
CHECK(button > 0, "the footer has a Quit Game button");
|
||||
CHECK(close > 0 && button > close, "Quit Game comes after Close (line %d, Close %d)", button + 1, close + 1);
|
||||
CHECK(button > 0 && Has(lines[button], "s_quit.Choose()"), "Quit Game only asks (s_quit.Choose())");
|
||||
CHECK(text > 0, "the prompt says \"Quit SpeedBreaker? Progress since your last save will be lost.\"");
|
||||
CHECK(quitButton > text && cancelButton > quitButton, "Quit, then Cancel to its right (lines %d, %d)", quitButton + 1, cancelButton + 1);
|
||||
CHECK(focus > quitButton && focus < cancelButton && Has(lines[focus + 1], "SetKeyboardFocusHere"),
|
||||
"the first frame's focus goes to the button after it: Cancel (line %d)", focus + 1);
|
||||
CHECK(defaultFocus == cancelButton + 1, "Cancel is the prompt's default focus (line %d)", defaultFocus + 1);
|
||||
CHECK(back > 0 && Has(lines[back - 1] + lines[back - 2], "ImGuiKey_GamepadFaceRight") && Has(lines[back - 1] + lines[back - 2], "ImGuiKey_Escape"),
|
||||
"B and Escape cancel the prompt (line %d)", back + 1);
|
||||
CHECK(push > 0 && (Has(lines[push - 1], "SDL_EVENT_QUIT") || Has(lines[push - 2], "SDL_EVENT_QUIT") || Has(lines[push - 3], "SDL_EVENT_QUIT")),
|
||||
"the quit is an SDL_EVENT_QUIT pushed for the main loop (line %d)", push + 1);
|
||||
// The menu closing under it cancels (ui.cpp's CloseSettings).
|
||||
bool forget = false;
|
||||
for (const std::string& l : Lines(runtime + "/ui/ui.cpp"))
|
||||
forget |= Has(l, "ForgetQuitPrompt();");
|
||||
CHECK(forget, "ui.cpp's CloseSettings forgets the prompt");
|
||||
// The startup error page's Quit follows the same rule.
|
||||
bool startup = false;
|
||||
for (const std::string& l : Lines(runtime + "/ui/startup_error.cpp"))
|
||||
startup |= Has(l, "kCanQuit = quit::kOffered");
|
||||
CHECK(startup, "startup_error.cpp's Quit uses quit::kOffered");
|
||||
}
|
||||
|
||||
// The way out: RunFrame ends on SDL_EVENT_QUIT, and main() then shuts
|
||||
// down as for a window close.
|
||||
void WayOut(const std::string& runtime)
|
||||
{
|
||||
const std::vector<std::string> presenter = Lines(runtime + "/video/presenter.cpp");
|
||||
bool ends = false;
|
||||
for (size_t i = 0; i + 1 < presenter.size(); i++)
|
||||
if (Has(presenter[i], "e.type == SDL_EVENT_QUIT") && Has(presenter[i + 1], "return false;"))
|
||||
ends = true;
|
||||
CHECK(ends, "presenter.cpp's RunFrame returns false on SDL_EVENT_QUIT");
|
||||
|
||||
const std::vector<std::string> main = Lines(runtime + "/main.cpp");
|
||||
int loop = -1;
|
||||
for (size_t i = 0; i < main.size(); i++)
|
||||
if (Has(main[i], "while (video::RunFrame())"))
|
||||
loop = int(i);
|
||||
CHECK(loop > 0, "main.cpp's game loop runs video::RunFrame");
|
||||
bool save = false, renderer = false, video = false, flush = false, exit0 = false, otherExit = false;
|
||||
for (size_t i = loop + 1; loop > 0 && i < main.size() && Trim(main[i]) != "}"; i++)
|
||||
{
|
||||
save |= Has(main[i], "settings::Save()");
|
||||
renderer |= Has(main[i], "gpu::renderer::Shutdown()");
|
||||
video |= Has(main[i], "video::Shutdown()");
|
||||
flush |= Has(main[i], "report::FlushLog()");
|
||||
exit0 |= Has(main[i], "std::_Exit(0)");
|
||||
otherExit |= (Has(main[i], "_Exit(") || Has(main[i], "exit(")) && !Has(main[i], "std::_Exit(0)");
|
||||
}
|
||||
CHECK(save && renderer && video && flush, "after the loop: settings saved, renderer and video shut down, log flushed");
|
||||
CHECK(exit0 && !otherExit, "exit code 0");
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
Platforms();
|
||||
Steps();
|
||||
NoAccidentalQuit();
|
||||
if (argc > 1)
|
||||
{
|
||||
SettingsMenu(argv[1]);
|
||||
WayOut(argv[1]);
|
||||
}
|
||||
else
|
||||
printf(" (no runtime folder given: the code around it not checked)\n");
|
||||
if (g_failures)
|
||||
{
|
||||
printf("quit_game_test: %d FAILED\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("quit_game_test: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Tests for the Key options (Settings > Controls, user/settings.h): the
|
||||
// table, the [keys] names in settings.toml, and what the menu's Set accepts.
|
||||
// Links user/settings.cpp (and toml++ from XenonRecomp's vendored copy); no
|
||||
// SDL. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime -Itools/XenonRecomp/thirdparty/tomlplusplus/include \
|
||||
// tests/settings_keys_test.cpp runtime/user/settings.cpp -o build/settings_keys_test && build/settings_keys_test
|
||||
#include <user/key_names.h>
|
||||
#include <user/settings.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <unistd.h> // mkdtemp
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
static std::string ReadAll(const std::filesystem::path& file)
|
||||
{
|
||||
std::ifstream f(file);
|
||||
std::stringstream s;
|
||||
s << f.rdbuf();
|
||||
return s.str();
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
using namespace settings;
|
||||
using keynames::Code;
|
||||
|
||||
// The table: 48 Key options, every one in [keys], on the Controls page,
|
||||
// live, shown, with no environment variable and a default the key table
|
||||
// knows (none included).
|
||||
int keys = 0;
|
||||
std::set<std::string> names;
|
||||
for (const Option& o : Options())
|
||||
{
|
||||
if (o.type != Type::Key)
|
||||
{
|
||||
CHECK(std::string_view(o.table) != "keys", "%s.%s: only Key options live in [keys]", o.table, o.key);
|
||||
CHECK(o.env != nullptr, "%s.%s: every other option has an environment variable", o.table, o.key);
|
||||
continue;
|
||||
}
|
||||
keys++;
|
||||
CHECK(std::string_view(o.table) == "keys", "%s.%s: a Key option lives in [keys]", o.table, o.key);
|
||||
CHECK(o.category == Category::Controls, "%s: on the Controls page", o.key);
|
||||
CHECK(o.apply == Apply::Live && !o.hidden, "%s: live and shown", o.key);
|
||||
CHECK(o.env == nullptr, "%s: no environment variable", o.key);
|
||||
CHECK(names.insert(o.key).second, "%s: one row per key", o.key);
|
||||
CHECK(o.defaultValue >= 0 && o.defaultValue == std::floor(o.defaultValue) && keynames::Known(uint16_t(o.defaultValue)),
|
||||
"%s: the default is a key, or none", o.key);
|
||||
CHECK(o.min == 0 && o.max == 0 && o.step == 0 && o.entries.empty(), "%s: no range", o.key);
|
||||
}
|
||||
CHECK(keys == 48, "%d Key options", keys);
|
||||
CHECK(std::string_view(CategoryName(Category::Controls)) == "Controls", "the category's name");
|
||||
|
||||
// The defaults: v0.1.4's keys (the arrows steer as well as A and D, and
|
||||
// are the D-pad; O/L/K/; are the right stick), plus the left stick
|
||||
// click, which had none. In page order, in pairs: the second key is the first's id +
|
||||
// 1, named <key>_2, with the row's label.
|
||||
struct Default { Id id; const char* key; const char* label; const char* first; const char* second; };
|
||||
const Default defaults[] = {
|
||||
{ Id::KeyRightTrigger, "right_trigger", "Right Trigger", "w", "none" },
|
||||
{ Id::KeyLeftTrigger, "left_trigger", "Left Trigger", "s", "none" },
|
||||
{ Id::KeyLeftStickLeft, "left_stick_left", "Left Stick Left", "a", "left" },
|
||||
{ Id::KeyLeftStickRight, "left_stick_right", "Left Stick Right", "d", "right" },
|
||||
{ Id::KeyLeftStickUp, "left_stick_up", "Left Stick Up", "up", "none" },
|
||||
{ Id::KeyLeftStickDown, "left_stick_down", "Left Stick Down", "down", "none" },
|
||||
{ Id::KeyA, "a", "A", "space", "none" },
|
||||
{ Id::KeyB, "b", "B", "backspace", "none" },
|
||||
{ Id::KeyX, "x", "X", "left_shift", "none" },
|
||||
{ Id::KeyY, "y", "Y", "left_ctrl", "none" },
|
||||
{ Id::KeyLB, "lb", "LB", "q", "none" },
|
||||
{ Id::KeyRB, "rb", "RB", "e", "none" },
|
||||
{ Id::KeyDpadUp, "dpad_up", "D-pad Up", "up", "none" },
|
||||
{ Id::KeyDpadDown, "dpad_down", "D-pad Down", "down", "none" },
|
||||
{ Id::KeyDpadLeft, "dpad_left", "D-pad Left", "left", "none" },
|
||||
{ Id::KeyDpadRight, "dpad_right", "D-pad Right", "right", "none" },
|
||||
{ Id::KeyStart, "start", "Start", "return", "none" },
|
||||
{ Id::KeyBack, "back", "Back", "escape", "none" },
|
||||
{ Id::KeyRightStickUp, "right_stick_up", "Right Stick Up", "o", "none" },
|
||||
{ Id::KeyRightStickDown, "right_stick_down", "Right Stick Down", "l", "none" },
|
||||
{ Id::KeyRightStickLeft, "right_stick_left", "Right Stick Left", "k", "none" },
|
||||
{ Id::KeyRightStickRight, "right_stick_right", "Right Stick Right", "semicolon", "none" },
|
||||
{ Id::KeyLeftStickClick, "left_stick_click", "Left Stick Click", "z", "none" },
|
||||
{ Id::KeyRightStickClick, "right_stick_click", "Right Stick Click", "c", "none" },
|
||||
};
|
||||
size_t previous = size_t(Id::Vibration);
|
||||
for (const Default& d : defaults)
|
||||
{
|
||||
const Option& o = Info(d.id);
|
||||
const Option& o2 = Info(Id(size_t(d.id) + 1));
|
||||
CHECK(std::string_view(o.key) == d.key, "%s is %s", o.key, d.key);
|
||||
CHECK(std::string_view(o2.key) == std::string(d.key) + "_2", "%s: the second key is %s_2", o2.key, d.key);
|
||||
CHECK(o2.type == Type::Key, "%s: the second key is a Key option", o2.key);
|
||||
CHECK(std::string_view(o.label) == d.label && std::string_view(o2.label) == d.label, "%s: both keys carry the row's label", d.key);
|
||||
CHECK(o.defaultValue == double(Code(d.first)), "%s defaults to %s, not %s", o.key, d.first, FormatValue(o.id, o.defaultValue).c_str());
|
||||
CHECK(o2.defaultValue == double(Code(d.second)), "%s defaults to %s, not %s", o2.key, d.second, FormatValue(o2.id, o2.defaultValue).c_str());
|
||||
CHECK(size_t(d.id) == previous + 1, "%s follows the row before it", o.key);
|
||||
previous = size_t(d.id) + 1;
|
||||
}
|
||||
CHECK(size_t(Id::AsyncShaders) == previous + 1, "Advanced follows the keys");
|
||||
CHECK(EnvOverride(Id::KeyA) == nullptr, "nothing overrides a key");
|
||||
|
||||
// A scratch folder of our own.
|
||||
std::string pattern = (std::filesystem::temp_directory_path() / "speedbreaker-keys-XXXXXX").string();
|
||||
if (!mkdtemp(pattern.data()))
|
||||
{
|
||||
printf("can't make a scratch folder: %s\n", strerror(errno));
|
||||
return 2;
|
||||
}
|
||||
const std::filesystem::path dir = pattern;
|
||||
const std::filesystem::path file = dir / "settings.toml";
|
||||
|
||||
// The file: names in, in any case; "none" empties a key; anything else
|
||||
// keeps the default, SDL's spelling from the first build included.
|
||||
{
|
||||
std::ofstream f(file);
|
||||
f << "[keys]\n"
|
||||
"right_trigger = \"up\"\n"
|
||||
"left_trigger = \"Keypad_1\"\n"
|
||||
"left_stick_left_2 = \"none\"\n"
|
||||
"right_stick_left = \"K\"\n"
|
||||
"right_stick_up = \"LEFT_SHIFT\"\n"
|
||||
"a = \"Left Shift\"\n"
|
||||
"b = 42\n"
|
||||
"x = true\n"
|
||||
"y = \"\"\n"
|
||||
"lb = \"f1\"\n"
|
||||
"rb = \"left_gui\"\n";
|
||||
}
|
||||
CHECK(Load(file), "loads: %s", LastError().c_str());
|
||||
CHECK(GetInt(Id::KeyRightTrigger) == Code("up"), "\"up\": %d", GetInt(Id::KeyRightTrigger));
|
||||
CHECK(GetInt(Id::KeyLeftTrigger) == Code("keypad_1"), "a token in another case: %d", GetInt(Id::KeyLeftTrigger));
|
||||
CHECK(GetInt(Id::KeyLeftStickLeft2) == keynames::kNone, "\"none\" is no key: %d", GetInt(Id::KeyLeftStickLeft2));
|
||||
CHECK(GetInt(Id::KeyRightStickLeft) == Code("k"), "\"K\" is k: %d", GetInt(Id::KeyRightStickLeft));
|
||||
CHECK(GetInt(Id::KeyRightStickUp) == Code("left_shift"), "\"LEFT_SHIFT\" is left_shift: %d", GetInt(Id::KeyRightStickUp));
|
||||
CHECK(GetInt(Id::KeyA) == Code("space"), "SDL's spelling keeps the default");
|
||||
CHECK(GetInt(Id::KeyB) == Code("backspace"), "a number keeps the default");
|
||||
CHECK(GetInt(Id::KeyX) == Code("left_shift"), "a boolean keeps the default");
|
||||
CHECK(GetInt(Id::KeyY) == Code("left_ctrl"), "an empty name keeps the default");
|
||||
CHECK(GetInt(Id::KeyLB) == Code("q") && GetInt(Id::KeyRB) == Code("e"), "F1 and Cmd / Windows can't be bound from the file");
|
||||
CHECK(GetInt(Id::KeyStart) == Code("return") && GetInt(Id::KeyLeftStickRight2) == Code("right"), "a missing key keeps the default");
|
||||
CHECK(IsDefault(Id::KeyLB) && !IsDefault(Id::KeyRightTrigger) && !IsDefault(Id::KeyLeftStickLeft2), "IsDefault follows the file");
|
||||
|
||||
// Shown in the log and the bug report by name.
|
||||
CHECK(FormatValue(Id::KeyRightTrigger, GetValue(Id::KeyRightTrigger)) == "up", "FormatValue: %s",
|
||||
FormatValue(Id::KeyRightTrigger, GetValue(Id::KeyRightTrigger)).c_str());
|
||||
CHECK(FormatValue(Id::KeyX, Code("left_shift")) == "left_shift" && FormatValue(Id::KeyX, 0) == "none", "a token, or none");
|
||||
CHECK(FormatValue(Id::KeyA, 1) == "?" && FormatValue(Id::KeyA, 227) == "?" && FormatValue(Id::KeyA, 1e9) == "?" &&
|
||||
FormatValue(Id::KeyA, NAN) == "?" && FormatValue(Id::KeyA, -1) == "?", "not a key: ?");
|
||||
|
||||
// Set: a key of the table, or none; anything else is Invalid.
|
||||
CHECK(Set(Id::KeyA, Code("return")) == SetResult::Applied, "a key");
|
||||
CHECK(Set(Id::KeyA, Code("return")) == SetResult::Unchanged, "the same key again");
|
||||
CHECK(Set(Id::KeyA, keynames::kNone) == SetResult::Applied && GetInt(Id::KeyA) == 0, "none");
|
||||
CHECK(Set(Id::KeyA2, Code("return")) == SetResult::Applied && Set(Id::KeyA, Code("return")) == SetResult::Applied,
|
||||
"the same key in both cells of a row is allowed");
|
||||
CHECK(Set(Id::KeyA2, keynames::kNone) == SetResult::Applied, "and so is none in both");
|
||||
CHECK(Set(Id::KeyA, 1) == SetResult::Invalid, "1 is no key");
|
||||
CHECK(Set(Id::KeyA, 58) == SetResult::Invalid, "F1 is no key");
|
||||
CHECK(Set(Id::KeyA, 227) == SetResult::Invalid, "Left GUI is no key");
|
||||
CHECK(Set(Id::KeyA, keynames::kUnknown) == SetResult::Invalid && Set(Id::KeyA, 1e9) == SetResult::Invalid &&
|
||||
Set(Id::KeyA, -1) == SetResult::Invalid, "out of the table");
|
||||
CHECK(Set(Id::KeyA, double(Code("return")) + 0.5) == SetResult::Invalid, "not a whole number");
|
||||
CHECK(Set(Id::KeyA, NAN) == SetResult::Invalid, "NaN");
|
||||
CHECK(GetInt(Id::KeyA) == Code("return"), "invalid sets change nothing: %d", GetInt(Id::KeyA));
|
||||
CHECK(StepValue(Id::KeyA, Code("return"), 1) == double(Code("return")) && StepValue(Id::KeyA, Code("return"), -1) == double(Code("return")),
|
||||
"Left and Right do nothing to a key");
|
||||
CHECK(!IsDefault(Id::KeyA) && Reset(Id::KeyA) == SetResult::Applied && IsDefault(Id::KeyA) && GetInt(Id::KeyA) == Code("space"), "Reset");
|
||||
|
||||
// Save: every key by name, the comment, no environment variable; and
|
||||
// what it wrote loads back.
|
||||
CHECK(Save(file), "saves: %s", LastError().c_str());
|
||||
std::string text = ReadAll(file);
|
||||
size_t at = text.find("\n[keys]\n"), advanced = text.find("\n[advanced]\n");
|
||||
CHECK(at != std::string::npos && advanced != std::string::npos && at < advanced, "[keys] before [advanced]");
|
||||
std::string section = at == std::string::npos ? "" : text.substr(at, advanced == std::string::npos ? std::string::npos : advanced - at);
|
||||
CHECK(section.find("right_trigger = \"up\"\n") != std::string::npos, "saved by name");
|
||||
CHECK(section.find("left_trigger = \"keypad_1\"\n") != std::string::npos, "the table's case, not the file's");
|
||||
CHECK(section.find("left_stick_left_2 = \"none\"\n") != std::string::npos, "an emptied key");
|
||||
CHECK(section.find("right_stick_left = \"k\"\n") != std::string::npos, "lower case");
|
||||
CHECK(section.find("right_stick_right = \"semicolon\"\n") != std::string::npos, "a symbol key by its name");
|
||||
CHECK(section.find("right_stick_up = \"left_shift\"\n") != std::string::npos, "a modifier");
|
||||
CHECK(section.find("left_stick_left = \"a\"\n") != std::string::npos && section.find("dpad_left_2 = \"none\"\n") != std::string::npos,
|
||||
"defaults are written too, both keys");
|
||||
CHECK(section.find("# A key name such as \"w\"") != std::string::npos, "the comment says what goes here");
|
||||
CHECK(section.find("NFSMW_") == std::string::npos, "no environment variable on a key");
|
||||
CHECK(text.find("(NFSMW_VIBRATION)") != std::string::npos, "the other options keep theirs");
|
||||
CHECK(Load(file), "loads what it saved: %s", LastError().c_str());
|
||||
CHECK(GetInt(Id::KeyRightTrigger) == Code("up") && GetInt(Id::KeyLeftTrigger) == Code("keypad_1") &&
|
||||
GetInt(Id::KeyRightStickUp) == Code("left_shift") && GetInt(Id::KeyLeftStickLeft2) == 0, "round trip");
|
||||
CHECK(!Dirty(), "nothing changed since the save");
|
||||
|
||||
// The keys SDL names by a symbol are words here: nothing to escape.
|
||||
CHECK(Set(Id::KeyA, Code("backslash")) == SetResult::Applied && Set(Id::KeyB, Code("apostrophe")) == SetResult::Applied,
|
||||
"the backslash and apostrophe keys are keys");
|
||||
CHECK(Save(file), "saves them: %s", LastError().c_str());
|
||||
text = ReadAll(file);
|
||||
CHECK(text.find("\na = \"backslash\"\n") != std::string::npos && text.find("\nb = \"apostrophe\"\n") != std::string::npos, "by name");
|
||||
CHECK(Load(file), "and the file still parses: %s", LastError().c_str());
|
||||
CHECK(GetInt(Id::KeyA) == Code("backslash") && GetInt(Id::KeyB) == Code("apostrophe"), "round trip: %d %d", GetInt(Id::KeyA), GetInt(Id::KeyB));
|
||||
CHECK(GetInt(Id::KeyRightTrigger) == Code("up") && GetInt(Id::MasterVolume) == 100, "and the rest of the file survived");
|
||||
std::filesystem::remove_all(dir);
|
||||
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Graphics > Upscaling's values in settings.toml (user/settings.cpp,
|
||||
// linked as the game links it): "fsr1" (FSR 1, v0.1.5) read, saved and read
|
||||
// back, next to "bicubic" and "linear"; the menu's order (Bicubic, Bilinear,
|
||||
// FSR 1); NFSMW_SCALING=fsr1 for one run, never saved; a token this version
|
||||
// doesn't know (a later version's) falls back to the default, Bicubic; and
|
||||
// Sharpening's default, 0.3, unchanged.
|
||||
// The argument is a scratch folder (settings.toml is written there).
|
||||
//
|
||||
// Build by hand (from the repo root):
|
||||
// c++ -std=c++20 -O2 -UNDEBUG -I runtime tests/settings_scaling_test.cpp runtime/user/settings.cpp -o build/settings_scaling_test
|
||||
// build/settings_scaling_test build/settings_scaling_scratch
|
||||
#include <user/settings.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failures = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
s_failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fprintf(stderr, "\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
using settings::Id;
|
||||
using settings::ScalingMode;
|
||||
|
||||
std::string Read(const std::filesystem::path& path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::stringstream text;
|
||||
text << in.rdbuf();
|
||||
return text.str();
|
||||
}
|
||||
|
||||
void Write(const std::filesystem::path& path, const std::string& text)
|
||||
{
|
||||
std::ofstream(path, std::ios::binary) << text;
|
||||
}
|
||||
|
||||
int32_t Scaling() { return settings::GetInt(Id::Scaling); }
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
unsetenv("NFSMW_SCALING");
|
||||
unsetenv("NFSMW_SHARPEN");
|
||||
const std::filesystem::path dir = argc > 1 ? argv[1] : "settings_scaling_scratch";
|
||||
std::filesystem::remove_all(dir);
|
||||
std::filesystem::create_directories(dir);
|
||||
const std::filesystem::path file = dir / "settings.toml";
|
||||
|
||||
// The option: three entries in menu order, Bicubic the default.
|
||||
const settings::Option& o = settings::Info(Id::Scaling);
|
||||
CHECK(o.entries.size() == 3, "%zu entries", o.entries.size());
|
||||
if (o.entries.size() == 3)
|
||||
{
|
||||
CHECK(o.entries[0].value == int32_t(ScalingMode::Bicubic) && std::string(o.entries[0].token) == "bicubic", "first: bicubic");
|
||||
CHECK(o.entries[1].value == int32_t(ScalingMode::Linear) && std::string(o.entries[1].token) == "linear", "second: linear");
|
||||
CHECK(o.entries[2].value == int32_t(ScalingMode::Fsr1) && std::string(o.entries[2].token) == "fsr1", "third: fsr1");
|
||||
}
|
||||
CHECK(settings::DefaultValue(Id::Scaling) == double(ScalingMode::Bicubic), "default bicubic");
|
||||
CHECK(settings::FormatValue(Id::Scaling, double(ScalingMode::Fsr1)) == "FSR 1", "label %s", settings::FormatValue(Id::Scaling, 2).c_str());
|
||||
CHECK(settings::StepValue(Id::Scaling, double(ScalingMode::Linear), 1) == double(ScalingMode::Fsr1), "Bilinear, right: FSR 1");
|
||||
CHECK(settings::StepValue(Id::Scaling, double(ScalingMode::Fsr1), -1) == double(ScalingMode::Linear), "FSR 1, left: Bilinear");
|
||||
|
||||
// No file: the defaults.
|
||||
CHECK(settings::Load(file), "load without a file: %s", settings::LastError().c_str());
|
||||
CHECK(Scaling() == int32_t(ScalingMode::Bicubic), "no file: %d", Scaling());
|
||||
CHECK(std::fabs(settings::GetFloat(Id::Sharpening) - 0.3f) < 1e-6f, "Sharpening's default 0.3: %.3f", settings::GetFloat(Id::Sharpening));
|
||||
|
||||
// Chosen in the menu, saved, read back.
|
||||
CHECK(settings::Set(Id::Scaling, double(ScalingMode::Fsr1)) == settings::SetResult::Applied, "set FSR 1");
|
||||
CHECK(Scaling() == int32_t(ScalingMode::Fsr1), "in effect at once: %d", Scaling());
|
||||
CHECK(settings::Save(file), "save: %s", settings::LastError().c_str());
|
||||
const std::string saved = Read(file);
|
||||
CHECK(saved.find("scaling = \"fsr1\"") != std::string::npos, "saved as a token:\n%s", saved.c_str());
|
||||
CHECK(settings::Set(Id::Scaling, double(ScalingMode::Linear)) == settings::SetResult::Applied, "set bilinear");
|
||||
CHECK(settings::Load(file) && Scaling() == int32_t(ScalingMode::Fsr1), "read back: %d", Scaling());
|
||||
|
||||
// Each token by hand.
|
||||
for (auto [token, value] : { std::pair{ "bicubic", ScalingMode::Bicubic }, std::pair{ "linear", ScalingMode::Linear }, std::pair{ "fsr1", ScalingMode::Fsr1 } })
|
||||
{
|
||||
Write(file, std::string("[video]\nscaling = \"") + token + "\"\n");
|
||||
CHECK(settings::Load(file) && Scaling() == int32_t(value), "%s: %d", token, Scaling());
|
||||
}
|
||||
// A later version's value: the default, and the file isn't rewritten by loading it.
|
||||
Write(file, "[video]\nscaling = \"fsr3\"\n");
|
||||
settings::Load(file);
|
||||
CHECK(Scaling() == int32_t(ScalingMode::Bicubic), "unknown token: %d", Scaling());
|
||||
CHECK(Read(file).find("fsr3") != std::string::npos, "the file is left as it was");
|
||||
|
||||
// NFSMW_SCALING=fsr1 for this run: in effect, never saved.
|
||||
Write(file, "[video]\nscaling = \"linear\"\n");
|
||||
setenv("NFSMW_SCALING", "fsr1", 1);
|
||||
CHECK(settings::Load(file), "load with the environment");
|
||||
CHECK(Scaling() == int32_t(ScalingMode::Fsr1), "environment: %d", Scaling());
|
||||
CHECK(settings::EnvOverride(Id::Scaling) != nullptr, "the menu shows the override");
|
||||
CHECK(settings::GetStoredValue(Id::Scaling) == double(ScalingMode::Linear), "stored: %g", settings::GetStoredValue(Id::Scaling));
|
||||
CHECK(settings::Save(file) && Read(file).find("scaling = \"linear\"") != std::string::npos, "saved: the file's value:\n%s", Read(file).c_str());
|
||||
unsetenv("NFSMW_SCALING");
|
||||
|
||||
std::filesystem::remove_all(dir);
|
||||
if (s_failures)
|
||||
{
|
||||
printf("%d check(s) FAILED\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// video::SwapchainUse (runtime/video/swapchain_use.h): the present waiter's
|
||||
// thread uses a swapchain (its present waits, the driver's reports) only
|
||||
// while it is the current one, and the main thread never destroys one under
|
||||
// such a use. v0.1.4 and v0.1.5 read the driver's reports
|
||||
// (vkGetPastPresentationTimingGOOGLE) after the wait's lock, so a fullscreen
|
||||
// toggle on a 120 Hz Mac could destroy the swapchain under the read: an abort,
|
||||
// "mutex lock failed". Fake swapchains carry a lifetime canary: a use that
|
||||
// reaches a destroyed one, or another one than its present's, is counted.
|
||||
// The optional argument is runtime/video/presenter.cpp: every waiter call that
|
||||
// takes a swapchain must be inside a use, and the swapchain retired before it
|
||||
// is destroyed (checked, then planted mutations of it that must fail).
|
||||
//
|
||||
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/swapchain_use_test.cpp -o build/swapchain_use_test
|
||||
#include <video/swapchain_use.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
namespace
|
||||
{
|
||||
// A swapchain as the waiter sees it. Never freed (a pool), so a use after
|
||||
// its destruction is seen, not undefined: `alive` is the canary.
|
||||
struct FakeSwapchain
|
||||
{
|
||||
std::atomic<bool> alive{ false };
|
||||
std::atomic<uint64_t> generation{ 0 }; // the one it was published as
|
||||
std::atomic<int> users{ 0 };
|
||||
};
|
||||
using Use = video::SwapchainUse<FakeSwapchain*>;
|
||||
|
||||
// A use of `s` for a present of generation `generation`: true if it was
|
||||
// sound (alive, the present's own swapchain) throughout.
|
||||
bool Touch(FakeSwapchain* s, uint64_t generation, int spins = 0)
|
||||
{
|
||||
s->users++;
|
||||
bool ok = s->alive.load() && s->generation.load() == generation;
|
||||
for (volatile int i = 0; i < spins; i = i + 1)
|
||||
;
|
||||
ok = ok && s->alive.load();
|
||||
s->users--;
|
||||
return ok;
|
||||
}
|
||||
|
||||
void Basics()
|
||||
{
|
||||
printf("publish, use, retire:\n");
|
||||
FakeSwapchain a, b;
|
||||
Use use;
|
||||
int ran = 0;
|
||||
CHECK(!use.Use(0, [&](FakeSwapchain*) { ran++; }) && ran == 0, "nothing published: no use");
|
||||
const uint64_t ga = use.Publish(&a);
|
||||
CHECK(ga != 0, "a generation is never 0");
|
||||
FakeSwapchain* seen = nullptr;
|
||||
CHECK(use.Use(ga, [&](FakeSwapchain* s) { seen = s; }) && seen == &a, "the published one's generation: used, with it");
|
||||
CHECK(!use.Use(0, [&](FakeSwapchain*) { ran++; }) && ran == 0, "generation 0 (no swapchain): no use");
|
||||
CHECK(!use.Use(ga + 1, [&](FakeSwapchain*) { ran++; }) && ran == 0, "a generation not published yet: no use");
|
||||
use.Retire();
|
||||
CHECK(!use.Use(ga, [&](FakeSwapchain*) { ran++; }) && ran == 0, "retired: no use");
|
||||
const uint64_t gb = use.Publish(&b);
|
||||
CHECK(gb != ga, "each swapchain its own generation");
|
||||
CHECK(!use.Use(ga, [&](FakeSwapchain*) { ran++; }) && ran == 0, "an older swapchain's present: no use of the new one");
|
||||
CHECK(use.Use(gb, [&](FakeSwapchain* s) { seen = s; }) && seen == &b, "the new one: used");
|
||||
use.Retire();
|
||||
use.Retire();
|
||||
CHECK(!use.Use(gb, [&](FakeSwapchain*) { ran++; }) && ran == 0, "retired twice (Shutdown after a destroy): no use");
|
||||
}
|
||||
|
||||
// MoltenVK's and Mesa's handles are pointers: a new swapchain can get a
|
||||
// destroyed one's. A present to the old one is not the new one's.
|
||||
void HandleReuse()
|
||||
{
|
||||
printf("a new swapchain with a destroyed one's handle:\n");
|
||||
FakeSwapchain a;
|
||||
Use use;
|
||||
const uint64_t g1 = use.Publish(&a);
|
||||
use.Retire();
|
||||
const uint64_t g2 = use.Publish(&a); // the same handle again
|
||||
int ran = 0;
|
||||
CHECK(g1 != g2, "a new generation");
|
||||
CHECK(!use.Use(g1, [&](FakeSwapchain*) { ran++; }) && ran == 0, "the old present isn't waited for on the new swapchain");
|
||||
CHECK(use.Use(g2, [&](FakeSwapchain*) { ran++; }) && ran == 1, "the new one's present is");
|
||||
}
|
||||
|
||||
// Retire() must not return while a use is under way (the main thread
|
||||
// destroys the swapchain right after it), and none may start after it.
|
||||
void RetireWaits()
|
||||
{
|
||||
printf("retire waits for a use under way:\n");
|
||||
FakeSwapchain a;
|
||||
a.alive = true;
|
||||
Use use;
|
||||
const uint64_t g = use.Publish(&a);
|
||||
a.generation = g;
|
||||
std::atomic<bool> inside{ false }, done{ false };
|
||||
std::thread user([&] {
|
||||
use.Use(g, [&](FakeSwapchain*) {
|
||||
inside = true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
done = true;
|
||||
});
|
||||
});
|
||||
while (!inside)
|
||||
std::this_thread::yield();
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
use.Retire();
|
||||
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
CHECK(done, "Retire returned while the use was still under way (after %.1f ms)", ms);
|
||||
user.join();
|
||||
int ran = 0;
|
||||
CHECK(!use.Use(g, [&](FakeSwapchain*) { ran++; }) && ran == 0, "no use after the retire");
|
||||
}
|
||||
|
||||
struct StressCounts
|
||||
{
|
||||
uint64_t unsound = 0, destroyedInUse = 0, used = 0, refused = 0;
|
||||
};
|
||||
|
||||
// The presenter's own shape: the main thread destroys the swapchain and
|
||||
// makes another, again and again (fullscreen toggles); the waiter waits
|
||||
// for presents and reads the driver's reports after each wait, for
|
||||
// presents of the current swapchain and of older ones still queued. The
|
||||
// new swapchain mostly gets the old one's handle (slot) again.
|
||||
// `v015Order`: the reports read after the wait's use, with the present's
|
||||
// handle, as v0.1.4 and v0.1.5 did.
|
||||
StressCounts RunStress(bool v015Order, int cycles)
|
||||
{
|
||||
FakeSwapchain pool[3];
|
||||
Use use;
|
||||
std::atomic<uint64_t> lastPresent{ 0 }; // the generation the latest present was for
|
||||
std::atomic<bool> stop{ false };
|
||||
std::atomic<uint64_t> unsound{ 0 }, used{ 0 }, refused{ 0 };
|
||||
uint64_t destroyedInUse = 0;
|
||||
std::thread waiter([&] {
|
||||
uint64_t seen[4] = {};
|
||||
for (uint64_t n = 0; !stop; n++)
|
||||
{
|
||||
// A present just queued, or (every other time) one queued a
|
||||
// few presents ago, perhaps to a swapchain since destroyed.
|
||||
seen[n % 4] = lastPresent.load();
|
||||
const uint64_t g = (n % 2) ? seen[(n + 1) % 4] : seen[n % 4];
|
||||
FakeSwapchain* handle = nullptr;
|
||||
const bool waited = use.Use(g, [&](FakeSwapchain* s) { // the wait
|
||||
handle = s;
|
||||
if (!Touch(s, g, 200))
|
||||
unsound++;
|
||||
});
|
||||
(waited ? used : refused)++;
|
||||
if (!v015Order)
|
||||
{
|
||||
const bool read = use.Use(g, [&](FakeSwapchain* s) { // the driver's reports
|
||||
if (!Touch(s, g, 200))
|
||||
unsound++;
|
||||
});
|
||||
(read ? used : refused)++;
|
||||
}
|
||||
else if (handle && !Touch(handle, g, 200))
|
||||
unsound++;
|
||||
std::this_thread::yield();
|
||||
}
|
||||
});
|
||||
FakeSwapchain* current = nullptr;
|
||||
for (int i = 0; i < cycles; i++)
|
||||
{
|
||||
// DestroySwapchain: retire, then destroy.
|
||||
use.Retire();
|
||||
if (current)
|
||||
{
|
||||
destroyedInUse += current->users.load() != 0;
|
||||
current->alive = false;
|
||||
}
|
||||
// CreateSwapchain: make one and publish it; presents to it follow.
|
||||
size_t slot = current ? size_t(current - pool) : 0;
|
||||
if (i % 4 == 0)
|
||||
slot = (slot + 1) % 3;
|
||||
current = &pool[slot];
|
||||
current->alive = true;
|
||||
const uint64_t g = use.Publish(current);
|
||||
current->generation = g;
|
||||
for (int p = 0; p < 3; p++)
|
||||
{
|
||||
lastPresent = g;
|
||||
for (volatile int k = 0; k < 300; k = k + 1)
|
||||
;
|
||||
}
|
||||
}
|
||||
stop = true;
|
||||
waiter.join();
|
||||
return { unsound.load(), destroyedInUse, used.load(), refused.load() };
|
||||
}
|
||||
|
||||
void Stress()
|
||||
{
|
||||
printf("the waiter against swapchains destroyed and made again:\n");
|
||||
constexpr int kCycles = 20000;
|
||||
const StressCounts c = RunStress(false, kCycles);
|
||||
CHECK(c.unsound == 0, "%llu uses reached a destroyed swapchain, or another than their present's", (unsigned long long)c.unsound);
|
||||
CHECK(c.destroyedInUse == 0, "%llu swapchains destroyed while in use", (unsigned long long)c.destroyedInUse);
|
||||
CHECK(c.used > 1000 && c.refused > 0, "the waiter both used swapchains (%llu) and was refused stale ones (%llu)",
|
||||
(unsigned long long)c.used, (unsigned long long)c.refused);
|
||||
printf(" %d recreations: %llu uses, %llu refused, %llu unsound\n", kCycles, (unsigned long long)c.used,
|
||||
(unsigned long long)c.refused, (unsigned long long)c.unsound);
|
||||
// The same with v0.1.5's order must reach destroyed swapchains (it
|
||||
// is a race: up to 5 tries), or the canary proves nothing.
|
||||
uint64_t old = 0;
|
||||
int tries = 0;
|
||||
while (old == 0 && tries < 5)
|
||||
{
|
||||
old += RunStress(true, kCycles).unsound;
|
||||
tries++;
|
||||
}
|
||||
CHECK(old > 0, "v0.1.5's order (the reports read after the wait's lock) never reached a destroyed swapchain in %d tries", tries);
|
||||
printf(" v0.1.5's order: %llu unsound uses (%d tr%s)\n", (unsigned long long)old, tries, tries == 1 ? "y" : "ies");
|
||||
}
|
||||
|
||||
// The orders v0.1.5 had, step by step: Touch() catches each, so the
|
||||
// canary would have caught the bug.
|
||||
void OldOrders()
|
||||
{
|
||||
printf("v0.1.5's orders, step by step (each must be caught):\n");
|
||||
// 1. The wait checked the swapchain under the lock, and the reports
|
||||
// were read after it: the swapchain destroyed in between.
|
||||
{
|
||||
FakeSwapchain a;
|
||||
Use use;
|
||||
const uint64_t g = use.Publish(&a);
|
||||
a.alive = true;
|
||||
a.generation = g;
|
||||
FakeSwapchain* waited = nullptr;
|
||||
use.Use(g, [&](FakeSwapchain* s) { waited = s; }); // the wait
|
||||
use.Retire(); // DestroySwapchain
|
||||
a.alive = false;
|
||||
CHECK(!Touch(waited, g), "the report read after the lock reaches the destroyed swapchain: not caught");
|
||||
}
|
||||
// 2. The present named its swapchain by handle: the waiter, holding a
|
||||
// present of the destroyed one, found the new one at that handle.
|
||||
{
|
||||
FakeSwapchain a;
|
||||
Use use;
|
||||
const uint64_t g1 = use.Publish(&a);
|
||||
a.alive = true;
|
||||
a.generation = g1;
|
||||
FakeSwapchain* presentHandle = &a;
|
||||
use.Retire();
|
||||
a.alive = false;
|
||||
const uint64_t g2 = use.Publish(&a);
|
||||
a.alive = true;
|
||||
a.generation = g2;
|
||||
FakeSwapchain* current = &a; // v0.1.5: if (t.swapchain == s_waitSwapchain) wait on it
|
||||
CHECK(presentHandle == current && !Touch(current, g1), "the old present waited for on the new swapchain: not caught");
|
||||
}
|
||||
}
|
||||
|
||||
// presenter.cpp's calls that take the waiter's swapchain, and what the
|
||||
// main thread does around destroying one; the problems found.
|
||||
std::vector<std::string> SourceProblems(const std::string& src)
|
||||
{
|
||||
std::vector<std::string> problems;
|
||||
const std::string use = "s_swapchainUse.Use(";
|
||||
for (const char* call : { "s_waitForPresent(s_device", "s_pastTiming(s_device" })
|
||||
{
|
||||
int found = 0;
|
||||
for (size_t at = src.find(call); at != std::string::npos; at = src.find(call, at + 1))
|
||||
{
|
||||
found++;
|
||||
const size_t open = src.rfind(use, at);
|
||||
if (open == std::string::npos || src.find("});", open) < at)
|
||||
problems.push_back(std::string(call) + " outside a use, at offset " + std::to_string(at));
|
||||
}
|
||||
if (!found)
|
||||
problems.push_back(std::string("no ") + call + " call found");
|
||||
}
|
||||
// A function's body: from its head to the brace at its own indent.
|
||||
auto body = [&](const char* head) {
|
||||
const size_t at = src.find(head);
|
||||
if (at == std::string::npos)
|
||||
return std::string();
|
||||
const size_t line = src.rfind('\n', at) + 1;
|
||||
const std::string end = "\n" + src.substr(line, at - line) + "}\n";
|
||||
return src.substr(at, src.find(end, at) - at);
|
||||
};
|
||||
const std::string destroy = body("void DestroySwapchain()");
|
||||
const size_t retire = destroy.find("s_swapchainUse.Retire();");
|
||||
const size_t destroyCall = destroy.find("vkDestroySwapchainKHR(");
|
||||
if (destroy.empty() || retire == std::string::npos || destroyCall == std::string::npos || retire > destroyCall)
|
||||
problems.push_back("DestroySwapchain doesn't retire the swapchain before destroying it");
|
||||
size_t destroys = 0;
|
||||
for (size_t at = src.find("vkDestroySwapchainKHR("); at != std::string::npos; at = src.find("vkDestroySwapchainKHR(", at + 1))
|
||||
destroys++;
|
||||
if (destroys != 1)
|
||||
problems.push_back("vkDestroySwapchainKHR called " + std::to_string(destroys) + " times (only DestroySwapchain may)");
|
||||
const std::string create = body("bool CreateSwapchain()");
|
||||
const size_t made = create.find("vkCreateSwapchainKHR(");
|
||||
const size_t published = create.find("s_swapchainGeneration = s_swapchainUse.Publish(s_swapchain);");
|
||||
if (made == std::string::npos || published == std::string::npos || published < made)
|
||||
problems.push_back("CreateSwapchain doesn't publish the swapchain it made");
|
||||
if (src.find("s_timings.push_back({ id, s_swapchainGeneration,") == std::string::npos)
|
||||
problems.push_back("a present's timing doesn't name its swapchain's generation");
|
||||
if (body("void Shutdown()").find("s_swapchainUse.Retire();") == std::string::npos)
|
||||
problems.push_back("Shutdown doesn't retire the swapchain");
|
||||
return problems;
|
||||
}
|
||||
|
||||
std::string Replaced(std::string s, const std::string& from, const std::string& to)
|
||||
{
|
||||
const size_t at = s.find(from);
|
||||
if (at == std::string::npos)
|
||||
return {};
|
||||
return s.replace(at, from.size(), to);
|
||||
}
|
||||
|
||||
void Presenter(const char* path)
|
||||
{
|
||||
printf("presenter.cpp:\n");
|
||||
std::ifstream in(path);
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
const std::string src = ss.str();
|
||||
CHECK(!src.empty(), "can't read %s", path);
|
||||
if (src.empty())
|
||||
return;
|
||||
for (const std::string& p : SourceProblems(src))
|
||||
CHECK(false, "%s", p.c_str());
|
||||
// Planted mutations, each a way back to the bug: each must be found.
|
||||
struct Plant
|
||||
{
|
||||
const char* name;
|
||||
std::string from, to;
|
||||
};
|
||||
const Plant plants[] = {
|
||||
{ "the reports read after the use (v0.1.5's order)", " const int64_t hostToSteady = HostToSteadyNs();",
|
||||
" s_pastTiming(s_device, VK_NULL_HANDLE, &count, nullptr);\n const int64_t hostToSteady = HostToSteadyNs();" },
|
||||
{ "a present wait outside a use", " VkResult r = VK_ERROR_OUT_OF_DATE_KHR; // its swapchain is gone\n",
|
||||
" VkResult r = s_waitForPresent(s_device, VK_NULL_HANDLE, t.id, 0);\n" },
|
||||
{ "no retire before the destroy", " s_swapchainUse.Retire(); // the waiter is off it after this", "" },
|
||||
{ "a second destroy elsewhere", " s_swapchainUse.Retire(); // the present waiter is off the swapchain",
|
||||
" vkDestroySwapchainKHR(s_device, s_swapchain, nullptr); // the present waiter is off the swapchain" },
|
||||
{ "never published", "s_swapchainGeneration = s_swapchainUse.Publish(s_swapchain);", "s_swapchainGeneration = 0;" },
|
||||
{ "presents named by handle", "s_timings.push_back({ id, s_swapchainGeneration,", "s_timings.push_back({ id, s_swapchain," },
|
||||
};
|
||||
for (const Plant& p : plants)
|
||||
{
|
||||
const std::string mutated = Replaced(src, p.from, p.to);
|
||||
CHECK(!mutated.empty(), "plant \"%s\": its text isn't in presenter.cpp", p.name);
|
||||
if (!mutated.empty())
|
||||
{
|
||||
const bool caught = !SourceProblems(mutated).empty();
|
||||
CHECK(caught, "plant \"%s\" not caught", p.name);
|
||||
printf(" plant: %s: %s\n", p.name, caught ? "caught" : "NOT caught");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
Basics();
|
||||
HandleReuse();
|
||||
RetireWaits();
|
||||
Stress();
|
||||
OldOrders();
|
||||
if (argc > 1)
|
||||
Presenter(argv[1]);
|
||||
else
|
||||
printf("(presenter.cpp not given: its checks skipped)\n");
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// The guest vblank's timer (runtime/video/vblank_timer.cpp) on this machine:
|
||||
// how late a thread sleeping to vblanks 16.667 ms apart wakes, as an ordinary
|
||||
// thread and as the Vsync thread prepares itself, alone and with every core
|
||||
// kept busy by threads of the class the game's own busy threads run at (the
|
||||
// command processor, the renderer, audio: user-interactive on macOS).
|
||||
// From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/vblank_timer_test.cpp runtime/video/vblank_timer.cpp -o build/vblank_timer_test
|
||||
// build/vblank_timer_test [seconds per run, default 3]
|
||||
// Checked on macOS only, where the thread changed (2026-10-12): prepared, it
|
||||
// wakes within 1.5 ms in 99% of vblanks and 0.5 ms at the median, loaded or
|
||||
// not, real-time scheduling granted. The ordinary thread's numbers are
|
||||
// printed beside it (the vblank's until then: 3.2 ms late at the median in
|
||||
// the game, up to 9).
|
||||
#include <video/vblank_timer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
#include <pthread.h>
|
||||
#endif
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int64_t kPeriodNs = 16'666'667;
|
||||
|
||||
int64_t NowNs()
|
||||
{
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
struct Result
|
||||
{
|
||||
video::VblankTiming::Summary late;
|
||||
bool realtime = false;
|
||||
};
|
||||
|
||||
// A thread as the Vsync thread runs: prepared, then sleeping to each
|
||||
// vblank in turn (the time it should have fired, not the time it woke).
|
||||
Result Run(video::VblankTimer timer, double seconds)
|
||||
{
|
||||
Result r;
|
||||
std::thread t([&] {
|
||||
r.realtime = video::PrepareVblankThread(timer, kPeriodNs);
|
||||
video::VblankTiming timing;
|
||||
int64_t next = NowNs() + kPeriodNs;
|
||||
const int count = int(seconds * 60.0);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
std::this_thread::sleep_until(std::chrono::steady_clock::time_point(std::chrono::nanoseconds(next)));
|
||||
timing.Add(NowNs() - next, 0);
|
||||
next += kPeriodNs;
|
||||
}
|
||||
r.late = timing.Summarize();
|
||||
});
|
||||
t.join();
|
||||
return r;
|
||||
}
|
||||
|
||||
// Every core busy (each thread spins, yielding now and then, as the
|
||||
// command processor's idle poll does).
|
||||
struct Load
|
||||
{
|
||||
std::atomic<bool> stop{ false };
|
||||
std::vector<std::thread> threads;
|
||||
explicit Load(unsigned count)
|
||||
{
|
||||
for (unsigned i = 0; i < count; i++)
|
||||
threads.emplace_back([this] {
|
||||
#ifdef __APPLE__
|
||||
pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0);
|
||||
#endif
|
||||
volatile uint64_t x = 0;
|
||||
while (!stop.load(std::memory_order_relaxed))
|
||||
{
|
||||
for (int k = 0; k < 20000; k++)
|
||||
x = x + uint64_t(k);
|
||||
std::this_thread::yield();
|
||||
}
|
||||
});
|
||||
}
|
||||
~Load()
|
||||
{
|
||||
stop = true;
|
||||
for (std::thread& t : threads)
|
||||
t.join();
|
||||
}
|
||||
};
|
||||
|
||||
void Print(const char* name, const Result& r)
|
||||
{
|
||||
printf(" %-44s late %.3f ms (median), %.3f ms (99%%), %.3f ms at most (%u vblanks)%s\n", name, r.late.lateMedianNs / 1e6,
|
||||
r.late.late99Ns / 1e6, r.late.lateMaxNs / 1e6, r.late.count, r.realtime ? "; real-time" : "");
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
const double seconds = argc > 1 ? std::max(1.0, std::atof(argv[1])) : 3.0;
|
||||
const unsigned cores = std::max(2u, std::thread::hardware_concurrency());
|
||||
printf("vblanks every 16.667 ms, %.0f s each; %u cores\n", seconds, cores);
|
||||
Result ordinaryIdle = Run(video::VblankTimer::Ordinary, seconds);
|
||||
Print("an ordinary thread, idle machine", ordinaryIdle);
|
||||
Result vsyncIdle = Run(video::VblankTimer::RealTime, seconds);
|
||||
Print("the Vsync thread's, idle machine", vsyncIdle);
|
||||
Result ordinaryLoaded, vsyncLoaded;
|
||||
{
|
||||
Load load(cores);
|
||||
ordinaryLoaded = Run(video::VblankTimer::Ordinary, seconds);
|
||||
Print("an ordinary thread, every core busy", ordinaryLoaded);
|
||||
vsyncLoaded = Run(video::VblankTimer::RealTime, seconds);
|
||||
Print("the Vsync thread's, every core busy", vsyncLoaded);
|
||||
}
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
for (const Result* r : { &vsyncIdle, &vsyncLoaded })
|
||||
{
|
||||
CHECK(r->late.late99Ns <= 1.5e6, "the Vsync thread within 1.5 ms in 99%% of vblanks (%.3f ms)", r->late.late99Ns / 1e6);
|
||||
CHECK(r->late.lateMedianNs <= 0.5e6, "and 0.5 ms at the median (%.3f ms)", r->late.lateMedianNs / 1e6);
|
||||
CHECK(r->realtime, "real-time scheduling granted");
|
||||
}
|
||||
#else
|
||||
printf(" (not checked here: the Vsync thread is an ordinary one on this platform)\n");
|
||||
#endif
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
+236
-10
@@ -4,8 +4,9 @@
|
||||
// build/whole_vblanks_test [frames.csv[@30] ...]
|
||||
// Without arguments: synthetic frames against vblanks. With NFSMW_GAME_TIME_LOG
|
||||
// files (measured_ms,vblanks,...; "@30" after a path: Frame Rate 30): also the
|
||||
// game time each rule gives their frames (those of 0-6 vblanks), against the
|
||||
// measured time and the vblanks that fired.
|
||||
// game time each rule gives their frames (all but loads: those of 0 to
|
||||
// WholeVblanks::kLoadVblanks), against the measured time and the vblanks that
|
||||
// fired.
|
||||
#include <game/whole_vblanks.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -58,12 +59,48 @@ namespace
|
||||
}
|
||||
};
|
||||
|
||||
// v0.1.3's rule (2026-10-11 to 2026-10-12): as WholeVblanks, but a
|
||||
// frame of more than 6 vblanks kept its measured time and started over
|
||||
// (ahead 0), and nothing was owed beyond kSlack. Below 15 fps the frame
|
||||
// after each such one, kSlack behind again, got kSlack fewer than fired.
|
||||
struct V013Rule
|
||||
{
|
||||
double ahead = 0.0;
|
||||
static constexpr double kSlack = game::WholeVblanks::kSlack;
|
||||
uint64_t Give(double measuredMs, uint64_t vblanks, double vblankMs)
|
||||
{
|
||||
if (vblanks > 6 || !(vblankMs > 0.0))
|
||||
{
|
||||
ahead = 0.0;
|
||||
return 0;
|
||||
}
|
||||
ahead -= double(vblanks);
|
||||
if (ahead + 1.0 > kSlack)
|
||||
{
|
||||
ahead = std::min(ahead + std::max(measuredMs, 0.0) / vblankMs, kSlack);
|
||||
return 0;
|
||||
}
|
||||
int64_t give = 1;
|
||||
if (ahead + 1.0 < -kSlack)
|
||||
give = std::clamp<int64_t>(std::llround(-kSlack - ahead), 1, 6);
|
||||
ahead = std::max(ahead + double(give), -kSlack);
|
||||
return uint64_t(give);
|
||||
}
|
||||
};
|
||||
|
||||
struct Frame
|
||||
{
|
||||
double ms;
|
||||
uint64_t vblanks;
|
||||
};
|
||||
|
||||
// The world steps a frame runs: what it was given, or its measured time
|
||||
// in steps, and at most 6 (the stepper's cap; the steps over it are dropped).
|
||||
double WorldSteps(uint64_t given, double ms, double vblankMs)
|
||||
{
|
||||
return given ? double(std::min<uint64_t>(given, 6)) : std::min(ms / vblankMs, 6.0);
|
||||
}
|
||||
|
||||
// GitHub issue #9's mechanism: the main loop runs up to `queue` frames
|
||||
// ahead of a display that shows one frame a vblank (the render thread
|
||||
// and the GPU ring hold them). A frame's work is 4-10 ms, and with
|
||||
@@ -121,8 +158,9 @@ namespace
|
||||
|
||||
struct Totals
|
||||
{
|
||||
double measuredMs = 0, vblankMs = 0, newMs = 0, oldMs = 0;
|
||||
uint64_t frames = 0, given = 0, paced = 0, pacedGivenOne = 0;
|
||||
double measuredMs = 0, vblankMs = 0, newMs = 0, oldMs = 0, v013Ms = 0;
|
||||
double newSteps = 0, v013Steps = 0; // world steps (6 a frame at most)
|
||||
uint64_t frames = 0, given = 0, paced = 0, pacedGivenOne = 0, notAsV013 = 0;
|
||||
int maxSteps = 0;
|
||||
};
|
||||
|
||||
@@ -130,14 +168,20 @@ namespace
|
||||
Totals Play(const std::vector<Frame>& frames, double vblankMs)
|
||||
{
|
||||
game::WholeVblanks rule;
|
||||
V013Rule v013;
|
||||
Totals t;
|
||||
for (const Frame& f : frames)
|
||||
{
|
||||
uint64_t k = rule.Give(f.ms, f.vblanks, vblankMs), old = HalfVblankRule(f.ms, f.vblanks, vblankMs);
|
||||
uint64_t k = rule.Give(f.ms, f.vblanks, vblankMs), old = HalfVblankRule(f.ms, f.vblanks, vblankMs),
|
||||
prev = v013.Give(f.ms, f.vblanks, vblankMs);
|
||||
t.measuredMs += f.ms;
|
||||
t.vblankMs += double(f.vblanks) * vblankMs;
|
||||
t.newMs += k ? double(k) * vblankMs : f.ms;
|
||||
t.oldMs += old ? double(old) * vblankMs : f.ms;
|
||||
t.v013Ms += prev ? double(prev) * vblankMs : f.ms;
|
||||
t.newSteps += WorldSteps(k, f.ms, vblankMs);
|
||||
t.v013Steps += WorldSteps(prev, f.ms, vblankMs);
|
||||
t.notAsV013 += k != prev;
|
||||
t.frames++;
|
||||
t.given += k != 0;
|
||||
t.maxSteps = std::max(t.maxSteps, int(k));
|
||||
@@ -335,6 +379,184 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
// GPU-bound frames below 15 fps: `meanMs` +- `jitterMs`, and with
|
||||
// probability `spikeP` one `spikeMs` to 2 x `spikeMs` longer (a texture
|
||||
// upload, streaming). Vblanks every kRefreshMs, each 0-0.5 ms late; a
|
||||
// frame's count is those after its start up to the next one's.
|
||||
std::vector<Frame> SlowFrames(double meanMs, double jitterMs, double spikeP, double spikeMs, int count, uint32_t seed)
|
||||
{
|
||||
std::mt19937 rng(seed);
|
||||
std::uniform_real_distribution<double> unit(0.0, 1.0);
|
||||
std::vector<double> starts;
|
||||
double t = unit(rng) * kRefreshMs;
|
||||
for (int i = 0; i <= count; i++)
|
||||
{
|
||||
starts.push_back(t);
|
||||
double ms = meanMs + (2 * unit(rng) - 1) * jitterMs;
|
||||
if (unit(rng) < spikeP)
|
||||
ms += spikeMs * (1.0 + unit(rng));
|
||||
t += std::max(ms, 1.0);
|
||||
}
|
||||
std::vector<double> fired;
|
||||
for (double due = 0.0; due <= t + kRefreshMs; due += kRefreshMs)
|
||||
fired.push_back(due + unit(rng) * 0.5);
|
||||
std::vector<Frame> out;
|
||||
size_t v = 0;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
uint64_t n = 0;
|
||||
while (v < fired.size() && fired[v] <= starts[size_t(i)])
|
||||
v++;
|
||||
while (v < fired.size() && fired[v] <= starts[size_t(i) + 1])
|
||||
v++, n++;
|
||||
out.push_back({ starts[size_t(i) + 1] - starts[size_t(i)], n });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Below 15 fps (an Intel UHD laptop GPU at 1.5x, ~12 fps,
|
||||
// a frame of 7 or more vblanks every second or so): game time follows the
|
||||
// vblanks that fired, frames over 6 included; loads start over.
|
||||
void LowFps()
|
||||
{
|
||||
using game::WholeVblanks;
|
||||
const double owedFloor = -WholeVblanks::kSlack - WholeVblanks::kMaxOwed;
|
||||
// Frame by frame.
|
||||
{
|
||||
WholeVblanks r;
|
||||
// GPU-bound at 12 fps: kSlack behind after the first, then each
|
||||
// its count. The frames before judge the rate: from the fourth
|
||||
// one it is below 20 fps.
|
||||
CHECK(r.Give(83.0, 5, kRefreshMs) == 2 && r.ahead == -WholeVblanks::kSlack, "12 fps, the first frame: 2 (%.2f)", r.ahead);
|
||||
for (int i = 0; i < 3; i++)
|
||||
CHECK(r.Give(83.0, 5, kRefreshMs) == 5 && r.ahead == -WholeVblanks::kSlack, "then its count: 5");
|
||||
CHECK(r.frameMs >= WholeVblanks::kLowRateMs, "below 20 fps after 4 frames of 83 ms (%.1f ms)", r.frameMs);
|
||||
// A frame of 7: 6 (the stepper's cap), 1 owed; the next of 5 gets 6.
|
||||
CHECK(r.Give(117.0, 7, kRefreshMs) == 6 && r.ahead == -WholeVblanks::kSlack - 1.0, "7 fired: 6, one owed (%.2f)", r.ahead);
|
||||
CHECK(r.Give(83.0, 5, kRefreshMs) == 6 && r.ahead == -WholeVblanks::kSlack, "5 fired, one owed: 6, level again (%.2f)", r.ahead);
|
||||
// A frame of 12: 6, and 6 owed, given back one a frame by frames of 5.
|
||||
CHECK(r.Give(200.0, 12, kRefreshMs) == 6 && r.ahead == -WholeVblanks::kSlack - 6.0, "12 fired: 6, 6 owed (%.2f)", r.ahead);
|
||||
int extra = 0;
|
||||
for (int i = 0; i < 6; i++)
|
||||
extra += int(r.Give(83.0, 5, kRefreshMs)) - 5;
|
||||
CHECK(extra == 6 && r.ahead == -WholeVblanks::kSlack, "frames of 5 give the 6 back, one each (%d, %.2f)", extra, r.ahead);
|
||||
// The short frame after a long one (the loop catching up) gives
|
||||
// back at most its own count again: a frame of 1 gets 2.
|
||||
r.Give(200.0, 12, kRefreshMs);
|
||||
CHECK(r.Give(16.6, 1, kRefreshMs) == 2 && r.ahead == -WholeVblanks::kSlack - 5.0, "a frame of 1, 6 owed: 2 (%.2f)", r.ahead);
|
||||
for (int i = 0; i < 5; i++)
|
||||
r.Give(83.0, 5, kRefreshMs);
|
||||
CHECK(r.ahead == -WholeVblanks::kSlack, "the rest by the frames of 5 (%.2f)", r.ahead);
|
||||
// At 8 fps (frames of 7-8: more than 6 on average) more is owed
|
||||
// than comes back, up to kMaxOwed beyond kSlack.
|
||||
for (int i = 0; i < 40; i++)
|
||||
CHECK(r.Give(125.0, 7 + uint64_t(i % 2), kRefreshMs) == 6, "8 fps: 6 a frame");
|
||||
CHECK(r.ahead == owedFloor, "owed kMaxOwed beyond kSlack, no more (%.2f)", r.ahead);
|
||||
// Back at 60 fps: 2 a frame while the frames before still average
|
||||
// 50 ms or more, then the rest is forgotten (no catch-up at 60 fps).
|
||||
std::string got;
|
||||
int back = 0;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
uint64_t k = r.Give(16.6, 1, kRefreshMs);
|
||||
back += int(k) - 1;
|
||||
got += std::to_string(k) + " ";
|
||||
}
|
||||
CHECK(back >= 2 && back <= 8 && r.ahead == -WholeVblanks::kSlack, "60 fps again: 2 a frame for a few, then 1 (%s; %.2f)",
|
||||
got.c_str(), r.ahead);
|
||||
// At 60 fps a long frame is a hitch, as in v0.1.3: its measured
|
||||
// time and a fresh start, nothing owed, so no frame after it runs
|
||||
// more than one step (GitHub issue #9's frames).
|
||||
for (int i = 0; i < 20; i++)
|
||||
r.Give(16.6, 1, kRefreshMs);
|
||||
CHECK(r.Give(300.0, 18, kRefreshMs) == 0 && r.ahead == 0.0, "a 300 ms hitch at 60 fps: measured time, a fresh start (%.2f)",
|
||||
r.ahead);
|
||||
CHECK(r.Give(4.0, 0, kRefreshMs) == 1 && r.Give(16.6, 1, kRefreshMs) == 1 && r.Give(16.6, 1, kRefreshMs) == 1,
|
||||
"after it: one a frame, the queue catching up");
|
||||
// Half a second is still a frame; more is a load: measured time,
|
||||
// nothing owed after it, the rate judged afresh.
|
||||
r.Reset();
|
||||
for (int i = 0; i < 4; i++)
|
||||
r.Give(83.0, 5, kRefreshMs);
|
||||
r.Give(200.0, 12, kRefreshMs);
|
||||
CHECK(r.ahead < -WholeVblanks::kSlack, "owing again (%.2f)", r.ahead);
|
||||
CHECK(r.Give(500.0, WholeVblanks::kLoadVblanks, kRefreshMs) == 6, "30 vblanks: not a load, 6");
|
||||
CHECK(r.Give(2000.0, 120, kRefreshMs) == 0 && r.ahead == 0.0 && r.frameMs == WholeVblanks::kHighRateMs,
|
||||
"a load (2 s): measured time, nothing owed (%.2f)", r.ahead);
|
||||
CHECK(r.Give(83.0, 5, kRefreshMs) == 2 && r.ahead == -WholeVblanks::kSlack, "after it: 2, as from a fresh start (%.2f)", r.ahead);
|
||||
r.Give(200.0, 12, kRefreshMs);
|
||||
CHECK(r.Give(510.0, WholeVblanks::kLoadVblanks + 1, kRefreshMs) == 0 && r.ahead == 0.0, "31 vblanks: a load");
|
||||
}
|
||||
|
||||
// Long runs: game time over the vblanks that fired (frames over 6
|
||||
// included), and world steps (6 a frame at most).
|
||||
struct Case
|
||||
{
|
||||
const char* name;
|
||||
double meanMs, jitterMs, spikeP, spikeMs;
|
||||
// Game time within kSlack + kMaxOwed (+1) of the vblanks all
|
||||
// run long: the frames have room to give back what longer ones
|
||||
// owe before kMaxOwed is reached. (With frames of 11-18 vblanks
|
||||
// now and then, two close together can reach it: a little is
|
||||
// forgotten.)
|
||||
bool keepsUp;
|
||||
double minRatio; // game time over the vblanks, at least
|
||||
};
|
||||
const Case cases[] = {
|
||||
{ "Intel UHD at 1.5x: 12 fps (80 ms +-35), a 140-235 ms frame every ~1.5 s", 80.0, 35.0, 1.0 / 20, 60.0, true, 0.999 },
|
||||
{ "15 fps (67 ms +-20), a 190-310 ms frame every ~2 s", 67.0, 20.0, 1.0 / 30, 120.0, false, 0.999 },
|
||||
{ "10.5 fps (95 ms +-15)", 95.0, 15.0, 0.0, 0.0, true, 0.999 },
|
||||
{ "11 fps (83 ms +-25), a 170-250 ms frame every ~1 s", 83.0, 25.0, 1.0 / 12, 85.0, false, 0.995 },
|
||||
{ "8 fps (125 ms +-25): more than 6 vblanks a frame", 125.0, 25.0, 0.0, 0.0, false, 0.0 },
|
||||
};
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
double fired = 0, given = 0, v013 = 0, steps = 0, v013Steps = 0, worst = 0, lowest = 0;
|
||||
uint64_t frames = 0, over6 = 0;
|
||||
int maxGive = 0;
|
||||
for (int p = 0; p < 20; p++)
|
||||
{
|
||||
std::vector<Frame> run = SlowFrames(c.meanMs, c.jitterMs, c.spikeP, c.spikeMs, 3000, 4321u + uint32_t(p));
|
||||
WholeVblanks rule;
|
||||
V013Rule prev;
|
||||
double runFired = 0, runGiven = 0;
|
||||
for (const Frame& f : run)
|
||||
{
|
||||
uint64_t k = rule.Give(f.ms, f.vblanks, kRefreshMs), o = prev.Give(f.ms, f.vblanks, kRefreshMs);
|
||||
runFired += double(f.vblanks);
|
||||
runGiven += k ? double(k) : f.ms / kRefreshMs;
|
||||
v013 += o ? double(o) : f.ms / kRefreshMs;
|
||||
steps += WorldSteps(k, f.ms, kRefreshMs);
|
||||
v013Steps += WorldSteps(o, f.ms, kRefreshMs);
|
||||
over6 += f.vblanks > 6;
|
||||
maxGive = std::max(maxGive, int(k));
|
||||
lowest = std::min(lowest, rule.ahead);
|
||||
if (c.keepsUp)
|
||||
worst = std::max(worst, std::abs(runGiven - runFired));
|
||||
}
|
||||
fired += runFired;
|
||||
given += runGiven;
|
||||
frames += run.size();
|
||||
}
|
||||
printf(" %-56s %.1f%% of frames over 6; game time over vblanks fired %.4f (v0.1.3 %.4f), world steps %.4f (v0.1.3 %.4f)",
|
||||
c.name, 100.0 * double(over6) / double(frames), given / fired, v013 / fired, steps / fired, v013Steps / fired);
|
||||
if (c.keepsUp)
|
||||
printf("; within %.2f vblanks\n", worst);
|
||||
else
|
||||
printf("; owed %.2f at most\n", -lowest);
|
||||
CHECK(maxGive <= 6, "%s: at most 6 a frame (%d)", c.name, maxGive);
|
||||
CHECK(lowest >= owedFloor, "%s: never owed more than kSlack + kMaxOwed (%.2f)", c.name, lowest);
|
||||
if (c.keepsUp)
|
||||
CHECK(worst <= WholeVblanks::kSlack + WholeVblanks::kMaxOwed + 1.0,
|
||||
"%s: game time within kSlack + kMaxOwed + 1 of the vblanks over 20 x 3000 frames (%.2f)", c.name, worst);
|
||||
CHECK(given / fired >= c.minRatio && steps / fired >= c.minRatio, "%s: game time and world steps follow the vblanks (x%.4f, x%.4f)",
|
||||
c.name, given / fired, steps / fired);
|
||||
if (c.minRatio > 0.0)
|
||||
CHECK(v013 / fired < 0.985 && v013Steps / fired < 0.985, "%s: v0.1.3's rule ran slow here (x%.4f, world x%.4f)", c.name,
|
||||
v013 / fired, v013Steps / fired);
|
||||
}
|
||||
}
|
||||
|
||||
// NFSMW_GAME_TIME_LOG rows: measured_ms,vblanks,... (the frame's own; the
|
||||
// rule used then doesn't change them).
|
||||
void Replay(const std::string& arg)
|
||||
@@ -360,18 +582,20 @@ namespace
|
||||
{
|
||||
double ms;
|
||||
unsigned long long k;
|
||||
if (sscanf(line.c_str(), "%lf,%llu", &ms, &k) == 2 && k <= 6)
|
||||
if (sscanf(line.c_str(), "%lf,%llu", &ms, &k) == 2 && k <= game::WholeVblanks::kLoadVblanks)
|
||||
frames.push_back({ ms, k });
|
||||
}
|
||||
if (frames.empty())
|
||||
{
|
||||
printf(" %s: no frames of 0-6 vblanks\n", path.c_str());
|
||||
printf(" %s: no frames of 0-%llu vblanks\n", path.c_str(), (unsigned long long)game::WholeVblanks::kLoadVblanks);
|
||||
return;
|
||||
}
|
||||
Totals t = Play(frames, vblankMs);
|
||||
printf(" %s: %llu frames of 0-6 vblanks, %.1f s measured; game time over measured: new rule %.4f, half-vblank rule %.4f, "
|
||||
"vblanks %.4f\n", path.c_str(), (unsigned long long)t.frames, t.measuredMs / 1000, t.newMs / t.measuredMs, t.oldMs / t.measuredMs,
|
||||
t.vblankMs / t.measuredMs);
|
||||
printf(" %s: %llu frames of 0-%llu vblanks, %.1f s measured; game time over measured: new rule %.4f, v0.1.3 rule %.4f, "
|
||||
"half-vblank rule %.4f, vblanks %.4f; world steps over vblanks: new %.4f, v0.1.3 %.4f; frames given otherwise than by v0.1.3's "
|
||||
"rule %llu\n", path.c_str(), (unsigned long long)t.frames, (unsigned long long)game::WholeVblanks::kLoadVblanks,
|
||||
t.measuredMs / 1000, t.newMs / t.measuredMs, t.v013Ms / t.measuredMs, t.oldMs / t.measuredMs, t.vblankMs / t.measuredMs,
|
||||
t.newSteps * vblankMs / t.vblankMs, t.v013Steps * vblankMs / t.vblankMs, (unsigned long long)t.notAsV013);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +605,8 @@ int main(int argc, char** argv)
|
||||
Synthetic();
|
||||
printf("a main loop queued ahead of the display (GitHub issue #9):\n");
|
||||
QueuedLoops();
|
||||
printf("below 15 fps: frames over 6 vblanks, and loads:\n");
|
||||
LowFps();
|
||||
if (argc > 1)
|
||||
printf("logs:\n");
|
||||
for (int i = 1; i < argc; i++)
|
||||
|
||||
Reference in new issue
Block a user