Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c206ab98 | ||
|
|
b49fc321d1 | ||
|
|
34e1f75901 |
No files matched your search
@@ -4,6 +4,121 @@ 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.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
|
||||
|
||||
- Smoother driving: no more small hitch every few seconds (the car jumped ahead, then stood
|
||||
still for a frame or two), which frame-time graphs didn't show (GitHub issue #9). Thanks to
|
||||
m9cy884mv6-a11y for the careful report.
|
||||
- Sun shadows of cars, walls and tunnel roofs: cars cast their hard shadow in sunlight again, and
|
||||
walls, barriers and tunnel roofs shade the road and the cars, as on the Xbox 360. Inside tunnels
|
||||
the car no longer flickers between sunlight and tree shadows (GitHub issue #3). Thanks to the
|
||||
players who reported the flicker on GitHub and the missing car shadows on Reddit.
|
||||
- Nitrous light trails are as long as on the Xbox 360 again: at 60 fps they were half as long, and
|
||||
looked brighter (GitHub issue #10). Thanks to jaswinprakash for the report, and to victorgbd,
|
||||
whose Android port of another Most Wanted recompilation found the cause.
|
||||
- Pause menu > Performance Tuning: each slider has its own name again (Steering, Handling, Brakes,
|
||||
Ride Height, Aerodynamics, Nitrous, Turbo or Supercharger) instead of "Steering" on every row
|
||||
(GitHub issue #8).
|
||||
|
||||
### More about it
|
||||
|
||||
- The hitch: the game's main loop runs a couple of frames ahead of the screen. When one of its
|
||||
frames took longer, SpeedBreaker gave it two or three steps of game time and the next ones
|
||||
none, while the screen still showed one frame per refresh. A frame the screen shows for one
|
||||
refresh now runs one step. The game's speed is unchanged.
|
||||
- The shadows: the game draws cars, walls and tunnel roofs into its sun shadow map as plain solid
|
||||
shapes, which on the Xbox 360 run no pixel shader. SpeedBreaker ran the trees' leaf-cutting
|
||||
shader on them too, and it threw them all away. Checked against real Xbox 360 footage of the
|
||||
same race. Sunlit roads are as bright as in v0.1.1 and v0.1.2.
|
||||
- The light trails: the game draws them from where the car was over its last two frames, and the
|
||||
Xbox 360 races at 30 fps, so at 60 fps they covered half the distance. They now span the same
|
||||
time as on the 360. At Frame Rate 30 nothing changes.
|
||||
- The tuning labels: the recompiled code looked up each row's name in a table that had lost all
|
||||
but its first entry.
|
||||
- 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.2
|
||||
|
||||
### What's new
|
||||
|
||||
@@ -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.2 LANGUAGES C CXX)
|
||||
project(SpeedBreaker VERSION 0.1.4 LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<b>An unofficial static recompilation of the Xbox 360 version of <i>Need for Speed: Most Wanted</i> (2005):<br>
|
||||
a native port, not an emulator.<br>
|
||||
Play your own copy on Steam Deck, Steam Machine and Steam Frame (SteamOS), Linux and Mac,<br>
|
||||
or build it yourself for iPhone and iPad.</b>
|
||||
and for iPhone and iPad, sideload the experimental IPA or build it yourself.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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, 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.
|
||||
- **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
|
||||
@@ -101,8 +102,9 @@ and a settings menu you can open mid-race.
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Screenshots: SpeedBreaker on a Mac Studio (M4 Max). They are unedited frames from the game's
|
||||
own output, except that the licence plate in the top image is blurred. The game content in them
|
||||
<sub>Screenshots: SpeedBreaker v0.1.2 on a Mac Studio (M4 Max). They are unedited frames from the
|
||||
game's own output, except that the licence plates in the top image and the first race image are
|
||||
blurred. The game content in them
|
||||
belongs to its owners.</sub>
|
||||
|
||||
## Download
|
||||
@@ -111,9 +113,9 @@ belongs to its owners.</sub>
|
||||
|---|---|---|
|
||||
| **Steam Deck** and **Steam Machine** (SteamOS) | [Download][dl-steamos-x86_64]<br>`SpeedBreaker-steamos-x86_64.tar.xz` | Steam Deck OLED (SteamOS 3.9.2). Steam Machine (SteamOS 3.8) on a 3440x1440 ultrawide. The Steam Deck LCD hasn't been tested yet. |
|
||||
| **Steam Frame** (SteamOS, arm64) | [Download][dl-steamos-arm64]<br>`SpeedBreaker-steamos-arm64.tar.xz` | Steam Frame (SteamOS 0.3.0). It plays as a big flat screen in the headset; it is not a VR mode. |
|
||||
| **Linux** (x86-64) | [Download][dl-steamos-x86_64] the x86-64 SteamOS build, `SpeedBreaker-steamos-x86_64.tar.xz`, or [build from source](#building-from-source) | SteamOS here. Players report it working on Fedora 44 (GeForce GTX 1080 Ti) and Ubuntu 24.04 (Intel Arc 140T). Other setups: reports welcome. |
|
||||
| **Linux** (x86-64) | [Download][dl-steamos-x86_64] the x86-64 SteamOS build, `SpeedBreaker-steamos-x86_64.tar.xz`, or [build from source](#building-from-source) | Tested here on SteamOS. Players reported v0.1.1 working on Fedora 44 (GeForce GTX 1080 Ti) and Ubuntu 24.04 (Intel Arc 140T). Other setups: reports welcome. |
|
||||
| **Mac** (Apple silicon) | [Download][dl-macos]<br>`SpeedBreaker-macos.dmg` | MacBook Pro (M1 Pro) on macOS 27 and Mac Studio (M4 Max) on macOS 26; macOS 15.6 (Sequoia) in a virtual machine. There is no Intel Mac build. |
|
||||
| **iPhone** and **iPad** | [Sideload the IPA](#iphone-and-ipad) (experimental), `SpeedBreaker-<version>-ios.ipa` on the [release page][releases], or [build it yourself](#iphone-and-ipad) with Xcode | iPhone 15 Pro Max and iPhone 18 Pro Max (iOS 27). iPad Pro 12.9-inch (M2, iPadOS 27) with a Backbone Pro. Not on the App Store. The IPA hasn't been tested with a free Apple Account yet. |
|
||||
| **iPhone** and **iPad** | [Sideload the IPA](#iphone-and-ipad) (experimental), `SpeedBreaker-<version>-ios.ipa` on the [release page][releases], or [build it yourself](#iphone-and-ipad) with Xcode | iPhone 15 Pro Max and iPhone 18 Pro Max (iOS 27). iPad Pro 12.9-inch (M2, iPadOS 27) with a Backbone Pro. Not on the App Store. Those were Xcode-signed builds: the IPA itself hasn't been installed on a device yet. |
|
||||
| **Windows** | Coming soon | |
|
||||
| **Android** | Coming soon | |
|
||||
|
||||
@@ -131,8 +133,9 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
|
||||
- **Steam Deck, Steam Machine and Linux PCs** (the x86-64 SteamOS download): a 64-bit x86 CPU
|
||||
with AVX2 (x86-64-v3): Intel Haswell (2013) or newer, or AMD Excavator, Zen or newer. A
|
||||
Vulkan 1.2 graphics card.
|
||||
- **Steam Frame** (the arm64 SteamOS download): SteamOS on the Steam Frame. The arm64 download
|
||||
is for the Frame only; it hasn't been tested on any other arm64 Linux device.
|
||||
- **Steam Frame** (the arm64 SteamOS download): SteamOS on the Steam Frame, kept up to date
|
||||
(v0.1.2 needs the September 2026 release or later). The arm64 download is for the Frame only;
|
||||
it hasn't been tested on any other arm64 Linux device.
|
||||
- **Mac:** Apple silicon and **macOS 15** (Sequoia) or later. The app won't open on older
|
||||
versions of macOS, and there is no Intel Mac build.
|
||||
- **iPhone and iPad:** iOS or iPadOS 17 or later, on an A15-class chip or newer (iPhone 13 and
|
||||
@@ -260,18 +263,20 @@ SpeedBreaker plays as a big flat screen in the headset. It is not a VR mode.
|
||||
|
||||
There's no separate desktop-Linux package yet. Try the x86-64 SteamOS download,
|
||||
[`SpeedBreaker-steamos-x86_64.tar.xz`][dl-steamos-x86_64], which brings its own runtime libraries
|
||||
but hasn't been tested outside SteamOS, or [build from source](#building-from-source). Unpack it,
|
||||
and is tested here on SteamOS (players reported v0.1.1 working on Fedora 44 and Ubuntu 24.04), or
|
||||
[build from source](#building-from-source). Unpack it,
|
||||
put your `.iso` in your home folder, Downloads, Desktop, Documents or Games (or on a USB drive),
|
||||
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 one (NVIDIA or AMD); with NVIDIA's
|
||||
driver the launcher also turns on PRIME render offload. The log's `[video] Vulkan devices:` line
|
||||
says which GPU it chose and why. To pick another, start it with `NFSMW_GPU=<its number or part of
|
||||
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
|
||||
yet: the log's `[video] Vulkan devices:` line says which GPU it chose and why, and a report with
|
||||
that line from yours would help. To pick another, start it with `NFSMW_GPU=<its number or part of
|
||||
its name>`, e.g. `NFSMW_GPU=intel %command%` in Steam's launch options.
|
||||
|
||||
### Mac
|
||||
@@ -295,7 +300,9 @@ 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. While you play, the mouse
|
||||
pointer hides after two seconds without moving. SpeedBreaker needs macOS 15 (Sequoia) or later.
|
||||
|
||||
### iPhone and iPad
|
||||
|
||||
@@ -305,10 +312,12 @@ SpeedBreaker isn't on the App Store. There are two ways to get it onto your iPho
|
||||
on the [release page][releases]. A sideloading tool such as AltStore signs it with your own Apple
|
||||
Account and installs it. The game asks iOS for more memory than most apps, and a free Apple
|
||||
Account can't give all of it, so with a free account it may not start, or iOS may close it in
|
||||
busy scenes. When iOS refuses the memory, the game says so on screen. It hasn't been tested with
|
||||
a free Apple Account yet: read
|
||||
[Sideloading the IPA](scripts/ios/README.md#sideloading-the-ipa-experimental) first, and please
|
||||
report how it goes.
|
||||
busy scenes. When iOS refuses the memory, the game says so on screen. The IPA itself hasn't been
|
||||
installed on a device yet (the tested iPhones and iPad ran the same app built and signed with
|
||||
Xcode): read [Sideloading the IPA](scripts/ios/README.md#sideloading-the-ipa-experimental)
|
||||
first, and please report how it goes, with your device and tool. Coming from a build you made
|
||||
yourself, the IPA installs as a separate app: install the game again from your disc image, and
|
||||
your saves stay with the old app.
|
||||
- **Build it yourself** with Xcode on a Mac, signed with your own Apple developer account:
|
||||
[scripts/ios/README.md](scripts/ios/README.md) walks you through it.
|
||||
|
||||
@@ -345,12 +354,15 @@ 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 | 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.
|
||||
- The game shifts gears with the right stick, so from the keyboard you shift with O and L.
|
||||
Holding both keys of a pair centres the stick.
|
||||
- There is no button remapping and no mouse control in game.
|
||||
|
||||
**To open the settings:**
|
||||
@@ -368,7 +380,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>
|
||||
@@ -377,11 +391,17 @@ 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**
|
||||
|
||||
@@ -392,7 +412,7 @@ Each page has a *Reset page* button.
|
||||
| Sharpening | 0.00 to 1.00 | 0.30 (0 turns it off) |
|
||||
| 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 (1x, 2x or 3x). On iPhone and iPad, Auto always picks 1x. |
|
||||
| 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. |
|
||||
|
||||
**Audio:** Master Volume (100%), Mute (Off), and Mute in Background (Off), which silences the
|
||||
game while its window isn't in front.
|
||||
@@ -411,15 +431,20 @@ 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
|
||||
Steam Frame headset.
|
||||
- **Between 1x and 2x,** try 1.5x (1080p): much sharper than 1x for a little over half the work
|
||||
of 2x.
|
||||
- **Between 1x and 2x,** try 1.5x (1080p): much sharper than 1x, with a little over half the
|
||||
pixels of 2x.
|
||||
- **On iPhone and iPad,** keep Internal Resolution at Auto (1x). On the tested iPad Pro (M2), 2x
|
||||
is too slow for races. The iPhone 18 Pro Max mostly holds 60 fps at 1.5x, but gets hot within
|
||||
about ten minutes, and Frame Rate Auto then drops to 30. Screen Resolution 75% saves more.
|
||||
is too slow for races. The iPhone 18 Pro Max holds 60 fps at 1.5x, but on a cable it reached
|
||||
iOS's "serious" heat level in about nine minutes (1x did in about six), and Frame Rate Auto then
|
||||
drops to 30. Screen Resolution 75% saves more.
|
||||
- **Docked Deck or Steam Machine:** set the shortcut's Game Resolution to Native.
|
||||
- **See what's happening:** Settings > Advanced > Performance Overlay.
|
||||
|
||||
@@ -466,9 +491,10 @@ or later on Apple silicon.
|
||||
**The game stutters on my Steam Deck OLED.** Set the screen to 60 Hz (see
|
||||
[Steam Deck setup](#steam-deck-and-steam-machine)).
|
||||
|
||||
**My laptop runs the game on the slow built-in graphics.** Since v0.1.2 SpeedBreaker picks the
|
||||
dedicated GPU by itself. If it still doesn't, see the `[video] Vulkan devices:` line in
|
||||
`last-run.log` and set `NFSMW_GPU` (see [Linux](#linux)).
|
||||
**My laptop runs the game on the slow built-in graphics.** Since v0.1.2 SpeedBreaker is meant to
|
||||
pick the dedicated GPU by itself (not yet confirmed on a real two-GPU laptop). If it doesn't, see
|
||||
the `[video] Vulkan devices:` line in `last-run.log`, set `NFSMW_GPU` (see [Linux](#linux)), and
|
||||
please open an issue with that line.
|
||||
|
||||
**The picture is small or stretched on my TV or monitor in Game Mode.** Set the shortcut's
|
||||
Properties > General > Game Resolution to Native.
|
||||
@@ -491,6 +517,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.
|
||||
|
||||
@@ -513,7 +550,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.2-<date>_<time>.zip`.
|
||||
version you were running, for example `speedbreaker-bug-report-v0.1.4-<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.
|
||||
|
||||
@@ -550,7 +587,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.
|
||||
|
||||
@@ -723,9 +762,9 @@ research.
|
||||
The x86-64 SteamOS download also carries glibc and libstdc++ (LGPL-2.1+; GPL-3.0 with the GCC
|
||||
Runtime Library Exception); the Steam Frame download uses the system's. Full license texts are in
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and [LICENSES/](LICENSES/), and every download
|
||||
carries them too: in `licenses/` in the SteamOS downloads, and in
|
||||
carries them too: in `licenses/` in the SteamOS downloads, in
|
||||
`SpeedBreaker.app/Contents/Resources/licenses` in the Mac app, with each bundled library's own
|
||||
license. The source code of the LGPL and GPL parts in the downloads (FFmpeg, libmspack, and the
|
||||
license, and in `SpeedBreaker.app/licenses` in the iPhone and iPad IPA. The source code of the LGPL and GPL parts in the downloads (FFmpeg, libmspack, and the
|
||||
x86-64 download's glibc and GCC runtime) is published next to the downloads of each release, as
|
||||
`SpeedBreaker-<version>-third-party-source.tar.xz`.
|
||||
|
||||
@@ -747,7 +786,8 @@ Corporation.**
|
||||
"Need for Speed", "Most Wanted" and "Carbon" are trademarks of Electronic Arts Inc. Steam,
|
||||
Steam Deck, Steam Machine, Steam Frame, SteamOS and their logos and icons are trademarks of
|
||||
Valve Corporation. All other trademarks, including Xbox, Xbox 360, PlayStation, Apple, Mac,
|
||||
macOS, iPhone, iPad, iCloud, Xcode, App Store, Backbone, Vulkan, Linux, Windows and Android,
|
||||
macOS, iPhone, iPad, iCloud, Xcode, App Store, Backbone, Vulkan, Linux, Windows, Android, NVIDIA,
|
||||
GeForce, AMD and Intel,
|
||||
belong to their respective owners. They are used here only to describe what SpeedBreaker works
|
||||
with. The game is named only to say which game SpeedBreaker runs; no logos, box art or key art
|
||||
of the game are used. The game content visible in screenshots belongs to its owners.
|
||||
|
||||
@@ -8,7 +8,7 @@ include, software by others under their own licenses, listed here. The license t
|
||||
|
||||
The downloads carry these notices too: the Mac app in `SpeedBreaker.app/Contents/Resources/licenses`,
|
||||
each SteamOS download in its `licenses/` folder (with the license of every library it bundles,
|
||||
collected when the bundle is made).
|
||||
collected when the bundle is made), and the iPhone and iPad IPA in `SpeedBreaker.app/licenses`.
|
||||
|
||||
## Compiled into SpeedBreaker (every platform)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 531 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 656 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 294 KiB |
@@ -91,14 +91,14 @@ functions = [
|
||||
{ address = 0x822C2068, size = 0xA8 },
|
||||
{ address = 0x822C5AA0, size = 0x9C },
|
||||
{ address = 0x822C5B40, size = 0xA8 },
|
||||
{ address = 0x822C7720, size = 0x68 },
|
||||
{ address = 0x822C7720, size = 0x108 },
|
||||
{ address = 0x822C8630, size = 0x74 },
|
||||
{ address = 0x822C89F8, size = 0x44 },
|
||||
{ address = 0x822C89F8, size = 0x6C },
|
||||
{ address = 0x822CDEB8, size = 0x26C },
|
||||
{ address = 0x822D3B90, size = 0xDC },
|
||||
{ address = 0x822D9A20, size = 0x12C },
|
||||
{ address = 0x822E3D68, size = 0x304 },
|
||||
{ address = 0x822E7CE0, size = 0x40 },
|
||||
{ address = 0x822E7CE0, size = 0xA0 },
|
||||
{ address = 0x822E7D88, size = 0x164 },
|
||||
{ address = 0x822EA348, size = 0x248 },
|
||||
{ address = 0x822EA590, size = 0x7C },
|
||||
|
||||
@@ -2939,15 +2939,6 @@ labels = [
|
||||
0x822C74F4,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C7720
|
||||
r = 11
|
||||
default = 0x822C76E4
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C8638
|
||||
r = 3
|
||||
@@ -2976,15 +2967,6 @@ labels = [
|
||||
0x822C88D4,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C89F8
|
||||
r = 10
|
||||
default = 0x822C89D0
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822CC614
|
||||
r = 11
|
||||
@@ -3277,14 +3259,6 @@ labels = [
|
||||
0x822E7CB0,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822E7CE0
|
||||
r = 3
|
||||
default = 0x822E7BC4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822E7D9C
|
||||
r = 4
|
||||
@@ -13320,6 +13294,51 @@ labels = [
|
||||
0x822C5BD8,
|
||||
]
|
||||
|
||||
# bctr 0x822c7734 in 0x822c7718: absolute table at 0x822c7738, index r6, 10 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C7728
|
||||
r = 6
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
0x822C7788,
|
||||
0x822C779C,
|
||||
0x822C77B0,
|
||||
0x822C77C4,
|
||||
0x822C77D8,
|
||||
0x822C77EC,
|
||||
0x822C7800,
|
||||
0x822C7814,
|
||||
]
|
||||
|
||||
# bctr 0x822c8a0c in 0x822c89f4: absolute table at 0x822c8a10, index r3, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C8A00
|
||||
r = 3
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
0x822C8A5C,
|
||||
0x822C8A3C,
|
||||
0x822C8A44,
|
||||
0x822C8A4C,
|
||||
0x822C8A54,
|
||||
]
|
||||
|
||||
# bctr 0x822e7cf4 in 0x822e7cdc: absolute table at 0x822e7cf8, index r4, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E7CE8
|
||||
r = 4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
0x822E7D20,
|
||||
0x822E7D2C,
|
||||
0x822E7D38,
|
||||
0x822E7D44,
|
||||
0x822E7D50,
|
||||
0x822E7D5C,
|
||||
]
|
||||
|
||||
# bctr 0x822e9a30 in 0x822e9a00: absolute table at 0x822e9a34, index r5, 56 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E9A24
|
||||
|
||||
@@ -131,6 +131,51 @@ labels = [
|
||||
0x822C5BD8,
|
||||
]
|
||||
|
||||
# bctr 0x822c7734 in 0x822c7718: absolute table at 0x822c7738, index r6, 10 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C7728
|
||||
r = 6
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
0x822C7788,
|
||||
0x822C779C,
|
||||
0x822C77B0,
|
||||
0x822C77C4,
|
||||
0x822C77D8,
|
||||
0x822C77EC,
|
||||
0x822C7800,
|
||||
0x822C7814,
|
||||
]
|
||||
|
||||
# bctr 0x822c8a0c in 0x822c89f4: absolute table at 0x822c8a10, index r3, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C8A00
|
||||
r = 3
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
0x822C8A5C,
|
||||
0x822C8A3C,
|
||||
0x822C8A44,
|
||||
0x822C8A4C,
|
||||
0x822C8A54,
|
||||
]
|
||||
|
||||
# bctr 0x822e7cf4 in 0x822e7cdc: absolute table at 0x822e7cf8, index r4, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E7CE8
|
||||
r = 4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
0x822E7D20,
|
||||
0x822E7D2C,
|
||||
0x822E7D38,
|
||||
0x822E7D44,
|
||||
0x822E7D50,
|
||||
0x822E7D5C,
|
||||
]
|
||||
|
||||
# bctr 0x822e9a30 in 0x822e9a00: absolute table at 0x822e9a34, index r5, 56 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E9A24
|
||||
|
||||
@@ -18,20 +18,28 @@
|
||||
// a step boundary, frames alternate between no step and two.
|
||||
//
|
||||
// Each guest vblank is made one step instead (NFSMW_GAME_TIME=0 turns this
|
||||
// off). A frame is given k/60 s for the k guest vblanks that fired during it
|
||||
// (counted from one frame's start to the next, 1 to 6), as the console's
|
||||
// 60 Hz vblank would have given it; the stepper's elapsed time, then k steps
|
||||
// give or take a tick, is rounded to k. The main loop starts a frame when D3D
|
||||
// lets it, after the vblank that released the last one, so its start can
|
||||
// jitter by most of a vblank without changing the count; a frame slower than
|
||||
// the vblank starts anywhere between two, and its count is its length
|
||||
// rounded either way. A frame no vblank fired in keeps its measured time,
|
||||
// which later frames give back (game/whole_vblanks.h): game time adds up to
|
||||
// the vblanks that fired. 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.
|
||||
// off). A frame is given whole vblanks of 1/60 s, as the console's 60 Hz
|
||||
// vblank would have given it; the stepper's elapsed time, then k steps give
|
||||
// or take a tick, is rounded to k. The main loop runs ahead of the display:
|
||||
// the game's render thread swaps (VdSwap) during the loop's next frame, and
|
||||
// the command processor lets each swap go at a vblank about two frames after
|
||||
// that, so the loop's frames drift against the vblanks (counted from one
|
||||
// frame's start to the next) while the display shows one frame a vblank from
|
||||
// the queue: a frame the loop is slow with counts 2 or 3, the next ones none.
|
||||
// Each is given one vblank while game time stays within a few vblanks of the
|
||||
// vblanks that fired (game/whole_vblanks.h; given their counts, the world
|
||||
// ran 2-3 steps in one displayed frame and none in the next: GitHub issue
|
||||
// #9). Further behind, the queue ran dry and the display waited, and the
|
||||
// frame gets the rest; a frame slower than the vblank (GPU-bound) gets its
|
||||
// count, its length rounded either way. Game time adds up to the vblanks
|
||||
// that fired, to within those few. It runs at the display's rate over 60:
|
||||
// 0.044% slow on a 59.97 Hz monitor, at most 2.5% (the lock's range). Frames
|
||||
// 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"
|
||||
@@ -50,6 +58,8 @@ namespace
|
||||
return on;
|
||||
}();
|
||||
std::atomic<uint64_t> s_frames{ 0 }, s_snapped{ 0 }, s_noStep{ 0 }, s_multiStep{ 0 };
|
||||
// Game time handed to the frames so far (game::FrameClockMs), ms in 16.16.
|
||||
std::atomic<uint64_t> s_clock{ 0 };
|
||||
// NFSMW_GAME_TIME_LOG=<file>: one line per frame, written when the next
|
||||
// one starts: its measured ms, guest vblanks, whether it was given whole
|
||||
// vblanks, the world steps it ran (-1: no world), how long after the last
|
||||
@@ -86,12 +96,23 @@ namespace game
|
||||
return { s_frames.load(std::memory_order_relaxed), s_noStep.load(std::memory_order_relaxed),
|
||||
s_multiStep.load(std::memory_order_relaxed), s_snapped.load(std::memory_order_relaxed) };
|
||||
}
|
||||
|
||||
double FrameClockMs()
|
||||
{
|
||||
return double(s_clock.load(std::memory_order_relaxed)) / 65536.0;
|
||||
}
|
||||
}
|
||||
|
||||
// GameFrameTime, before `bl 0x823AFE20` at 0x823B0148 in the main loop: r3 is
|
||||
// the frame's measured time, milliseconds in 16.16 fixed point.
|
||||
void GameFrameTime(PPCRegister& r3)
|
||||
{
|
||||
// Whatever the frame is given below (or its measured time), on every path.
|
||||
struct AddToClock
|
||||
{
|
||||
PPCRegister& r3;
|
||||
~AddToClock() { s_clock.fetch_add(uint64_t(std::max(int32_t(r3.u32), 0)), std::memory_order_relaxed); }
|
||||
} addToClock{ r3 };
|
||||
static uint64_t last = 0;
|
||||
uint64_t now = video::GuestVblanks(), vblanks = now - last;
|
||||
last = now;
|
||||
|
||||
@@ -15,4 +15,10 @@ namespace game
|
||||
uint64_t frames, noStep, multiStep, snapped;
|
||||
};
|
||||
FrameTimeStats GetFrameTimeStats();
|
||||
|
||||
// The game time the main loop has handed its frames since launch, in ms:
|
||||
// each frame's measured time, or the whole vblanks GameFrameTime gives it
|
||||
// (1/60 s each). It moves once a frame, at the frame's start, paused or
|
||||
// not; the game draws the frame after (the nitrous trails, game/light_trails.h).
|
||||
double FrameClockMs();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See light_trails.h.
|
||||
//
|
||||
// One function override (the generated functions are weak; no ppc/
|
||||
// regeneration): sub_82134608, the game's 4x4 matrix copy (r3 = destination,
|
||||
// r4 = source), called from many places. Called from RenderFlares' ring
|
||||
// write (ctx.lr = kRingWriteReturn), it first records the car's new entry
|
||||
// and fills the ring's other two entries with the car 2/60 and 4/60 s of
|
||||
// game time ago; then the copy runs as always.
|
||||
// NFSMW_LIGHT_TRAILS=0 leaves the ring as the game writes it (an A/B switch:
|
||||
// trails half the 360's length at 60 fps).
|
||||
#include <stdafx.h>
|
||||
#include "light_trails.h"
|
||||
#include "frame_time.h"
|
||||
|
||||
#include <kernel/function.h>
|
||||
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
extern "C" PPC_FUNC(__imp__sub_82134608);
|
||||
|
||||
namespace
|
||||
{
|
||||
using namespace game::trails;
|
||||
|
||||
const bool s_enabled = [] {
|
||||
const char* v = std::getenv("NFSMW_LIGHT_TRAILS");
|
||||
const bool on = !(v && v[0] == '0');
|
||||
if (!on)
|
||||
fprintf(stderr, "[trails] NFSMW_LIGHT_TRAILS=0: nitrous light trails as the game draws them at this frame rate "
|
||||
"(the last two frames: half the 360's length at 60 fps)\n");
|
||||
return on;
|
||||
}();
|
||||
|
||||
std::mutex s_mutex;
|
||||
// Per CarRenderInfo (guest address).
|
||||
std::unordered_map<uint32_t, History> s_cars;
|
||||
double s_lastPruneMs = 0.0;
|
||||
|
||||
float LoadFloat(uint8_t* base, uint32_t address)
|
||||
{
|
||||
const uint32_t bits = PPC_LOAD_U32(address);
|
||||
float f;
|
||||
memcpy(&f, &bits, sizeof(f));
|
||||
return f;
|
||||
}
|
||||
|
||||
void StoreFloat(uint8_t* base, uint32_t address, float f)
|
||||
{
|
||||
uint32_t bits;
|
||||
memcpy(&bits, &f, sizeof(bits));
|
||||
PPC_STORE_U32(address, bits);
|
||||
}
|
||||
|
||||
void StoreEntry(uint8_t* base, uint32_t info, uint32_t slot, const Entry& e)
|
||||
{
|
||||
const uint32_t pos = info + kPositions + slot * kPositionBytes;
|
||||
for (uint32_t k = 0; k < 3; k++)
|
||||
StoreFloat(base, pos + k * 4, e.pos[k]);
|
||||
memcpy(base + info + kMatrices + slot * kMatrixBytes, e.matrix.data(), kMatrixBytes);
|
||||
}
|
||||
|
||||
void RingWrite(PPCContext& ctx, uint8_t* base)
|
||||
{
|
||||
const uint32_t info = ctx.r30.u32;
|
||||
const int32_t newest = int32_t(PPC_LOAD_U32(info + kNewestIndex));
|
||||
// The game's code as read (sub_824E6220): anything else, leave it be.
|
||||
if (newest < 0 || newest > 2 || ctx.r3.u32 != info + kMatrices + uint32_t(newest) * kMatrixBytes)
|
||||
{
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [&] {
|
||||
fprintf(stderr, "[trails] unexpected ring write (info %08X index %d destination %08X): trails left as the game draws them\n",
|
||||
info, newest, ctx.r3.u32);
|
||||
});
|
||||
return;
|
||||
}
|
||||
Entry now;
|
||||
const uint32_t stack = ctx.r1.u32 + kNewPositionOnStack;
|
||||
for (uint32_t k = 0; k < 3; k++)
|
||||
now.pos[k] = LoadFloat(base, stack + k * 4);
|
||||
memcpy(now.matrix.data(), base + ctx.r4.u32, kMatrixBytes);
|
||||
|
||||
const double ms = game::FrameClockMs();
|
||||
Fill fill;
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
// Cars gone for a second are forgotten (CarRenderInfos are freed
|
||||
// and reused).
|
||||
if (ms - s_lastPruneMs > 1000.0 || ms < s_lastPruneMs)
|
||||
{
|
||||
std::erase_if(s_cars, [&](const auto& kv) { return ms - kv.second.NewestMs() > 1000.0; });
|
||||
s_lastPruneMs = ms;
|
||||
}
|
||||
History& h = s_cars[info];
|
||||
h.Add(ms, now);
|
||||
fill = FillFor(h, ms);
|
||||
}
|
||||
const uint32_t slot = uint32_t(newest);
|
||||
if (fill.hasMiddle)
|
||||
StoreEntry(base, info, MiddleSlot(slot), fill.middle);
|
||||
if (fill.hasOldest)
|
||||
StoreEntry(base, info, OldestSlot(slot), fill.oldest);
|
||||
if (fill.hasMiddle && fill.hasOldest)
|
||||
{
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [] {
|
||||
fprintf(stderr, "[trails] nitrous light trails at the 360's length: the car's ring holds it now and %.1f and %.1f ms "
|
||||
"of game time ago, as at the console's 30 fps (NFSMW_LIGHT_TRAILS=0: the game's last two frames)\n",
|
||||
kConsoleFrameMs, 2.0 * kConsoleFrameMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PPC_FUNC(sub_82134608)
|
||||
{
|
||||
if (s_enabled && static_cast<uint32_t>(ctx.lr) == kRingWriteReturn)
|
||||
RingWrite(ctx, base);
|
||||
__imp__sub_82134608(ctx, base);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The nitrous light trails at the length the Xbox 360 draws them, at any
|
||||
// frame rate (game/light_trails.cpp hooks the game; tests/light_trails_test.cpp
|
||||
// checks this header alone). GitHub issue #10.
|
||||
//
|
||||
// The trail is not an effect on the picture: VehicleRenderConn::RenderFlares
|
||||
// (sub_824E6220, called per view) keeps, for every car it draws in a player
|
||||
// view (the view's id at +4 is 1 or 2) and not in a reflection, the car's
|
||||
// render matrix and position in a ring of three in its CarRenderInfo (the
|
||||
// connection's +68): the matrices at +4512 (64 bytes each), the positions at
|
||||
// +4704 (16 bytes each, x y z used), the newest one's index at +4752. It
|
||||
// advances the index and writes the newest entry once a call, so once a
|
||||
// frame. Then, when the car's nitrous is on (CarRenderInfo +4492) and it
|
||||
// moves (faster than 0.2 in the decompilation), it draws the car's light
|
||||
// flares 8 times along each of the three segments between the entries
|
||||
// (oldest to middle, newest to oldest, middle to newest; sub_824DF658 with
|
||||
// flags 8 and 2), each with the matrix of the segment's start. So the trail
|
||||
// is where the car was over the last two frames, 24 flares.
|
||||
//
|
||||
// A real Xbox 360 runs the race at 30 fps (a 60 fps capture of one shows every
|
||||
// race frame twice, now and then three or four times), so its trail covers
|
||||
// 4/60 s of driving. At SpeedBreaker's 60 fps it covered 2/60 s: half as long,
|
||||
// with the same 24 flares packed into half the distance, so brighter.
|
||||
//
|
||||
// The fix keeps the game's code and its ring, and changes what is in the two
|
||||
// entries the game doesn't write: at the ring write (the matrix copy
|
||||
// sub_82134608 called from 0x824E63D0, after the index has moved and before
|
||||
// any flare is drawn), the middle entry gets the car's position and matrix of
|
||||
// 2/60 s of game time ago and the oldest those of 4/60 s ago, from a history
|
||||
// of what the game wrote; the game then writes the newest as before. Game
|
||||
// time is the time the main loop hands its frames (game::FrameClockMs: whole
|
||||
// vblanks, so host timing jitter doesn't move it), and a paused game
|
||||
// collapses the trail onto the car as a 360 does. At 30 fps those times are
|
||||
// the frames the game wrote last and the one before (nothing changes); at 60
|
||||
// fps the frames 2 and 4 before this one. When the history doesn't reach back
|
||||
// that far (a car just drawn for the first time, or again after a gap), the
|
||||
// game's own entry stays.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace game::trails
|
||||
{
|
||||
// ctx.lr when RenderFlares calls the matrix copy that writes the newest
|
||||
// entry (bl 0x82134608 at 0x824E63D0; r3 = the entry, r4 = the matrix,
|
||||
// r30 = the CarRenderInfo). The position is stored right after it, from
|
||||
// the caller's r1 + 96.
|
||||
constexpr uint32_t kRingWriteReturn = 0x824E63D4;
|
||||
constexpr uint32_t kNewPositionOnStack = 96;
|
||||
// CarRenderInfo's ring.
|
||||
constexpr uint32_t kMatrices = 4512, kMatrixBytes = 64;
|
||||
constexpr uint32_t kPositions = 4704, kPositionBytes = 16;
|
||||
constexpr uint32_t kNewestIndex = 4752;
|
||||
|
||||
// One vblank of game time, a world step; the 360's race frame is two.
|
||||
constexpr double kVblankMs = 1000.0 / 60.0;
|
||||
constexpr double kConsoleFrameMs = 2.0 * kVblankMs;
|
||||
// A sample this close to the wanted time is that time: game time is
|
||||
// summed from 16.16 ms values, each rounded by at most 1/131072 ms.
|
||||
constexpr double kSnapMs = 0.01;
|
||||
// A car not drawn for this long starts its history over (a new car in a
|
||||
// reused CarRenderInfo, or one back after a while off screen).
|
||||
constexpr double kGapMs = 250.0;
|
||||
|
||||
// The ring entries around the newest (the index the game just moved to).
|
||||
constexpr uint32_t MiddleSlot(uint32_t newest) { return (newest + 2) % 3; } // the console's last frame
|
||||
constexpr uint32_t OldestSlot(uint32_t newest) { return (newest + 1) % 3; } // the one before
|
||||
|
||||
// One ring entry, the matrix as the game's bytes (copied, never read).
|
||||
struct Entry
|
||||
{
|
||||
float pos[3] = {};
|
||||
std::array<uint8_t, kMatrixBytes> matrix{};
|
||||
};
|
||||
|
||||
// What the game wrote for one car, at the game times it wrote it.
|
||||
class History
|
||||
{
|
||||
public:
|
||||
static constexpr size_t kCapacity = 32; // reaches 4/60 s back at up to 465 fps
|
||||
|
||||
bool Empty() const { return count_ == 0; }
|
||||
size_t Size() const { return count_; }
|
||||
double NewestMs() const { return count_ ? At(count_ - 1).ms : 0.0; }
|
||||
|
||||
void Clear() { count_ = 0; }
|
||||
|
||||
// The entry the game wrote at `ms`. A second write at the same time
|
||||
// (the same frame) replaces the first; time going back, or a gap of
|
||||
// kGapMs, starts over.
|
||||
void Add(double ms, const Entry& e)
|
||||
{
|
||||
if (count_)
|
||||
{
|
||||
const double newest = NewestMs();
|
||||
if (std::abs(ms - newest) <= 1e-6)
|
||||
{
|
||||
Mutable(count_ - 1) = { ms, e };
|
||||
return;
|
||||
}
|
||||
if (ms < newest || ms - newest > kGapMs)
|
||||
count_ = 0;
|
||||
}
|
||||
if (count_ == kCapacity)
|
||||
{
|
||||
first_ = (first_ + 1) % kCapacity;
|
||||
count_--;
|
||||
}
|
||||
Mutable(count_++) = { ms, e };
|
||||
}
|
||||
|
||||
// The car at game time `ms`: a sample within kSnapMs of it as it was
|
||||
// written, else the position between the two samples around it and
|
||||
// the nearer one's matrix. False when the history doesn't reach back
|
||||
// to `ms` (or `ms` is past the newest sample).
|
||||
bool Get(double ms, Entry& out) const
|
||||
{
|
||||
if (!count_ || ms < At(0).ms - kSnapMs || ms > NewestMs() + kSnapMs)
|
||||
return false;
|
||||
for (size_t i = count_; i-- > 0;)
|
||||
{
|
||||
const Sample& s = At(i);
|
||||
if (std::abs(s.ms - ms) <= kSnapMs)
|
||||
{
|
||||
out = s.e;
|
||||
return true;
|
||||
}
|
||||
if (s.ms < ms && i + 1 < count_)
|
||||
{
|
||||
const Sample& n = At(i + 1);
|
||||
const double f = (ms - s.ms) / (n.ms - s.ms);
|
||||
for (int k = 0; k < 3; k++)
|
||||
out.pos[k] = float(double(s.e.pos[k]) + (double(n.e.pos[k]) - double(s.e.pos[k])) * f);
|
||||
out.matrix = f < 0.5 ? s.e.matrix : n.e.matrix;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Sample
|
||||
{
|
||||
double ms = 0.0;
|
||||
Entry e;
|
||||
};
|
||||
const Sample& At(size_t i) const { return samples_[(first_ + i) % kCapacity]; }
|
||||
Sample& Mutable(size_t i) { return samples_[(first_ + i) % kCapacity]; }
|
||||
|
||||
std::array<Sample, kCapacity> samples_{};
|
||||
size_t first_ = 0, count_ = 0;
|
||||
};
|
||||
|
||||
// What to put in the two entries the game doesn't write, given the car's
|
||||
// history (with the newest entry already added at `nowMs`): the middle
|
||||
// one 2/60 s back, the oldest 4/60 s back. Each `has` is false where the
|
||||
// history doesn't reach (keep the game's entry).
|
||||
struct Fill
|
||||
{
|
||||
bool hasMiddle = false, hasOldest = false;
|
||||
Entry middle, oldest;
|
||||
};
|
||||
inline Fill FillFor(const History& h, double nowMs)
|
||||
{
|
||||
Fill f;
|
||||
f.hasMiddle = h.Get(nowMs - kConsoleFrameMs, f.middle);
|
||||
f.hasOldest = h.Get(nowMs - 2.0 * kConsoleFrameMs, f.oldest);
|
||||
return f;
|
||||
}
|
||||
}
|
||||
@@ -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,19 +10,47 @@
|
||||
namespace game
|
||||
{
|
||||
// Game time follows the guest vblanks that fire: over any run of frames
|
||||
// it adds up to them, to within a vblank, 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.
|
||||
// - A frame it doesn't (GPU-bound) starts anywhere between two, so its
|
||||
// count is its length rounded down or up: a 44 ms frame (2.64
|
||||
// refreshes) sees 2 or 3, and gets them.
|
||||
// - A frame no vblank fired in (a vblank thread waking late, as the
|
||||
// Mac's does several ms at a time when its GPU is busy; or two frames
|
||||
// between two vblanks) keeps its measured time, and is then ahead of
|
||||
// the vblanks by that much: the next frames give it back, one whole
|
||||
// vblank fewer when it adds up to one, never fewer than one.
|
||||
// - The main loop runs up to a few frames ahead of the display (its
|
||||
// render thread and the GPU ring queue them), so its own frames
|
||||
// don't line up with the vblanks: a frame the loop is slow with
|
||||
// (streaming, a file read, a page fault) spans 2 vblanks and the next
|
||||
// one, catching up, none, while the display still shows one frame a
|
||||
// vblank from the queue. Each of those frames gets one: game time may
|
||||
// run up to kSlack vblanks behind or ahead of the vblanks that fired.
|
||||
// Until 2026-10-11 they got their own counts (2, then the measured
|
||||
// few ms): the world ran 2 steps in a frame the display showed for
|
||||
// one vblank and none in the next, a hitch every few seconds while
|
||||
// driving that no frame-time graph shows (GitHub issue #9).
|
||||
// - Behind by more than that, the queue ran dry and the display waited
|
||||
// (a frame shown twice): the frame gets the vblanks beyond kSlack,
|
||||
// at most 6, so game time keeps up with the display.
|
||||
// - A frame it doesn't pace (GPU-bound) starts anywhere between two,
|
||||
// so its count is its length rounded down or up: a 44 ms frame (2.64
|
||||
// refreshes) sees 2 or 3, and once game time is kSlack behind (the
|
||||
// first such frame), gets them.
|
||||
// - 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
|
||||
@@ -32,35 +60,78 @@ namespace game
|
||||
// 6-9% slow.
|
||||
struct WholeVblanks
|
||||
{
|
||||
// Game time given beyond the vblanks that fired, in vblanks: at most
|
||||
// kMaxAhead (a held vblank, as a GPU-only suspension's, isn't
|
||||
// replayed: what can't be given back is forgotten).
|
||||
// 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). Below
|
||||
// 20 fps it may be down to -(kSlack + kMaxOwed).
|
||||
double ahead = 0.0;
|
||||
static constexpr double kMaxAhead = 6.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 (vblanks == 0)
|
||||
if (!low)
|
||||
ahead = std::max(ahead, -kSlack); // back above 20 fps: owed no more
|
||||
ahead -= double(vblanks);
|
||||
if (ahead + 1.0 > kSlack)
|
||||
{
|
||||
ahead = std::min(ahead + std::max(measuredMs, 0.0) / vblankMs, kMaxAhead);
|
||||
ahead = std::min(ahead + std::max(measuredMs, 0.0) / vblankMs, kSlack);
|
||||
return 0;
|
||||
}
|
||||
double owed = double(vblanks) - ahead;
|
||||
int64_t give = std::clamp<int64_t>(std::llround(owed), 1, 6);
|
||||
ahead = double(give) - owed;
|
||||
int64_t give = 1;
|
||||
if (ahead + 1.0 < -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Draws in depth-only EDRAM mode (RB_MODECONTROL edram_mode kDepthOnly) run
|
||||
// no pixel shader on Xenos: only the rasterised depth and stencil reach EDRAM
|
||||
// (xenos/xenos.h, EdramMode::kDepthOnly; Xenia draws them without one). The
|
||||
// pixel shader still bound then is whatever an earlier draw left, and running
|
||||
// it gives the draw that shader's kills, alpha test and depth export.
|
||||
//
|
||||
// NFSMW's sun shadow pass draws its alpha-tested casters (trees, poles,
|
||||
// signs) in colour+depth mode with a pixel shader that kills texels whose
|
||||
// alpha is 0.2 or less. Its other casters are depth-only, drawn with that
|
||||
// pixel shader still bound and vertex shaders that export no texture
|
||||
// coordinates: the solid occluders (walls, concrete barriers, tunnel roofs
|
||||
// and the hills over them) and, after the world's map is resolved to
|
||||
// 03A8A000, the cars (the map with them is 0444E000). Run, the bound shader
|
||||
// read texel (0,0)'s alpha for all of them and killed every pixel, so up to
|
||||
// v0.1.2 no car or wall cast a sun shadow and tunnels let the sun through
|
||||
// (GitHub issue #3).
|
||||
#pragma once
|
||||
#include "xenos/xenos.h"
|
||||
|
||||
namespace gpu
|
||||
{
|
||||
// Whether a draw in this EDRAM mode runs the guest's bound pixel shader.
|
||||
// guestOverride (NFSMW_DEPTH_ONLY_PS=1, for A/B only): always, as up to
|
||||
// v0.1.2.
|
||||
constexpr bool RunsGuestPixelShader(xe::gpu::xenos::EdramMode mode, bool guestOverride = false)
|
||||
{
|
||||
return mode != xe::gpu::xenos::EdramMode::kDepthOnly || guestOverride;
|
||||
}
|
||||
|
||||
// What a depth-only draw runs instead: no inputs, outputs, kill or
|
||||
// resources, so it fits every pipeline layout and attachment set.
|
||||
inline constexpr char kEmptyPixelShaderGlsl[] = "#version 460\nvoid main()\n{\n}\n";
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "shader_translator.h"
|
||||
#include "zpd_report.h"
|
||||
#include "depth24_glsl.h"
|
||||
#include "depth_only.h"
|
||||
#include "render_scale.h"
|
||||
#include "shared_memory_glsl.h"
|
||||
#include "xenos/registers.h"
|
||||
@@ -1346,6 +1347,28 @@ namespace gpu::renderer
|
||||
Shader* s_vs = nullptr;
|
||||
Shader* s_ps = nullptr;
|
||||
|
||||
// Depth-only draws run no pixel shader on Xenos (depth_only.h): they
|
||||
// get this empty one instead of the guest's bound one, left over from
|
||||
// earlier draws. NFSMW_DEPTH_ONLY_PS=1 runs the bound one, as up to
|
||||
// v0.1.2 (A/B only).
|
||||
constexpr uint64_t kDepthOnlyPsHash = 0xDE97400000000001ull; // (a guest shader hashing to it: a 2^-64 chance)
|
||||
const bool s_depthOnlyGuestPs = [] { const char* v = std::getenv("NFSMW_DEPTH_ONLY_PS"); return v && v[0] == '1'; }();
|
||||
|
||||
Shader* DepthOnlyPixelShader()
|
||||
{
|
||||
static Shader* const ps = [] {
|
||||
auto& slot = s_shaders[kDepthOnlyPsHash];
|
||||
slot = std::make_unique<Shader>();
|
||||
slot->stage = ShaderStage::Pixel;
|
||||
slot->hash = kDepthOnlyPsHash;
|
||||
slot->info.ok = true;
|
||||
slot->info.constPacked = true; // (reads no constants: none uploaded for it)
|
||||
slot->info.glsl = gpu::kEmptyPixelShaderGlsl;
|
||||
return slot.get();
|
||||
}();
|
||||
return ps;
|
||||
}
|
||||
|
||||
// IM_LOAD shaders by where they are (LoadShader). A dense frame loads
|
||||
// thousands, the same few hundred from the same addresses, and
|
||||
// finding each by a hash of all its dwords (FNV-1a, a latency-bound
|
||||
@@ -6996,6 +7019,15 @@ void main()
|
||||
}
|
||||
if (edramMode != xenos::EdramMode::kColorDepth && edramMode != xenos::EdramMode::kDepthOnly)
|
||||
return;
|
||||
// Depth-only: no pixel shader runs (depth_only.h); the guest's stays
|
||||
// bound for the draws after this one.
|
||||
struct RestorePs
|
||||
{
|
||||
Shader* ps;
|
||||
~RestorePs() { s_ps = ps; }
|
||||
} restorePs{ s_ps };
|
||||
if (!gpu::RunsGuestPixelShader(edramMode, s_depthOnlyGuestPs))
|
||||
s_ps = DepthOnlyPixelShader();
|
||||
s_stats.draws++;
|
||||
auto skip = [&](uint64_t key, const char* why) {
|
||||
s_stats.skipped++;
|
||||
@@ -7355,7 +7387,9 @@ void main()
|
||||
if (polygonal && initiator.prim_type != xenos::PrimitiveType::kRectangleList)
|
||||
cull = (modeCntl.cull_front ? VK_CULL_MODE_FRONT_BIT : 0) | (modeCntl.cull_back ? VK_CULL_MODE_BACK_BIT : 0);
|
||||
auto colorControlKey = Reg<reg::RB_COLORCONTROL>();
|
||||
bool alphaTest = colorControlKey.alpha_test_enable && colorControlKey.alpha_func != xenos::CompareFunction::kAlways;
|
||||
// (The alpha test tests the pixel shader's alpha: none depth-only.)
|
||||
bool alphaTest = colorControlKey.alpha_test_enable && colorControlKey.alpha_func != xenos::CompareFunction::kAlways &&
|
||||
gpu::RunsGuestPixelShader(edramMode, s_depthOnlyGuestPs);
|
||||
key.raster = cull | (modeCntl.face ? 4u : 0u) | (clipCntl.clip_disable ? 8u : 0u) | (depthBias ? 16u : 0u) |
|
||||
(alphaTest ? 32u : 0u);
|
||||
|
||||
|
||||
@@ -634,6 +634,13 @@ namespace hid
|
||||
rt = 1.0f;
|
||||
if (k[SDL_SCANCODE_S])
|
||||
lt = 1.0f;
|
||||
// The right stick (the game shifts gears with it: drag races
|
||||
// need it). While either key of an axis is held the keyboard
|
||||
// has that axis; both keys centre it.
|
||||
if (k[SDL_SCANCODE_O] || k[SDL_SCANCODE_L])
|
||||
ry = float(k[SDL_SCANCODE_O]) - float(k[SDL_SCANCODE_L]);
|
||||
if (k[SDL_SCANCODE_K] || k[SDL_SCANCODE_SEMICOLON])
|
||||
rx = float(k[SDL_SCANCODE_SEMICOLON]) - float(k[SDL_SCANCODE_K]);
|
||||
}
|
||||
p.lx = ToShort(lx);
|
||||
p.ly = ToShort(ly);
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
// (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.
|
||||
// Q/E = LB/RB, Enter = START, Esc = BACK, O/L and K/; = right stick
|
||||
// (up/down, left/right), C = right stick click.
|
||||
// - 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,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;
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,11 @@
|
||||
// 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>
|
||||
@@ -19,6 +21,10 @@
|
||||
#include <imgui.h>
|
||||
#include <imgui_internal.h> // ClosePopupToLevel: a dialog left open when the menu closed
|
||||
|
||||
#include <SDL3/SDL_error.h>
|
||||
#include <SDL3/SDL_events.h>
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
namespace ui
|
||||
{
|
||||
namespace
|
||||
@@ -36,6 +42,11 @@ namespace ui
|
||||
|
||||
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);
|
||||
@@ -176,6 +187,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;
|
||||
@@ -288,6 +303,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)
|
||||
@@ -340,6 +433,14 @@ namespace ui
|
||||
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,7 +497,7 @@ 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);
|
||||
@@ -514,6 +615,16 @@ 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";
|
||||
@@ -546,6 +657,10 @@ namespace ui
|
||||
}
|
||||
if (DrawUpdateDialog())
|
||||
popup = true;
|
||||
#if SB_QUIT_GAME
|
||||
if (DrawQuitPrompt())
|
||||
popup = true;
|
||||
#endif
|
||||
if (!popup && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && !ImGui::IsAnyItemActive() &&
|
||||
(ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)))
|
||||
CloseSettings();
|
||||
@@ -561,4 +676,12 @@ namespace ui
|
||||
ImGui::ClosePopupToLevel(0, false);
|
||||
ForgetUpdateDialog();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -469,6 +470,63 @@ 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
|
||||
|
||||
// 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 (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 +613,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);
|
||||
@@ -647,6 +713,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.
|
||||
@@ -661,9 +728,13 @@ namespace ui
|
||||
// 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;
|
||||
kShot = SDLK_EXTENDED_MASK | 0x7F03, kMouseMove = SDLK_EXTENDED_MASK | 0x7F04;
|
||||
struct Key { double time; SDL_Keycode key; };
|
||||
static const auto start = std::chrono::steady_clock::now();
|
||||
static std::vector<Key> keys = [] {
|
||||
@@ -680,6 +751,7 @@ namespace ui
|
||||
std::string name = item.substr(colon + 1);
|
||||
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 });
|
||||
@@ -717,6 +789,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{};
|
||||
@@ -806,6 +900,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,
|
||||
@@ -948,6 +1043,7 @@ namespace ui
|
||||
s_settingsOpen = false;
|
||||
UpdateCapture();
|
||||
ForgetUpdateDialog();
|
||||
ForgetQuitPrompt();
|
||||
report::NoteMenuClosed();
|
||||
XamNotifySystemUi(false);
|
||||
if (settings::Dirty() && !settings::Save())
|
||||
|
||||
@@ -147,6 +147,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();
|
||||
|
||||
@@ -136,6 +136,15 @@ namespace settings
|
||||
.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.",
|
||||
@@ -1090,6 +1099,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 == "%")
|
||||
|
||||
@@ -46,6 +46,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,
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
}
|
||||
@@ -10,12 +10,15 @@
|
||||
#include <gpu/renderer.h>
|
||||
#include <game/ultrawide.h>
|
||||
#include "gpu_choice.h"
|
||||
#include "present_schedule.h"
|
||||
#include "picture_fit.h"
|
||||
#include "frame_rate.h"
|
||||
#include "swapchain_policy.h"
|
||||
#include "fullscreen_policy.h"
|
||||
#include <cpu/guest_thread.h>
|
||||
#include <cpu/guest_time.h>
|
||||
#include "vblank_lock.h"
|
||||
#include "display_tone.h"
|
||||
#include <ui/ui.h>
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
#include <platform/ios_ui.h>
|
||||
@@ -175,8 +178,10 @@ namespace video
|
||||
std::vector<std::promise<CapturedFrame>> s_captureRequests;
|
||||
// Window settings as applied (settings::Generation() tells when to look again).
|
||||
uint64_t s_settingsGeneration = ~0ull;
|
||||
bool s_fullscreen = false; // the window's state
|
||||
bool s_fullscreenSetting = false; // the setting as last applied
|
||||
// Settings > Display > Fullscreen (the player's choice, which decides
|
||||
// how the game starts) and the window's own state, which the system
|
||||
// can change for the session (video/fullscreen_policy.h).
|
||||
FullscreenPolicy s_fullscreen;
|
||||
// SteamOS Game Mode: gamescope gives the game a screen of the size
|
||||
// Steam chooses and resizes only fullscreen windows to it, so the
|
||||
// window is always fullscreen there (not saved: Desktop Mode keeps
|
||||
@@ -280,11 +285,117 @@ namespace video
|
||||
VkSwapchainKHR s_waitSwapchain = VK_NULL_HANDLE; // the one it may wait on
|
||||
FILE* s_timingLog = nullptr; // NFSMW_VBLANK_LOG=<file>: one CSV line per present
|
||||
|
||||
// macOS: a new frame may be shown a set time after the guest vblank
|
||||
// that released it (VK_GOOGLE_display_timing's desiredPresentTime,
|
||||
// which MoltenVK hands to Metal's presentDrawable:atTime:): when and
|
||||
// where, and the watchdog, in video/present_schedule.h. A MacBook
|
||||
// Pro's ProMotion panel shows each present when it is ready (on a
|
||||
// 240 Hz timing grid, not every other 120 Hz refresh), so as presented
|
||||
// its frames came 8-29 ms apart while driving, with the GPU's work on
|
||||
// each; held to their vblanks' cadence they come 16.67 ms apart.
|
||||
// kShowAfterVblankNs: three of the panel's steps, past the 9-11 ms a
|
||||
// frame takes to be ready at the median (M1 Pro, the default
|
||||
// settings): 99.9% of frames 16.67 ms apart while driving, shown
|
||||
// 12-16 ms after their vblank at the median (10.7 when ready). A
|
||||
// frame not ready by then is shown when it is, as before.
|
||||
// NFSMW_PRESENT_AT=<ms> sets the time and shows frames at it on any
|
||||
// display; 0: always when ready.
|
||||
bool s_displayTiming = false;
|
||||
int64_t s_showAfterVblankNs = 0;
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
constexpr int64_t kShowAfterVblankNs = 12'500'000;
|
||||
uint32_t s_displayTimingId = 0; // main thread
|
||||
// The decision and the watchdog (main thread: Wanted; the waiter:
|
||||
// the driver's reports), and the frames shown at a time not reported
|
||||
// on yet: present id -> the time asked for (steady clock).
|
||||
std::mutex s_scheduleMutex;
|
||||
PresentSchedule s_schedule;
|
||||
struct ScheduledPresent
|
||||
{
|
||||
uint32_t id = 0;
|
||||
int64_t desiredNs = 0;
|
||||
bool waited = false;
|
||||
};
|
||||
ScheduledPresent s_scheduled[64];
|
||||
uint32_t s_scheduledNext = 0;
|
||||
float s_displayHz = 0.0f; // the display mode's, for the log (main thread)
|
||||
#endif
|
||||
// NFSMW_VBLANK_LOG's <file>.actual.csv: the driver's own on-screen
|
||||
// time per present (Metal's presentedTime, mach_absolute_time ns, 0:
|
||||
// never shown; the first line gives the steady clock against it). The
|
||||
// present wait returns well before it there (Metal's scheduling, not
|
||||
// the panel).
|
||||
PFN_vkGetPastPresentationTimingGOOGLE s_pastTiming = nullptr;
|
||||
FILE* s_actualLog = nullptr;
|
||||
|
||||
int64_t NowNs()
|
||||
{
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
// Metal's host time is mach_absolute_time's (CLOCK_UPTIME_RAW); the
|
||||
// steady clock goes on through sleep. Steady = host + this.
|
||||
int64_t HostToSteadyNs()
|
||||
{
|
||||
return NowNs() - int64_t(clock_gettime_nsec_np(CLOCK_UPTIME_RAW));
|
||||
}
|
||||
|
||||
// Waiter thread, after each present wait (`t` the present waited
|
||||
// for): the driver's reports on the presents shown since the last
|
||||
// call, to NFSMW_VBLANK_LOG's .actual.csv and, for frames shown at a
|
||||
// time, to the watchdog; and `t`, if shown at a time, counted as
|
||||
// presented (the driver reports it a present or so later).
|
||||
void ReadDriverReports(const PresentTiming& t)
|
||||
{
|
||||
uint32_t count = 0;
|
||||
std::vector<VkPastPresentationTimingGOOGLE> past;
|
||||
if (s_pastTiming(s_device, t.swapchain, &count, nullptr) == VK_SUCCESS && count)
|
||||
{
|
||||
past.resize(count);
|
||||
if (s_pastTiming(s_device, t.swapchain, &count, past.data()) < VK_SUCCESS)
|
||||
count = 0;
|
||||
past.resize(count);
|
||||
}
|
||||
const int64_t hostToSteady = HostToSteadyNs();
|
||||
bool tripped = false;
|
||||
{
|
||||
std::lock_guard lock(s_scheduleMutex);
|
||||
for (const VkPastPresentationTimingGOOGLE& e : past)
|
||||
{
|
||||
if (s_actualLog)
|
||||
fprintf(s_actualLog, "%u,%llu\n", e.presentID, (unsigned long long)e.actualPresentTime);
|
||||
for (ScheduledPresent& p : s_scheduled)
|
||||
if (p.id == e.presentID && p.desiredNs)
|
||||
{
|
||||
const bool shown = e.actualPresentTime != 0;
|
||||
tripped |= s_schedule.Reported(shown, shown ? int64_t(e.actualPresentTime) + hostToSteady - p.desiredNs : 0);
|
||||
p = {};
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (ScheduledPresent& p : s_scheduled)
|
||||
if (p.id == uint32_t(t.id) && p.desiredNs && !p.waited)
|
||||
{
|
||||
p.waited = true;
|
||||
tripped |= s_schedule.Presented();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tripped)
|
||||
{
|
||||
if (s_schedule.GetReason() == PresentSchedule::Reason::Unreported)
|
||||
fprintf(stderr, "[vblank] new frames shown when ready from now on: %u frames shown at a time were presented and the "
|
||||
"driver reported none of them (VK_GOOGLE_display_timing)\n", PresentSchedule::kUnreported);
|
||||
else
|
||||
fprintf(stderr, "[vblank] new frames shown when ready from now on: the time asked for wasn't kept for %u of the last %u "
|
||||
"frames shown at a time (the driver's reports, VK_GOOGLE_display_timing: %llu shown over %lld ms early, %llu never "
|
||||
"shown)\n", s_schedule.BadInWindow(), s_schedule.ReportedInWindow(), (unsigned long long)s_schedule.Early(),
|
||||
(long long)(PresentSchedule::kEarlyNs / 1'000'000), (unsigned long long)s_schedule.NotShown());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Waiter thread: a present was on screen at `onScreen`. `timed`: the
|
||||
// wait was under way when it happened (not caught up on afterwards).
|
||||
void OnScreen(const PresentTiming& t, int64_t onScreen, bool timed)
|
||||
@@ -302,6 +413,10 @@ namespace video
|
||||
fprintf(s_timingLog, "%llu,%lld,%lld,%lld,%d,%d,%.1f,%lld,%.0f\n", (unsigned long long)t.id, (long long)t.vblank,
|
||||
(long long)t.presented, (long long)onScreen, timed, display.valid, display.periodNs, (long long)display.gridNs,
|
||||
display.rmsNs);
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
if (s_pastTiming)
|
||||
ReadDriverReports(t);
|
||||
#endif
|
||||
if (!t.vblank)
|
||||
return;
|
||||
s_shown++;
|
||||
@@ -330,6 +445,11 @@ namespace video
|
||||
void PresentWaiter()
|
||||
{
|
||||
SetHostThreadName("nfsmw-present");
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
// As the threads that present (the main thread) and draw: it
|
||||
// takes MoltenVK's swapchain locks beside their presents.
|
||||
pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0);
|
||||
#endif
|
||||
while (true)
|
||||
{
|
||||
PresentTiming t;
|
||||
@@ -1029,6 +1149,65 @@ void main()
|
||||
#endif
|
||||
const bool s_fastScale = [] { const char* v = std::getenv("NFSMW_FAST_SCALE"); return v ? v[0] == '1' : kFastScaleByDefault; }();
|
||||
|
||||
// Brightness and the 360's display gamma (video/display_tone.h): the
|
||||
// scale shader in use, its main renamed scaleMain, then this main,
|
||||
// which looks each channel of its result up in the tone table (256
|
||||
// entries for the values k/255, linear between them). The tables
|
||||
// are made on the CPU. With Brightness 0 and the 360's gamma off the
|
||||
// plain scale pipeline draws, so that picture (v0.1.3's) is the same,
|
||||
// bit for bit, as without this.
|
||||
const char* kToneGlsl = R"(
|
||||
layout(set = 0, binding = 1) uniform sampler2D toneTable;
|
||||
|
||||
vec3 toneOf(vec3 c)
|
||||
{
|
||||
vec3 t = clamp(c, 0.0, 1.0) * (255.0 / 256.0) + (0.5 / 256.0);
|
||||
return vec3(textureLod(toneTable, vec2(t.r, 0.5), 0.0).r, textureLod(toneTable, vec2(t.g, 0.5), 0.0).g,
|
||||
textureLod(toneTable, vec2(t.b, 0.5), 0.0).b);
|
||||
}
|
||||
|
||||
void main()
|
||||
{
|
||||
scaleMain();
|
||||
color = vec4(toneOf(color.rgb), color.a);
|
||||
}
|
||||
)";
|
||||
|
||||
// The tone variant of a scale shader's source ("" if it hasn't
|
||||
// exactly one main to rename).
|
||||
std::string ToneVariant(const char* scaleGlsl)
|
||||
{
|
||||
std::string glsl = scaleGlsl;
|
||||
const std::string_view kMain = "void main()";
|
||||
size_t at = glsl.find(kMain);
|
||||
if (at == std::string::npos || glsl.find(kMain, at + 1) != std::string::npos)
|
||||
return {};
|
||||
glsl.replace(at, kMain.size(), "void scaleMain()");
|
||||
return glsl + kToneGlsl;
|
||||
}
|
||||
|
||||
struct ToneState
|
||||
{
|
||||
bool tried = false, ready = false;
|
||||
VkDescriptorSetLayout set = VK_NULL_HANDLE;
|
||||
VkPipelineLayout layout = VK_NULL_HANDLE;
|
||||
VkShaderModule fs = VK_NULL_HANDLE;
|
||||
VkPipeline pipeline = VK_NULL_HANDLE;
|
||||
VkFormat format = VK_FORMAT_UNDEFINED; // the swapchain format the pipeline was made for
|
||||
// The table: 256 x 1 RGBA16F, written from a host-visible buffer.
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
VkDeviceMemory memory = VK_NULL_HANDLE;
|
||||
VkImageView view = VK_NULL_HANDLE;
|
||||
VkBuffer staging = VK_NULL_HANDLE;
|
||||
VkDeviceMemory stagingMemory = VK_NULL_HANDLE;
|
||||
void* stagingMapped = nullptr;
|
||||
// What the table holds.
|
||||
bool filled = false;
|
||||
int32_t brightness = 0;
|
||||
bool ramp = false;
|
||||
uint64_t rampGeneration = 0;
|
||||
} s_tone;
|
||||
|
||||
struct PostState
|
||||
{
|
||||
bool tried = false, ready = false;
|
||||
@@ -1067,17 +1246,13 @@ void main()
|
||||
return module;
|
||||
}
|
||||
|
||||
// The scale pipeline for the swapchain's format (made again if it changes).
|
||||
bool EnsureScalePipeline()
|
||||
// A scale pipeline (the plain one or the tone variant) for the
|
||||
// swapchain's format; VK_NULL_HANDLE if it can't be made.
|
||||
VkPipeline MakeScalePipeline(VkShaderModule fs, VkPipelineLayout layout)
|
||||
{
|
||||
if (s_post.scale && s_post.scaleFormat == s_swapFormat)
|
||||
return true;
|
||||
if (s_post.scale)
|
||||
vkDestroyPipeline(s_device, s_post.scale, nullptr);
|
||||
s_post.scale = VK_NULL_HANDLE;
|
||||
VkPipelineShaderStageCreateInfo stages[2] = {
|
||||
{ VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_VERTEX_BIT, s_post.vs, "main", nullptr },
|
||||
{ VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_FRAGMENT_BIT, s_post.fs, "main", nullptr },
|
||||
{ VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_FRAGMENT_BIT, fs, "main", nullptr },
|
||||
};
|
||||
VkPipelineVertexInputStateCreateInfo vi{ VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO };
|
||||
VkPipelineInputAssemblyStateCreateInfo ia{ VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO };
|
||||
@@ -1114,8 +1289,22 @@ void main()
|
||||
gpci.pMultisampleState = &ms;
|
||||
gpci.pColorBlendState = &cb;
|
||||
gpci.pDynamicState = &ds;
|
||||
gpci.layout = s_post.scaleLayout;
|
||||
if (vkCreateGraphicsPipelines(s_device, VK_NULL_HANDLE, 1, &gpci, nullptr, &s_post.scale) != VK_SUCCESS)
|
||||
gpci.layout = layout;
|
||||
VkPipeline pipeline = VK_NULL_HANDLE;
|
||||
if (vkCreateGraphicsPipelines(s_device, VK_NULL_HANDLE, 1, &gpci, nullptr, &pipeline) != VK_SUCCESS)
|
||||
return VK_NULL_HANDLE;
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
// The scale pipeline for the swapchain's format (made again if it changes).
|
||||
bool EnsureScalePipeline()
|
||||
{
|
||||
if (s_post.scale && s_post.scaleFormat == s_swapFormat)
|
||||
return true;
|
||||
if (s_post.scale)
|
||||
vkDestroyPipeline(s_device, s_post.scale, nullptr);
|
||||
s_post.scale = MakeScalePipeline(s_post.fs, s_post.scaleLayout);
|
||||
if (!s_post.scale)
|
||||
return false;
|
||||
s_post.scaleFormat = s_swapFormat;
|
||||
return true;
|
||||
@@ -1217,6 +1406,146 @@ void main()
|
||||
s_post.aaHeight = height;
|
||||
}
|
||||
|
||||
// The tone pipeline's pieces, made the first time Brightness or the
|
||||
// 360's display gamma is in use (the gamma is on by default, so
|
||||
// usually on the first frame).
|
||||
bool EnsureTone()
|
||||
{
|
||||
if (s_tone.tried)
|
||||
{
|
||||
if (!s_tone.ready)
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
s_tone.tried = true;
|
||||
std::string glsl = ToneVariant(s_fastScale ? kScaleFastFs : kScaleFs);
|
||||
if (glsl.empty())
|
||||
{
|
||||
fprintf(stderr, "[video] tone: the scale shader has no single main to wrap; Brightness is off\n");
|
||||
return false;
|
||||
}
|
||||
VkDescriptorSetLayoutBinding bindings[2] = {
|
||||
{ 0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr },
|
||||
{ 1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr },
|
||||
};
|
||||
VkDescriptorSetLayoutCreateInfo dslci{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO };
|
||||
dslci.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_PUSH_DESCRIPTOR_BIT_KHR;
|
||||
dslci.bindingCount = 2;
|
||||
dslci.pBindings = bindings;
|
||||
VkPushConstantRange range{ VK_SHADER_STAGE_FRAGMENT_BIT, 0, 36 }; // the scale shaders' P
|
||||
VkPipelineLayoutCreateInfo plci{ VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO };
|
||||
plci.setLayoutCount = 1;
|
||||
plci.pSetLayouts = &s_tone.set;
|
||||
plci.pushConstantRangeCount = 1;
|
||||
plci.pPushConstantRanges = ⦥
|
||||
if (!Check(vkCreateDescriptorSetLayout(s_device, &dslci, nullptr, &s_tone.set), "tone set layout") ||
|
||||
!Check(vkCreatePipelineLayout(s_device, &plci, nullptr, &s_tone.layout), "tone pipeline layout"))
|
||||
return false;
|
||||
s_tone.fs = PostModule(glsl.c_str(), 1);
|
||||
if (!s_tone.fs)
|
||||
return false;
|
||||
|
||||
VkImageCreateInfo ii{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
|
||||
ii.imageType = VK_IMAGE_TYPE_2D;
|
||||
ii.format = VK_FORMAT_R16G16B16A16_SFLOAT; // sampled with linear filtering on every Vulkan device
|
||||
ii.extent = { 256, 1, 1 };
|
||||
ii.mipLevels = 1;
|
||||
ii.arrayLayers = 1;
|
||||
ii.samples = VK_SAMPLE_COUNT_1_BIT;
|
||||
ii.tiling = VK_IMAGE_TILING_OPTIMAL;
|
||||
ii.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT;
|
||||
if (!Check(vkCreateImage(s_device, &ii, nullptr, &s_tone.image), "tone table image"))
|
||||
return false;
|
||||
VkMemoryRequirements req;
|
||||
vkGetImageMemoryRequirements(s_device, s_tone.image, &req);
|
||||
VkMemoryAllocateInfo ai{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
|
||||
ai.allocationSize = req.size;
|
||||
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
if (ai.memoryTypeIndex == UINT32_MAX || !Check(vkAllocateMemory(s_device, &ai, nullptr, &s_tone.memory), "tone table memory"))
|
||||
return false;
|
||||
vkBindImageMemory(s_device, s_tone.image, s_tone.memory, 0);
|
||||
VkImageViewCreateInfo vci{ VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO };
|
||||
vci.image = s_tone.image;
|
||||
vci.viewType = VK_IMAGE_VIEW_TYPE_2D;
|
||||
vci.format = VK_FORMAT_R16G16B16A16_SFLOAT;
|
||||
vci.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
|
||||
if (!Check(vkCreateImageView(s_device, &vci, nullptr, &s_tone.view), "tone table view"))
|
||||
return false;
|
||||
|
||||
VkBufferCreateInfo bci{ VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO };
|
||||
bci.size = 256 * 4 * sizeof(uint16_t);
|
||||
bci.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
|
||||
if (!Check(vkCreateBuffer(s_device, &bci, nullptr, &s_tone.staging), "tone staging buffer"))
|
||||
return false;
|
||||
vkGetBufferMemoryRequirements(s_device, s_tone.staging, &req);
|
||||
ai.allocationSize = req.size;
|
||||
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
|
||||
if (ai.memoryTypeIndex == UINT32_MAX || !Check(vkAllocateMemory(s_device, &ai, nullptr, &s_tone.stagingMemory), "tone staging memory"))
|
||||
return false;
|
||||
vkBindBufferMemory(s_device, s_tone.staging, s_tone.stagingMemory, 0);
|
||||
if (!Check(vkMapMemory(s_device, s_tone.stagingMemory, 0, VK_WHOLE_SIZE, 0, &s_tone.stagingMapped), "tone staging map"))
|
||||
return false;
|
||||
s_tone.ready = true;
|
||||
}
|
||||
if (s_tone.pipeline && s_tone.format == s_swapFormat)
|
||||
return true;
|
||||
if (s_tone.pipeline)
|
||||
vkDestroyPipeline(s_device, s_tone.pipeline, nullptr);
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
s_tone.pipeline = MakeScalePipeline(s_tone.fs, s_tone.layout);
|
||||
if (!s_tone.pipeline)
|
||||
{
|
||||
fprintf(stderr, "[video] tone: the pipeline couldn't be made; Brightness is off\n");
|
||||
s_tone.ready = false;
|
||||
return false;
|
||||
}
|
||||
s_tone.format = s_swapFormat;
|
||||
fprintf(stderr, "[video] tone: pipeline made in %.0f ms\n",
|
||||
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Before the scale pass: whether this frame's picture goes through
|
||||
// the tone table (Settings > Display > Brightness, Xbox 360 Display
|
||||
// Gamma), with the table brought up to date in `cmd` when its inputs
|
||||
// changed. At Brightness 0 with the gamma off it does nothing and
|
||||
// returns false.
|
||||
bool PrepareTone(VkCommandBuffer cmd)
|
||||
{
|
||||
const int32_t brightness = std::clamp(settings::GetInt(settings::Id::Brightness), tone::kBrightnessMin, tone::kBrightnessMax);
|
||||
const bool useRamp = settings::GetBool(settings::Id::DisplayGamma);
|
||||
if (tone::Neutral(brightness, useRamp) || !EnsureTone())
|
||||
return false;
|
||||
const uint64_t rampGeneration = useRamp ? display_gamma::Generation() : 0;
|
||||
if (s_tone.filled && s_tone.brightness == brightness && s_tone.ramp == useRamp && s_tone.rampGeneration == rampGeneration)
|
||||
return true;
|
||||
display_gamma::Table ramp;
|
||||
const bool haveRamp = useRamp && display_gamma::Latest(ramp) != 0;
|
||||
const tone::Lut lut = tone::BuildLut(brightness, haveRamp ? &ramp : nullptr);
|
||||
const auto texels = tone::ToHalfTexels(lut);
|
||||
// The frames in flight may be reading the table: rare (a setting
|
||||
// or the game's own ramp changed), so wait for them.
|
||||
WaitIdle();
|
||||
memcpy(s_tone.stagingMapped, texels.data(), sizeof(texels));
|
||||
Barrier(cmd, s_tone.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT,
|
||||
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
|
||||
VkBufferImageCopy copy{};
|
||||
copy.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 };
|
||||
copy.imageExtent = { 256, 1, 1 };
|
||||
vkCmdCopyBufferToImage(cmd, s_tone.staging, s_tone.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©);
|
||||
Barrier(cmd, s_tone.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
|
||||
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
|
||||
fprintf(stderr, "[video] tone: brightness %+d, Xbox 360 display gamma %s; 64->%.1f 128->%.1f 192->%.1f (8-bit levels, red)\n",
|
||||
brightness, !useRamp ? "off" : haveRamp ? std::format("on (the game's ramp #{})", rampGeneration).c_str() : "on (no ramp from the game yet)",
|
||||
lut.entry[64][0] * 255.0, lut.entry[128][0] * 255.0, lut.entry[192][0] * 255.0);
|
||||
s_tone.filled = true;
|
||||
s_tone.brightness = brightness;
|
||||
s_tone.ramp = useRamp;
|
||||
s_tone.rampGeneration = rampGeneration;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Xenia's Tiled2D for 32x32-aligned pitches (texture_address.h).
|
||||
uint32_t Tiled2D(uint32_t x, uint32_t y, uint32_t pitchAligned, uint32_t bppLog2)
|
||||
{
|
||||
@@ -1239,19 +1568,14 @@ void main()
|
||||
if (generation == s_settingsGeneration)
|
||||
return;
|
||||
s_settingsGeneration = generation;
|
||||
// Only when the setting itself changes: the window may have been
|
||||
// toggled by the system since (and NFSMW_FULLSCREEN pins the setting).
|
||||
bool fullscreen = settings::GetBool(settings::Id::Fullscreen);
|
||||
if (fullscreen != s_fullscreenSetting && !FullscreenLocked())
|
||||
// Only when the setting itself changes (the menu, Reset page; and
|
||||
// NFSMW_FULLSCREEN pins it): the window then follows it. A change
|
||||
// the system made since stays until then.
|
||||
if (std::optional<bool> fullscreen = s_fullscreen.SettingIs(settings::GetBool(settings::Id::Fullscreen)))
|
||||
{
|
||||
s_fullscreenSetting = fullscreen;
|
||||
if (fullscreen != s_fullscreen)
|
||||
{
|
||||
s_fullscreen = fullscreen;
|
||||
bool ok = SDL_SetWindowFullscreen(s_window, fullscreen);
|
||||
fprintf(stderr, "[video] fullscreen %s (setting)%s%s\n", fullscreen ? "on" : "off", ok ? "" : ": ",
|
||||
ok ? "" : SDL_GetError());
|
||||
}
|
||||
bool ok = SDL_SetWindowFullscreen(s_window, *fullscreen);
|
||||
fprintf(stderr, "[video] fullscreen %s (setting)%s%s\n", *fullscreen ? "on" : "off", ok ? "" : ": ",
|
||||
ok ? "" : SDL_GetError());
|
||||
}
|
||||
bool rebuild = false;
|
||||
bool vsync = settings::GetBool(settings::Id::VSync);
|
||||
@@ -1415,8 +1739,7 @@ void main()
|
||||
// The size it had last time (NFSMW_WINDOW=<width>x<height> overrides).
|
||||
int windowW = settings::GetInt(settings::Id::WindowWidth), windowH = settings::GetInt(settings::Id::WindowHeight);
|
||||
s_gameMode = DetectGameMode();
|
||||
s_fullscreenSetting = settings::GetBool(settings::Id::Fullscreen);
|
||||
s_fullscreen = FullscreenLocked() || s_fullscreenSetting;
|
||||
const bool startFullscreen = s_fullscreen.Launch(settings::GetBool(settings::Id::Fullscreen), FullscreenLocked());
|
||||
s_vsync = settings::GetBool(settings::Id::VSync);
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
// Always fullscreen there (FullscreenLocked), which hides the status
|
||||
@@ -1430,7 +1753,7 @@ void main()
|
||||
platform::ios::PrepareOrientation();
|
||||
#endif
|
||||
s_window = SDL_CreateWindow("SpeedBreaker", windowW, windowH,
|
||||
SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | (s_fullscreen ? SDL_WINDOW_FULLSCREEN : 0));
|
||||
SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | (startFullscreen ? SDL_WINDOW_FULLSCREEN : 0));
|
||||
if (!s_window)
|
||||
{
|
||||
fprintf(stderr, "[video] SDL_CreateWindow: %s\n", SDL_GetError());
|
||||
@@ -1507,9 +1830,10 @@ void main()
|
||||
vkEnumerateDeviceExtensionProperties(s_physical, nullptr, &extAvail, nullptr);
|
||||
std::vector<VkExtensionProperties> avail(extAvail);
|
||||
vkEnumerateDeviceExtensionProperties(s_physical, nullptr, &extAvail, avail.data());
|
||||
bool presentId = false, presentWait = false, hostImport = false, shadingRate = false;
|
||||
bool presentId = false, presentWait = false, hostImport = false, shadingRate = false, displayTiming = false;
|
||||
for (auto& e : avail)
|
||||
{
|
||||
displayTiming |= strcmp(e.extensionName, VK_GOOGLE_DISPLAY_TIMING_EXTENSION_NAME) == 0;
|
||||
if (strcmp(e.extensionName, "VK_KHR_portability_subset") == 0)
|
||||
devExts.push_back("VK_KHR_portability_subset");
|
||||
hostImport |= strcmp(e.extensionName, VK_EXT_EXTERNAL_MEMORY_HOST_EXTENSION_NAME) == 0;
|
||||
@@ -1559,6 +1883,29 @@ void main()
|
||||
devExts.push_back(VK_KHR_PRESENT_ID_EXTENSION_NAME);
|
||||
devExts.push_back(VK_KHR_PRESENT_WAIT_EXTENSION_NAME);
|
||||
}
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
{
|
||||
// Frames shown at a time need the watchdog, which needs the
|
||||
// present waiter (VK_KHR_present_wait).
|
||||
const char* v = std::getenv("NFSMW_PRESENT_AT");
|
||||
const double ms = v ? std::atof(v) : double(kShowAfterVblankNs) / 1e6;
|
||||
s_showAfterVblankNs = int64_t(std::clamp(ms, 0.0, 100.0) * 1e6);
|
||||
s_schedule = PresentSchedule(!v ? PresentSchedule::Mode::Auto : s_showAfterVblankNs > 0 ? PresentSchedule::Mode::On
|
||||
: PresentSchedule::Mode::Off);
|
||||
// Auto, on a display that isn't a 120 Hz one at launch (a 60 Hz
|
||||
// monitor, the macOS 15 VM): the extension isn't even enabled, so
|
||||
// the session's device and presents are v0.1.3's (it isn't asked
|
||||
// for again should the window move to a 120 Hz display later).
|
||||
const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(s_window));
|
||||
PresentSchedule launch;
|
||||
launch.SetDisplayHz(mode ? mode->refresh_rate : 0.0f);
|
||||
const bool mayShowAtTime = s_schedule.GetMode() == PresentSchedule::Mode::On ||
|
||||
(s_schedule.GetMode() == PresentSchedule::Mode::Auto && launch.FastDisplay());
|
||||
s_displayTiming = displayTiming && s_presentTiming && (mayShowAtTime || std::getenv("NFSMW_VBLANK_LOG"));
|
||||
}
|
||||
#endif
|
||||
if (s_displayTiming)
|
||||
devExts.push_back(VK_GOOGLE_DISPLAY_TIMING_EXTENSION_NAME);
|
||||
|
||||
float priority = 1.0f;
|
||||
VkDeviceQueueCreateInfo qci{ VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO };
|
||||
@@ -1637,13 +1984,36 @@ void main()
|
||||
if (s_presentTiming)
|
||||
s_waitForPresent = (PFN_vkWaitForPresentKHR)vkGetDeviceProcAddr(s_device, "vkWaitForPresentKHR");
|
||||
s_presentTiming = s_waitForPresent != nullptr;
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
// No frame is shown at a time without the driver's reports and the
|
||||
// present waiter that reads them (the watchdog).
|
||||
if (s_displayTiming && s_presentTiming)
|
||||
s_pastTiming = (PFN_vkGetPastPresentationTimingGOOGLE)vkGetDeviceProcAddr(s_device, "vkGetPastPresentationTimingGOOGLE");
|
||||
if (!s_pastTiming)
|
||||
s_displayTiming = false;
|
||||
#endif
|
||||
if (s_presentTiming)
|
||||
{
|
||||
if (const char* path = std::getenv("NFSMW_VBLANK_LOG"))
|
||||
if ((s_timingLog = fopen(path, "w")))
|
||||
{
|
||||
fprintf(s_timingLog, "id,vblank,presented,on_screen,timed,fit_valid,period_ns,grid_ns,rms_ns\n");
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
if (s_pastTiming && (s_actualLog = fopen((std::string(path) + ".actual.csv").c_str(), "w")))
|
||||
fprintf(s_actualLog, "# steady_ns %lld uptime_raw_ns %llu\nid,actual_ns\n", (long long)NowNs(),
|
||||
(unsigned long long)clock_gettime_nsec_np(CLOCK_UPTIME_RAW));
|
||||
#endif
|
||||
}
|
||||
std::thread(PresentWaiter).detach();
|
||||
}
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
if (s_displayTiming && s_showAfterVblankNs > 0 && s_schedule.GetMode() != PresentSchedule::Mode::Off)
|
||||
fprintf(stderr, "[vblank] new frames shown %.2f ms after their vblank %s (VK_GOOGLE_display_timing, watched; "
|
||||
"NFSMW_PRESENT_AT=0: never)\n", double(s_showAfterVblankNs) / 1e6, s_schedule.GetMode() == PresentSchedule::Mode::On
|
||||
? "on any display (NFSMW_PRESENT_AT)" : "on a 120 Hz display the vblank isn't locked to");
|
||||
else if (s_schedule.GetMode() == PresentSchedule::Mode::Auto)
|
||||
fprintf(stderr, "[vblank] new frames shown when ready: not a 120 Hz display at launch, or no VK_GOOGLE_display_timing (not used)\n");
|
||||
#endif
|
||||
fprintf(stderr, "[vblank] %s\n", s_presentTiming ? "presents are timed (VK_KHR_present_wait)"
|
||||
: timingOff ? "presents aren't timed (NFSMW_PRESENT_TIMING=0): the guest vblank can't lock to the display"
|
||||
: "no present timing (VK_KHR_present_wait) on this device: the guest vblank can't lock to the display");
|
||||
@@ -1703,7 +2073,7 @@ void main()
|
||||
const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(s_window));
|
||||
auto [nativeW, nativeH] = NativeMonitorMode();
|
||||
fprintf(stderr, "[video] screen %dx%d%s, monitor %dx%d, %s\n", mode ? mode->w : 0, mode ? mode->h : 0,
|
||||
s_fullscreen ? " (fullscreen)" : "", nativeW, nativeH, s_gameMode ? "Game Mode (gamescope)" : "desktop");
|
||||
s_fullscreen.Window() ? " (fullscreen)" : "", nativeW, nativeH, s_gameMode ? "Game Mode (gamescope)" : "desktop");
|
||||
// For the logs' system summary and bug reports (report/system_info.cpp).
|
||||
{
|
||||
const char* type = props.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU ? "discrete"
|
||||
@@ -1748,6 +2118,13 @@ void main()
|
||||
void UpdateDisplayItem()
|
||||
{
|
||||
const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(s_window));
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
s_displayHz = mode ? mode->refresh_rate : 0.0f;
|
||||
{
|
||||
std::lock_guard lock(s_scheduleMutex);
|
||||
s_schedule.SetDisplayHz(s_displayHz);
|
||||
}
|
||||
#endif
|
||||
static const std::pair<int, int> native = NativeMonitorMode();
|
||||
const auto [nativeW, nativeH] = native;
|
||||
int windowW = 0, windowH = 0;
|
||||
@@ -1756,7 +2133,7 @@ void main()
|
||||
report::SetSystemItem("Display", std::format("{} {}x{} at {:.2f} Hz{}; window {}x{} pixels{}, "
|
||||
"swapchain {}x{}, V-Sync {}; SDL video driver {}; {}", name ? name : "?", mode ? mode->w : 0, mode ? mode->h : 0,
|
||||
mode ? mode->refresh_rate : 0.0f, nativeW > 0 ? std::format(", monitor {}x{}", nativeW, nativeH) : std::string(),
|
||||
windowW, windowH, s_fullscreen ? " (fullscreen)" : "",
|
||||
windowW, windowH, s_fullscreen.Window() ? " (fullscreen)" : "",
|
||||
s_swapExtent.width, s_swapExtent.height, s_vsync ? "on" : "off", SDL_GetCurrentVideoDriver(),
|
||||
s_gameMode ? "Game Mode (gamescope)" : "desktop"));
|
||||
}
|
||||
@@ -1874,7 +2251,8 @@ void main()
|
||||
// (NFSMW_UI_KEYS's clock), to test what follows the window's size,
|
||||
// such as the Auto internal resolution, without a person. A step of
|
||||
// <s>:full or <s>:windowed toggles fullscreen there instead (on
|
||||
// macOS a fullscreen Space, as the green button makes).
|
||||
// macOS a fullscreen Space, as the green button makes), as the system
|
||||
// would: the Fullscreen setting stays as it is.
|
||||
void ScriptedWindowSizes()
|
||||
{
|
||||
struct Step { double at; int w, h; }; // w -1: fullscreen, -2: windowed
|
||||
@@ -2251,10 +2629,16 @@ void main()
|
||||
s_visible.store(true, std::memory_order_relaxed);
|
||||
else if (e.type == SDL_EVENT_WINDOW_ENTER_FULLSCREEN || e.type == SDL_EVENT_WINDOW_LEAVE_FULLSCREEN)
|
||||
{
|
||||
// Also when the system toggled it (the green button on macOS).
|
||||
s_fullscreen = e.type == SDL_EVENT_WINDOW_ENTER_FULLSCREEN;
|
||||
if (!FullscreenLocked() && !settings::EnvOverride(settings::Id::Fullscreen))
|
||||
settings::Set(settings::Id::Fullscreen, s_fullscreen);
|
||||
// The window's state only. A change the game didn't ask for
|
||||
// (the green button or Window > Enter Full Screen on a Mac, a
|
||||
// window manager's key, a macOS fullscreen Space that didn't
|
||||
// open at launch) lasts this session: the Fullscreen setting,
|
||||
// the player's choice, still decides the next launch.
|
||||
const bool on = e.type == SDL_EVENT_WINDOW_ENTER_FULLSCREEN;
|
||||
if (s_fullscreen.WindowIs(on) == FullscreenPolicy::Change::System)
|
||||
fprintf(stderr, "[video] fullscreen %s by the system (the window's fullscreen button or menu, or the window "
|
||||
"manager), for this session; the Fullscreen setting (%s) is unchanged and decides the next launch\n",
|
||||
on ? "on" : "off", settings::GetBool(settings::Id::Fullscreen) ? "On" : "Off");
|
||||
}
|
||||
}
|
||||
// Without the queue lock: the command processor's submits take it,
|
||||
@@ -2575,6 +2959,10 @@ void main()
|
||||
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, s_timestamps, s_frameIndex * 3 + 1);
|
||||
}
|
||||
|
||||
// Brightness and the 360's display gamma: false at Brightness 0
|
||||
// with the gamma off, and then the scale pass below is exactly as
|
||||
// before.
|
||||
const bool tone = scaleInput && PrepareTone(cmd);
|
||||
Barrier(cmd, dst, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, 0,
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT);
|
||||
VkRenderingAttachmentInfoKHR color{ VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO_KHR };
|
||||
@@ -2596,11 +2984,21 @@ void main()
|
||||
VkRect2D scissor{ { dx, dy }, { uint32_t(dw), uint32_t(dh) } };
|
||||
vkCmdSetViewport(cmd, 0, 1, &viewport);
|
||||
vkCmdSetScissor(cmd, 0, 1, &scissor);
|
||||
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, s_post.scale);
|
||||
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, tone ? s_tone.pipeline : s_post.scale);
|
||||
VkDescriptorImageInfo in{ s_post.sampler, scaleInput, VK_IMAGE_LAYOUT_GENERAL };
|
||||
VkWriteDescriptorSet write{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 0, 0, 1,
|
||||
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr };
|
||||
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, s_post.scaleLayout, 0, 1, &write);
|
||||
const VkPipelineLayout scaleLayout = tone ? s_tone.layout : s_post.scaleLayout;
|
||||
if (tone)
|
||||
{
|
||||
VkDescriptorImageInfo table{ s_post.sampler, s_tone.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL };
|
||||
VkWriteDescriptorSet writes[2] = { write, write };
|
||||
writes[1].dstBinding = 1;
|
||||
writes[1].pImageInfo = &table;
|
||||
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, scaleLayout, 0, 2, writes);
|
||||
}
|
||||
else
|
||||
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, s_post.scaleLayout, 0, 1, &write);
|
||||
bool bicubic = Bicubic();
|
||||
struct
|
||||
{
|
||||
@@ -2611,7 +3009,7 @@ void main()
|
||||
} pc = { float(w), float(h), Sharpen(), bicubic ? 1u : 0u, { float(band.x0), float(band.x1), float(band.y0), float(band.y1) },
|
||||
bandOn ? 1u : 0u };
|
||||
static_assert(sizeof(pc) == 36);
|
||||
vkCmdPushConstants(cmd, s_post.scaleLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
|
||||
vkCmdPushConstants(cmd, scaleLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
|
||||
vkCmdDraw(cmd, 3, 1, 0, 0);
|
||||
if (!captures.empty() && pictureRect.extent.width)
|
||||
{
|
||||
@@ -2782,6 +3180,53 @@ void main()
|
||||
presentId.pPresentIds = &id;
|
||||
pi.pNext = &presentId;
|
||||
}
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
VkPresentTimeGOOGLE presentTime{};
|
||||
VkPresentTimesInfoGOOGLE presentTimes{ VK_STRUCTURE_TYPE_PRESENT_TIMES_INFO_GOOGLE };
|
||||
if (s_displayTiming)
|
||||
{
|
||||
presentTime.presentID = s_presentTiming ? uint32_t(id) : ++s_displayTimingId;
|
||||
// A new frame, at its vblank's time where present_schedule.h
|
||||
// says so. A redraw (menus over a paused game): when ready.
|
||||
if (frameVblank && s_showAfterVblankNs > 0)
|
||||
{
|
||||
const bool locked = GuestVblank().Locked();
|
||||
bool wanted, tripped;
|
||||
{
|
||||
std::lock_guard lock(s_scheduleMutex);
|
||||
wanted = s_schedule.Wanted(locked);
|
||||
tripped = s_schedule.Tripped();
|
||||
if (wanted)
|
||||
{
|
||||
const int64_t desired = frameVblank + s_showAfterVblankNs;
|
||||
s_scheduled[s_scheduledNext++ % std::size(s_scheduled)] = { presentTime.presentID, desired, false };
|
||||
presentTime.desiredPresentTime = uint64_t(std::max<int64_t>(desired - HostToSteadyNs(), 0));
|
||||
}
|
||||
}
|
||||
// A line when it changes (the first few).
|
||||
static int last = -1, lines = 0;
|
||||
if (int(wanted) != last && lines < 8)
|
||||
{
|
||||
lines++;
|
||||
fprintf(stderr, "[vblank] new frames shown %s\n", wanted ? "at their vblank's time"
|
||||
: tripped ? "when ready (the watchdog)"
|
||||
: locked ? "when ready: the vblank is locked to the display"
|
||||
: std::format("when ready: a {:.2f} Hz display", s_displayHz).c_str());
|
||||
}
|
||||
last = int(wanted);
|
||||
}
|
||||
// Only a frame shown at a time carries the driver's present
|
||||
// times (and, for NFSMW_VBLANK_LOG, every present): the rest are
|
||||
// presented exactly as in v0.1.3.
|
||||
if (presentTime.desiredPresentTime || s_actualLog)
|
||||
{
|
||||
presentTimes.swapchainCount = 1;
|
||||
presentTimes.pTimes = &presentTime;
|
||||
presentTimes.pNext = pi.pNext;
|
||||
pi.pNext = &presentTimes;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
int64_t presented = NowNs();
|
||||
VkResult present = vkQueuePresentKHR(s_queue, &pi);
|
||||
queueLock.unlock();
|
||||
@@ -2924,6 +3369,16 @@ void main()
|
||||
return s_contextReady ? &s_context : nullptr;
|
||||
}
|
||||
|
||||
bool WindowFullscreen()
|
||||
{
|
||||
return s_fullscreen.Window();
|
||||
}
|
||||
|
||||
bool FullscreenWindowDiffers()
|
||||
{
|
||||
return s_fullscreen.WindowDiffers();
|
||||
}
|
||||
|
||||
bool FullscreenLocked()
|
||||
{
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
}
|
||||
@@ -44,11 +44,13 @@ iOS doesn't give an app that much by default, so the app asks for two entitlemen
|
||||
([xnu, kern_exec.c](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_exec.c)).
|
||||
Apple gives it to free Apple Accounts too, but not every tool asks for it.
|
||||
|
||||
What that means for each way of signing (as of October 2026; untested with a free account so far):
|
||||
What that means for each way of signing (as of October 2026). The IPA itself hasn't been installed on a
|
||||
device yet: the tested iPhones and iPad ran the same app built and signed with Xcode and a paid account.
|
||||
|
||||
| Signed with | Entitlements the app gets | Expected |
|
||||
|---|---|---|
|
||||
| A paid Apple Developer account, any tool, or Xcode | both | works, as tested |
|
||||
| A paid Apple Developer account, Xcode (building it yourself) | both | works, as tested |
|
||||
| A paid Apple Developer account, a sideloading tool | depends on the tool (AltStore keeps only Increased Memory Limit) | likely works; untested |
|
||||
| A free Apple Account, [AltStore Classic](https://altstore.io) 2.2 or later | Increased Memory Limit | likely works on devices that support it |
|
||||
| A free Apple Account, Xcode Personal Team (building it yourself) | Increased Memory Limit, after removing the other one | likely works on devices that support it |
|
||||
| A free Apple Account, [SideStore](https://sidestore.io) 0.7 | neither ([SideStore #1616](https://github.com/SideStore/SideStore/issues/1616)) | may start; iOS may close it in busy scenes |
|
||||
@@ -63,6 +65,10 @@ start**, whose Details line names the entitlements this copy was signed with. Wh
|
||||
iOS's default address space, or a memory limit below about 3 GB, a notice says iOS may close the
|
||||
game in busy scenes.
|
||||
|
||||
Coming from a build you made yourself, the IPA installs as a separate app (the tools give it
|
||||
another bundle identifier), with its own storage: install the game again from your disc image.
|
||||
Saves made in the old app stay with it.
|
||||
|
||||
### Free Apple Account limits
|
||||
|
||||
These are Apple's, whatever the tool ([AltStore's FAQ](https://faq.altstore.io/altstore-classic/your-altstore),
|
||||
|
||||
@@ -93,7 +93,9 @@ xcodebuild -project "$ROOT/scripts/ios/SpeedBreaker.xcodeproj" -scheme SpeedBrea
|
||||
MARKETING_VERSION="${VERSION:-0.0.0}" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
|
||||
OTHER_LDFLAGS="${LIBS[*]}" build
|
||||
APP=$DERIVED/Build/Products/Release-iphoneos/SpeedBreaker.app
|
||||
nm -gU "$APP/SpeedBreaker" | grep -q ' _vkGetInstanceProcAddr$' || { echo "build.sh: vkGetInstanceProcAddr was stripped" >&2; exit 1; }
|
||||
# Counted, not grep -q: grep -q stops reading nm's long output at the first
|
||||
# match, and with pipefail nm's SIGPIPE then fails the pipe (a false alarm).
|
||||
[ "$(nm -gU "$APP/SpeedBreaker" | grep -c ' _vkGetInstanceProcAddr$')" != 0 ] || { echo "build.sh: vkGetInstanceProcAddr was stripped" >&2; exit 1; }
|
||||
echo "$APP (v${VERSION:-?}, build $BUILD_NUMBER, $(du -sh "$APP" | cut -f1))"
|
||||
|
||||
if [ "${1:-}" = install ]; then
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -201,8 +201,9 @@ To move it: copy or move the whole folder (with `data/`, which holds the
|
||||
game: about 7 GB), then add `speedbreaker.sh` to Steam again on the other
|
||||
machine (step 2). The game finds itself wherever the folder is. Keep the
|
||||
folder on internal storage or on a card or drive formatted by SteamOS
|
||||
(ext4): on one formatted for Windows (exFAT, FAT32, NTFS) the program can't
|
||||
start, though such a drive is fine for carrying a copy of the folder. On a
|
||||
(ext4): from one formatted for Windows (exFAT, FAT32, NTFS) the program may
|
||||
not start (Game Mode doesn't mount such drives, and files there can lose the
|
||||
"executable" mark), though such a drive is fine for carrying a copy. On a
|
||||
read-only drive the game plays but says it can't keep settings or saves.
|
||||
Portable mode is for SteamOS and Linux; the Mac app keeps its data in the
|
||||
Mac's own folders.
|
||||
|
||||
@@ -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
|
||||
@@ -101,7 +102,8 @@ 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']
|
||||
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')
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
XenonAnalyse game/files/default.xex game/survey/switch_tables.raw.toml
|
||||
python3 survey/fix_switch_tables.py # writes config/switch_tables.toml
|
||||
python3 survey/find_missing_tables.py > config/switch_tables_inline.toml
|
||||
python3 survey/fix_switch_tables.py # again, with the new inline tables
|
||||
python3 survey/leaf_switch_bounds.py --write
|
||||
|
||||
Reads game/survey/image.bin (survey/xexsurvey.cpp) for the bounds checks.
|
||||
"""
|
||||
import re
|
||||
import re, struct
|
||||
|
||||
RAW, OUT = 'game/survey/switch_tables.raw.toml', 'config/switch_tables.toml'
|
||||
|
||||
@@ -21,15 +26,73 @@ ADD = [
|
||||
0x825A4970, 0x825A49A0, 0x825A49CC, 0x825A49F8, 0x825A4A2C, 0x825A47B4]),
|
||||
]
|
||||
|
||||
IMG, IMG_BASE = 'game/survey/image.bin', 0x82000000
|
||||
img = open(IMG, 'rb').read()
|
||||
word = lambda a: struct.unpack('>I', img[a - IMG_BASE:a - IMG_BASE + 4])[0]
|
||||
pdata_starts = set()
|
||||
for a in range(0x820B1200, 0x820DD6B8, 8):
|
||||
pdata_starts.add(word(a))
|
||||
|
||||
|
||||
def is_exit(w):
|
||||
"""b/ba, blr, bctr, or a bc/bclr/bcctr that always branches (BO 1z1zz)."""
|
||||
op, bo = w >> 26, (w >> 21) & 31
|
||||
if op == 18:
|
||||
return w & 1 == 0
|
||||
if op == 16:
|
||||
return bo & 0x14 == 0x14 and w & 1 == 0
|
||||
if op == 19 and (w >> 1) & 0x3FF in (16, 528):
|
||||
return bo & 0x14 == 0x14 and w & 1 == 0
|
||||
return False
|
||||
|
||||
|
||||
def borrowed_bounds_check(base):
|
||||
"""XenonAnalyse's ScanTable takes a table's bounds check from the code
|
||||
before its `lis`: walking back up to 32 instructions, the first bgt/ble,
|
||||
then the first cmplwi on that cr gives `r`, the label count (immediate+1)
|
||||
and `default`. A table with no bounds check of its own (a leaf function
|
||||
whose caller guarantees the index) gets a compare from whatever precedes
|
||||
it, across a return and into the previous function. Returns (the cmplwi,
|
||||
the first exit or .pdata start between it and the table), or None when
|
||||
the compare reaches the table."""
|
||||
cr = None
|
||||
for i in range(32):
|
||||
a = base - 4 * i
|
||||
w = word(a)
|
||||
op, bo, bi = w >> 26, (w >> 21) & 31, (w >> 16) & 31
|
||||
if cr is None:
|
||||
if (op == 16 or (op == 19 and (w >> 1) & 0x3FF == 16)) and bi % 4 == 1 and bo & 0x1C in (0x0C, 0x04):
|
||||
cr = bi // 4
|
||||
elif op == 10 and (w >> 21) & 1 == 0 and (w >> 23) & 7 == cr: # cmplwi on that cr
|
||||
cut = [x for x in range(a + 4, base, 4) if is_exit(word(x))]
|
||||
cut += [x for x in range(a + 4, base + 4, 4) if x in pdata_starts]
|
||||
return (a, min(cut)) if cut else None
|
||||
return None
|
||||
|
||||
|
||||
text = open(RAW).read()
|
||||
head, *blocks = text.split('[[switch]]')
|
||||
kept = []
|
||||
kept, borrowed = [], []
|
||||
for b in blocks:
|
||||
m = re.search(r'base = 0x([0-9A-F]+)', b)
|
||||
if m and int(m.group(1), 16) in DROP:
|
||||
continue
|
||||
# A table whose bounds check is borrowed (see borrowed_bounds_check) has
|
||||
# the wrong index register and too few labels. XenonRecomp then emits a
|
||||
# switch on that register over those labels with an unreachable default,
|
||||
# so the function always takes its first case: every Performance Tuning
|
||||
# row got the first row's label ("Steering", GitHub issue #8). Drop it:
|
||||
# find_missing_tables.py decodes it from its dispatch, as a table with no
|
||||
# bounds check. Regenerate config/switch_tables_inline.toml after this.
|
||||
if m and 'default = ' in b:
|
||||
cut = borrowed_bounds_check(int(m.group(1), 16))
|
||||
if cut:
|
||||
borrowed.append((int(m.group(1), 16), *cut))
|
||||
continue
|
||||
kept.append(b)
|
||||
assert len(blocks) - len(kept) == len(DROP), 'a DROP entry no longer matches the analyzer output'
|
||||
assert len(blocks) - len(kept) == len(DROP) + len(borrowed), 'a DROP entry no longer matches the analyzer output'
|
||||
for base, cmp, cut in borrowed:
|
||||
print(f'borrowed bounds check: table 0x{base:08X} took cmplwi 0x{cmp:08X}, cut off at 0x{cut:08X}; dropped')
|
||||
|
||||
out = head + '[[switch]]'.join([''] + kept)
|
||||
out = out.rstrip() + '\n\n# ---- MANUAL (survey/fix_switch_tables.py) ----\n'
|
||||
@@ -42,4 +105,4 @@ for t in ADD:
|
||||
out += '# ---- INLINE (config/switch_tables_inline.toml) ----\n'
|
||||
out += open('config/switch_tables_inline.toml').read()
|
||||
open(OUT, 'w').write(out)
|
||||
print(f'{OUT}: {len(kept) + len(ADD)} tables ({len(DROP)} dropped, {len(ADD)} added)')
|
||||
print(f'{OUT}: {len(kept) + len(ADD)} tables ({len(DROP) + len(borrowed)} dropped, {len(ADD)} added)')
|
||||
@@ -129,11 +129,14 @@ block = '\n'.join(lines)
|
||||
if '--write' in sys.argv:
|
||||
MARKER = '# --- generated by survey/leaf_switch_bounds.py --write ---'
|
||||
cfg = 'config/nfsmw.toml'
|
||||
text = open(cfg).read().split('# --- generated by survey/leaf_switch_bounds.py')[0].rstrip()
|
||||
full = open(cfg).read()
|
||||
text = full.split('# --- generated by survey/leaf_switch_bounds.py')[0].rstrip()
|
||||
# Keep what follows the generated list (the hooks).
|
||||
rest = full.split(MARKER, 1)[1].split('\n]\n', 1)[1] if MARKER in full else ''
|
||||
open(cfg, 'w').write(text + '\n\n' + MARKER + '\n'
|
||||
'# Leaf functions (no .pdata) with jump tables: start = latest entry (bl target,\n'
|
||||
'# data-referenced address, or after padding); end = branch-closure extent;\n'
|
||||
'# overlaps merged. Plus MANUAL entries. Do not edit by hand; rerun.\n' + block + '\n')
|
||||
'# overlaps merged. Plus MANUAL entries. Do not edit by hand; rerun.\n' + block + '\n' + rest)
|
||||
print(f'wrote {cfg}', file=sys.stderr)
|
||||
else:
|
||||
print(block)
|
||||
@@ -22,6 +22,10 @@ sb_test(zpd_report_test)
|
||||
# The resolve shader's 24-bit depth pack (gpu/depth24_glsl.h), header only;
|
||||
# renderer.cpp is read to check its resolve shader uses it.
|
||||
sb_test(depth24_pack_test ARGS ${RT}/gpu/renderer.cpp)
|
||||
# Depth-only draws run no pixel shader (gpu/depth_only.h, header only): the
|
||||
# sun's shadow pass as the game draws it, and renderer.cpp's Draw using the
|
||||
# rule (the cars' and tunnel roofs' shadows, v0.1.3).
|
||||
sb_test(depth_only_test ARGS ${RT}/gpu/renderer.cpp)
|
||||
# The internal resolution as a ratio, 1.5x included: guest <-> host pixels
|
||||
# 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.
|
||||
@@ -45,15 +49,43 @@ sb_test(band_filter_test SOURCES ${RT}/video/picture_fit.cpp)
|
||||
# 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)
|
||||
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)
|
||||
@@ -108,6 +140,16 @@ sb_test(version_check_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})
|
||||
# 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
|
||||
# Performance Tuning labels, GitHub issue #8) as the guest dispatches them.
|
||||
sb_test(switch_tables_test ARGS ${ROOT}/config/switch_tables.toml ${ROOT}/config/nfsmw.toml)
|
||||
# The write watch around CPU write faults, on physical memory mapped as the
|
||||
# runtime maps it.
|
||||
sb_test(write_watch_test SOURCES ${RT}/kernel/write_watch.cpp ${RT}/cpu/guest_time.cpp)
|
||||
|
||||
@@ -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,212 @@
|
||||
// Tests for gpu/depth_only.h: draws in depth-only EDRAM mode run no pixel
|
||||
// shader (Xenos writes their rasterised depth and stencil only). Up to v0.1.2
|
||||
// the renderer ran the pixel shader still bound, and in the sun's shadow pass
|
||||
// that was the alpha-tested tree casters' one: with no texture coordinates
|
||||
// from the cars' (and tunnel roofs') vertex shaders it read texel (0,0)'s
|
||||
// alpha and killed every pixel, so cars cast no sun shadow and tunnels let
|
||||
// the sun through. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/depth_only_test.cpp -o build/depth_only_test
|
||||
// build/depth_only_test runtime/gpu/renderer.cpp
|
||||
// The shadow pass is modelled with the order and the state the game's
|
||||
// commands have, on a small depth map. With
|
||||
// renderer.cpp's path (ctest passes it), Draw is also checked to use the
|
||||
// rule: the empty pixel shader for depth-only draws, before its shader checks
|
||||
// and its pipeline, and no alpha test for them.
|
||||
#include <gpu/depth_only.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using xe::gpu::xenos::EdramMode;
|
||||
|
||||
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 void TestRule()
|
||||
{
|
||||
CHECK(gpu::RunsGuestPixelShader(EdramMode::kColorDepth), "colour+depth draws run their pixel shader");
|
||||
CHECK(!gpu::RunsGuestPixelShader(EdramMode::kDepthOnly), "depth-only draws run none");
|
||||
CHECK(gpu::RunsGuestPixelShader(EdramMode::kDepthOnly, true), "NFSMW_DEPTH_ONLY_PS=1: the bound one, as up to v0.1.2");
|
||||
CHECK(gpu::RunsGuestPixelShader(EdramMode::kColorDepth, true), "the override changes depth-only draws only");
|
||||
}
|
||||
|
||||
static void TestEmptyShader()
|
||||
{
|
||||
const std::string g = gpu::kEmptyPixelShaderGlsl;
|
||||
CHECK(g.rfind("#version 460\n", 0) == 0, "a GLSL 4.60 shader: %s", g.c_str());
|
||||
CHECK(g.find("void main()") != std::string::npos, "has main: %s", g.c_str());
|
||||
for (const char* word : { "discard", "out ", "in ", "uniform", "layout", "buffer", "gl_FragDepth" })
|
||||
CHECK(g.find(word) == std::string::npos, "no '%s' (no kill, outputs, inputs or resources): %s", word, g.c_str());
|
||||
}
|
||||
|
||||
// The sun's shadow pass on a 32x32 map, as the game draws it each frame:
|
||||
// a depth-only clear, the alpha-tested world casters (colour+depth), the
|
||||
// depth-only world occluders (a tunnel roof), the resolve to 03A8A000 (B),
|
||||
// the depth-only car casters, the resolve to 0444E000 (A).
|
||||
namespace model
|
||||
{
|
||||
constexpr int kSize = 32;
|
||||
using Map = std::array<float, kSize * kSize>;
|
||||
|
||||
// The tree casters' pixel shader (ps_7f7cf48a3edfed26): kill when the
|
||||
// texture's alpha at the interpolated coordinate is 0.2 or less (PS c0.x,
|
||||
// with c1.x = 1 to enable it). Its texture: alpha 0 at texel (0,0) (as
|
||||
// the one bound for the cars samples), 1 over the leaves.
|
||||
float Alpha(int u, int v) { return (u == 0 && v == 0) ? 0.0f : 1.0f; }
|
||||
bool Kills(bool hasTexcoord, int u, int v)
|
||||
{
|
||||
// No texture coordinate exported by the vertex shader: the input
|
||||
// register reads 0 (as the renderer, and Xenia, give it).
|
||||
float a = hasTexcoord ? Alpha(u, v) : Alpha(0, 0);
|
||||
return a <= 0.2f;
|
||||
}
|
||||
|
||||
struct Draw
|
||||
{
|
||||
EdramMode mode;
|
||||
int x0, y0, x1, y1;
|
||||
float z;
|
||||
bool texcoord; // the vertex shader exports one
|
||||
bool always; // depth test ALWAYS (the clear), else LEQUAL
|
||||
bool treeShader; // the pixel shader bound is the tree casters' (else the clear's, which kills nothing)
|
||||
};
|
||||
|
||||
void Run(Map& depth, const Draw& d, bool guestOverride)
|
||||
{
|
||||
bool shaderRuns = gpu::RunsGuestPixelShader(d.mode, guestOverride);
|
||||
for (int y = d.y0; y < d.y1; y++)
|
||||
for (int x = d.x0; x < d.x1; x++)
|
||||
{
|
||||
if (shaderRuns && d.treeShader && Kills(d.texcoord, x + 1, y + 1))
|
||||
continue;
|
||||
float& z = depth[y * kSize + x];
|
||||
if (d.always || d.z <= z)
|
||||
z = d.z;
|
||||
}
|
||||
}
|
||||
|
||||
struct Pass { Map b, a; };
|
||||
|
||||
Pass ShadowPass(bool guestOverride)
|
||||
{
|
||||
Map depth;
|
||||
depth.fill(0.0f); // whatever EDRAM held
|
||||
Run(depth, { EdramMode::kDepthOnly, 0, 0, kSize, kSize, 1.0f, false, true, false }, guestOverride); // the clear
|
||||
Run(depth, { EdramMode::kColorDepth, 2, 2, 8, 8, 0.47f, true, false, true }, guestOverride); // a tree
|
||||
Run(depth, { EdramMode::kDepthOnly, 12, 0, 20, 32, 0.50f, false, false, true }, guestOverride); // a tunnel roof
|
||||
Pass p;
|
||||
p.b = depth; // resolve B
|
||||
Run(depth, { EdramMode::kDepthOnly, 24, 20, 28, 30, 0.52f, false, false, true }, guestOverride); // a car
|
||||
p.a = depth; // resolve A
|
||||
return p;
|
||||
}
|
||||
|
||||
int Count(const Map& m, int x0, int y0, int x1, int y1, float z)
|
||||
{
|
||||
int n = 0;
|
||||
for (int y = y0; y < y1; y++)
|
||||
for (int x = x0; x < x1; x++)
|
||||
n += m[y * kSize + x] == z;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
static void TestShadowPass()
|
||||
{
|
||||
using namespace model;
|
||||
Pass p = ShadowPass(false);
|
||||
CHECK(Count(p.b, 0, 0, kSize, kSize, 1.0f) == kSize * kSize - 36 - 8 * 32, "B: far plane where no caster is (%d)",
|
||||
Count(p.b, 0, 0, kSize, kSize, 1.0f));
|
||||
CHECK(Count(p.b, 2, 2, 8, 8, 0.47f) == 36, "B holds the tree (%d of 36)", Count(p.b, 2, 2, 8, 8, 0.47f));
|
||||
CHECK(Count(p.b, 12, 0, 20, 32, 0.50f) == 256, "B holds the tunnel roof (%d of 256)", Count(p.b, 12, 0, 20, 32, 0.50f));
|
||||
CHECK(Count(p.a, 24, 20, 28, 30, 0.52f) == 40, "A holds the car (%d of 40)", Count(p.a, 24, 20, 28, 30, 0.52f));
|
||||
int differ = 0;
|
||||
for (int i = 0; i < kSize * kSize; i++)
|
||||
differ += p.a[i] != p.b[i];
|
||||
CHECK(differ == 40, "A is B plus the car, nothing else (%d texels differ)", differ);
|
||||
|
||||
// Up to v0.1.2 (the bound pixel shader run depth-only): the tree only,
|
||||
// A == B: no car shadow, no tunnel shade. The model must show the bug.
|
||||
Pass old = ShadowPass(true);
|
||||
CHECK(Count(old.b, 2, 2, 8, 8, 0.47f) == 36, "v0.1.2 model: the tree (%d)", Count(old.b, 2, 2, 8, 8, 0.47f));
|
||||
CHECK(Count(old.b, 12, 0, 20, 32, 0.50f) == 0, "v0.1.2 model: no tunnel roof (%d)", Count(old.b, 12, 0, 20, 32, 0.50f));
|
||||
CHECK(old.a == old.b, "v0.1.2 model: A == B, no car");
|
||||
}
|
||||
|
||||
// renderer.cpp's Draw, comments left out.
|
||||
static std::string DrawBody(const std::string& source)
|
||||
{
|
||||
std::string code;
|
||||
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";
|
||||
}
|
||||
size_t start = code.find("void Draw(uint32_t initiatorValue");
|
||||
if (start == std::string::npos)
|
||||
return {};
|
||||
// To the next top-level function of the namespace (Draw's closing brace
|
||||
// is followed by `void Submit()`).
|
||||
size_t end = code.find("void Submit()", start);
|
||||
return code.substr(start, end == std::string::npos ? std::string::npos : end - start);
|
||||
}
|
||||
|
||||
static std::string Squash(const std::string& s)
|
||||
{
|
||||
std::string r;
|
||||
for (char c : s)
|
||||
if (c != ' ' && c != '\n' && c != '\t')
|
||||
r += c;
|
||||
return r;
|
||||
}
|
||||
|
||||
static void TestRenderer(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();
|
||||
const std::string draw = Squash(DrawBody(source));
|
||||
CHECK(!draw.empty(), "Draw not found in %s", path);
|
||||
if (draw.empty())
|
||||
return;
|
||||
size_t swap = draw.find("if(!gpu::RunsGuestPixelShader(edramMode,s_depthOnlyGuestPs))s_ps=DepthOnlyPixelShader();");
|
||||
size_t restore = draw.find("~RestorePs(){s_ps=ps;}}restorePs{s_ps};");
|
||||
size_t loaded = draw.find("if(!s_vs||!s_ps)");
|
||||
size_t key = draw.find("key.ps=s_ps->hash;");
|
||||
CHECK(swap != std::string::npos, "Draw gives depth-only draws the empty pixel shader");
|
||||
CHECK(restore != std::string::npos && restore < swap, "Draw puts the guest's pixel shader back after the draw");
|
||||
CHECK(loaded != std::string::npos && swap < loaded, "the swap comes before the 'no shaders loaded' check");
|
||||
CHECK(key != std::string::npos && swap < key, "the swap comes before the pipeline key");
|
||||
CHECK(draw.find("alpha_func!=xenos::CompareFunction::kAlways&&gpu::RunsGuestPixelShader(edramMode,s_depthOnlyGuestPs);") !=
|
||||
std::string::npos, "no alpha test for depth-only draws");
|
||||
const std::string all = Squash(source);
|
||||
CHECK(all.find("slot->info.glsl=gpu::kEmptyPixelShaderGlsl;") != std::string::npos, "DepthOnlyPixelShader is the empty shader");
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
TestRule();
|
||||
TestEmptyShader();
|
||||
TestShadowPass();
|
||||
if (argc > 1)
|
||||
TestRenderer(argv[1]);
|
||||
else
|
||||
printf("(no renderer.cpp given: its checks skipped)\n");
|
||||
if (g_failures)
|
||||
{
|
||||
printf("depth_only_test: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("depth_only_test: all passed\n");
|
||||
return 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;
|
||||
}
|
||||
@@ -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,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,298 @@
|
||||
// Tests for game::trails (runtime/game/light_trails.h): what the nitrous light
|
||||
// trails' ring holds when RenderFlares draws them, with the hook and without,
|
||||
// at 60 fps, 30 fps and frames between (GitHub issue #10). From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/light_trails_test.cpp -o build/light_trails_test
|
||||
// build/light_trails_test
|
||||
// The game's side is simulated as sub_824E6220 does it: the index moves
|
||||
// (-1 -> 0 first), the hook runs at the matrix copy, then the newest entry is
|
||||
// written. The trail is drawn between the three entries, so its length is the
|
||||
// distance from the newest to the oldest.
|
||||
#include <game/light_trails.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#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 namespace game::trails;
|
||||
|
||||
// A car on a straight line at `speed` units per ms, its matrix tagged
|
||||
// with the time it was written (so a test can tell which frame's it is).
|
||||
Entry CarAt(double ms, double speed)
|
||||
{
|
||||
Entry e;
|
||||
e.pos[0] = float(1000.0 + speed * ms);
|
||||
e.pos[1] = 5.0f;
|
||||
e.pos[2] = float(-200.0 + 0.5 * speed * ms);
|
||||
const uint64_t tag = uint64_t(std::llround(ms * 1000.0));
|
||||
memcpy(e.matrix.data(), &tag, sizeof(tag));
|
||||
return e;
|
||||
}
|
||||
|
||||
uint64_t TagOf(const Entry& e)
|
||||
{
|
||||
uint64_t tag;
|
||||
memcpy(&tag, e.matrix.data(), sizeof(tag));
|
||||
return tag;
|
||||
}
|
||||
|
||||
struct Ring
|
||||
{
|
||||
Entry e[3];
|
||||
int32_t index = -1;
|
||||
History history;
|
||||
bool hooked = true;
|
||||
|
||||
// One RenderFlares call in a player view at game time `ms`.
|
||||
void Draw(double ms, const Entry& now)
|
||||
{
|
||||
if (index < 0)
|
||||
index = 0;
|
||||
if (++index > 2)
|
||||
index = 0;
|
||||
const uint32_t slot = uint32_t(index);
|
||||
if (hooked)
|
||||
{
|
||||
history.Add(ms, now);
|
||||
const Fill f = FillFor(history, ms);
|
||||
if (f.hasMiddle)
|
||||
e[MiddleSlot(slot)] = f.middle;
|
||||
if (f.hasOldest)
|
||||
e[OldestSlot(slot)] = f.oldest;
|
||||
}
|
||||
e[slot] = now;
|
||||
}
|
||||
const Entry& Newest() const { return e[index]; }
|
||||
const Entry& Middle() const { return e[MiddleSlot(uint32_t(index))]; }
|
||||
const Entry& Oldest() const { return e[OldestSlot(uint32_t(index))]; }
|
||||
};
|
||||
|
||||
double Dist(const Entry& a, const Entry& b)
|
||||
{
|
||||
double s = 0;
|
||||
for (int k = 0; k < 3; k++)
|
||||
s += (double(a.pos[k]) - double(b.pos[k])) * (double(a.pos[k]) - double(b.pos[k]));
|
||||
return std::sqrt(s);
|
||||
}
|
||||
|
||||
bool SameEntry(const Entry& a, const Entry& b)
|
||||
{
|
||||
return memcmp(a.pos, b.pos, sizeof(a.pos)) == 0 && a.matrix == b.matrix;
|
||||
}
|
||||
|
||||
// Game time as GameFrameTime sums it: each frame's ms in 16.16, rounded.
|
||||
struct Clock
|
||||
{
|
||||
uint64_t units = 0;
|
||||
double ms = 0;
|
||||
void Advance(double frameMs)
|
||||
{
|
||||
units += uint64_t(std::llround(frameMs * 65536.0));
|
||||
ms = double(units) / 65536.0;
|
||||
}
|
||||
};
|
||||
|
||||
constexpr double kSpeed = 0.06; // units per ms: ~60 m/s if a unit is a metre
|
||||
constexpr double kV = kVblankMs;
|
||||
|
||||
void SlotMath()
|
||||
{
|
||||
printf("slots\n");
|
||||
// Newest 1 (the first write, from -1): middle 0, oldest 2; and around.
|
||||
CHECK(MiddleSlot(1) == 0 && OldestSlot(1) == 2, "newest 1");
|
||||
CHECK(MiddleSlot(2) == 1 && OldestSlot(2) == 0, "newest 2");
|
||||
CHECK(MiddleSlot(0) == 2 && OldestSlot(0) == 1, "newest 0");
|
||||
}
|
||||
|
||||
// 60 fps: the game alone spans 2 vblanks of driving; hooked, 4 (the 360's
|
||||
// two 30 fps frames), the middle exactly halfway, each entry the frame's
|
||||
// own (not interpolated).
|
||||
void SixtyFps()
|
||||
{
|
||||
printf("60 fps\n");
|
||||
Ring game, hooked;
|
||||
game.hooked = false;
|
||||
Clock c;
|
||||
for (int f = 0; f < 120; f++)
|
||||
{
|
||||
c.Advance(kV);
|
||||
game.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
}
|
||||
const double gameSpan = Dist(game.Newest(), game.Oldest()), span = Dist(hooked.Newest(), hooked.Oldest());
|
||||
const double want = Dist(CarAt(0, kSpeed), CarAt(4 * kV, kSpeed));
|
||||
CHECK(std::abs(gameSpan - want / 2) < 1e-3, "the game's own span %.4f, want half the 360's %.4f", gameSpan, want / 2);
|
||||
CHECK(std::abs(span - want) < 1e-3, "hooked span %.4f, want %.4f (4/60 s at the car's speed)", span, want);
|
||||
CHECK(std::abs(Dist(hooked.Newest(), hooked.Middle()) - want / 2) < 1e-3, "middle not halfway");
|
||||
// Entries are the samples the game wrote 2 and 4 frames ago, as written.
|
||||
const uint64_t now = TagOf(hooked.Newest());
|
||||
CHECK(now - TagOf(hooked.Middle()) == uint64_t(std::llround(2 * kV * 1000)) ||
|
||||
now - TagOf(hooked.Middle()) == uint64_t(std::llround(2 * kV * 1000)) + 1,
|
||||
"middle is the frame 2 vblanks back (tags %llu %llu)", (unsigned long long)now, (unsigned long long)TagOf(hooked.Middle()));
|
||||
CHECK(now - TagOf(hooked.Oldest()) >= uint64_t(std::llround(4 * kV * 1000)) - 1 &&
|
||||
now - TagOf(hooked.Oldest()) <= uint64_t(std::llround(4 * kV * 1000)) + 1,
|
||||
"oldest is the frame 4 vblanks back");
|
||||
}
|
||||
|
||||
// 30 fps (the 360's own rate, and Frame Rate 30): the hook writes exactly
|
||||
// what the game had in those entries.
|
||||
void ThirtyFpsUnchanged()
|
||||
{
|
||||
printf("30 fps: no change\n");
|
||||
Ring game, hooked;
|
||||
game.hooked = false;
|
||||
Clock c;
|
||||
int differ = 0;
|
||||
for (int f = 0; f < 90; f++)
|
||||
{
|
||||
c.Advance(2 * kV);
|
||||
game.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
for (int s = 0; s < 3; s++)
|
||||
differ += !SameEntry(game.e[s], hooked.e[s]);
|
||||
}
|
||||
CHECK(differ == 0, "%d ring entries differ from the game's at 30 fps", differ);
|
||||
}
|
||||
|
||||
// Frames between: Auto switching 60 -> 30 -> 60, a GPU-bound 2-vblank
|
||||
// frame now and then, measured (unsnapped) frame times. The span stays
|
||||
// 4/60 s of driving (to interpolation), never the game's 2 frames.
|
||||
void MixedFrames()
|
||||
{
|
||||
printf("mixed frames\n");
|
||||
Ring hooked;
|
||||
Clock c;
|
||||
std::vector<double> frames;
|
||||
for (int i = 0; i < 60; i++)
|
||||
frames.push_back(kV);
|
||||
for (int i = 0; i < 30; i++)
|
||||
frames.push_back(2 * kV);
|
||||
for (int i = 0; i < 60; i++)
|
||||
frames.push_back(i % 7 == 3 ? 2 * kV : kV);
|
||||
for (int i = 0; i < 60; i++)
|
||||
frames.push_back(kV * (0.9 + 0.2 * double((i * 37) % 11) / 10.0)); // 15.0-18.3 ms, as measured
|
||||
double worst = 0;
|
||||
for (size_t i = 0; i < frames.size(); i++)
|
||||
{
|
||||
c.Advance(frames[i]);
|
||||
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
if (i < 8)
|
||||
continue;
|
||||
const double span = Dist(hooked.Newest(), hooked.Oldest()), want = Dist(CarAt(0, kSpeed), CarAt(4 * kV, kSpeed));
|
||||
worst = std::max(worst, std::abs(span - want) / want);
|
||||
}
|
||||
CHECK(worst < 1e-3, "span off by %.4f%% of 4/60 s", worst * 100);
|
||||
}
|
||||
|
||||
// Paused (the world stands still, frames go on): the trail collapses onto
|
||||
// the car 4 vblanks after the stop, as a 360's does at its second paused
|
||||
// frame (its ring then holds the stop three times).
|
||||
void PausedCollapses()
|
||||
{
|
||||
printf("paused\n");
|
||||
Ring hooked;
|
||||
Clock c;
|
||||
for (int f = 0; f < 60; f++)
|
||||
{
|
||||
c.Advance(kV);
|
||||
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
}
|
||||
const double stopMs = c.ms;
|
||||
for (int f = 1; f <= 3; f++)
|
||||
{
|
||||
c.Advance(kV);
|
||||
hooked.Draw(c.ms, CarAt(stopMs, kSpeed));
|
||||
CHECK(Dist(hooked.Newest(), hooked.Oldest()) > 0, "collapsed %d vblanks after the stop, before 4", f);
|
||||
}
|
||||
c.Advance(kV);
|
||||
hooked.Draw(c.ms, CarAt(stopMs, kSpeed));
|
||||
CHECK(Dist(hooked.Newest(), hooked.Oldest()) == 0 && Dist(hooked.Newest(), hooked.Middle()) == 0, "not collapsed 4 vblanks after the stop");
|
||||
}
|
||||
|
||||
// A car drawn for the first time, or again after a gap: where the history
|
||||
// doesn't reach, the game's entries stay (never one from before the gap).
|
||||
void ShortHistory()
|
||||
{
|
||||
printf("short history and gaps\n");
|
||||
Ring game, hooked;
|
||||
game.hooked = false;
|
||||
Clock c;
|
||||
for (int f = 0; f < 4; f++)
|
||||
{
|
||||
c.Advance(kV);
|
||||
game.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
const Fill fill = FillFor(hooked.history, c.ms);
|
||||
CHECK(fill.hasMiddle == (f >= 2) && fill.hasOldest == false, "frame %d: middle %d oldest %d", f, fill.hasMiddle, fill.hasOldest);
|
||||
for (int s = 0; s < 3; s++)
|
||||
CHECK(f >= 2 || SameEntry(game.e[s], hooked.e[s]), "frame %d entry %d changed with no history", f, s);
|
||||
}
|
||||
c.Advance(kV);
|
||||
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
CHECK(FillFor(hooked.history, c.ms).hasOldest, "4 vblanks of history: oldest filled");
|
||||
|
||||
// Off screen for half a second, then back somewhere else.
|
||||
c.Advance(500.0);
|
||||
const double back = c.ms;
|
||||
hooked.Draw(c.ms, CarAt(c.ms + 1e5, kSpeed));
|
||||
CHECK(hooked.history.Size() == 1, "history kept across a %.0f ms gap (%zu samples)", 500.0, hooked.history.Size());
|
||||
const Fill fill = FillFor(hooked.history, back);
|
||||
CHECK(!fill.hasMiddle && !fill.hasOldest, "filled from before the gap");
|
||||
}
|
||||
|
||||
// Two player-view calls in one frame (the same game time): one sample.
|
||||
void SameFrameTwice()
|
||||
{
|
||||
printf("same frame twice\n");
|
||||
History h;
|
||||
h.Add(100.0, CarAt(100.0, kSpeed));
|
||||
h.Add(100.0, CarAt(100.0, kSpeed));
|
||||
CHECK(h.Size() == 1, "%zu samples", h.Size());
|
||||
h.Add(90.0, CarAt(90.0, kSpeed));
|
||||
CHECK(h.Size() == 1 && h.NewestMs() == 90.0, "time going back starts over");
|
||||
}
|
||||
|
||||
// Up to 360 fps (well past any display the vblank locks to) the history
|
||||
// still reaches 4/60 s back.
|
||||
void HighFrameRates()
|
||||
{
|
||||
printf("high frame rates\n");
|
||||
for (double fps : { 90.0, 120.0, 144.0, 240.0, 360.0 })
|
||||
{
|
||||
Ring hooked;
|
||||
Clock c;
|
||||
for (int f = 0; f < 200; f++)
|
||||
{
|
||||
c.Advance(1000.0 / fps);
|
||||
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
|
||||
}
|
||||
const double span = Dist(hooked.Newest(), hooked.Oldest()), want = Dist(CarAt(0, kSpeed), CarAt(4 * kV, kSpeed));
|
||||
CHECK(std::abs(span - want) / want < 1e-3, "%.0f fps: span %.4f, want %.4f", fps, span, want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
SlotMath();
|
||||
SixtyFps();
|
||||
ThirtyFpsUnchanged();
|
||||
MixedFrames();
|
||||
PausedCollapses();
|
||||
ShortHistory();
|
||||
SameFrameTwice();
|
||||
HighFrameRates();
|
||||
if (g_failures)
|
||||
{
|
||||
printf("%d FAILED\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("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,279 @@
|
||||
// Tests for config/switch_tables.toml against config/nfsmw.toml's leaf
|
||||
// function bounds: the jump tables XenonRecomp turns into C++ switches.
|
||||
// XenonAnalyse takes a table's index register, label count and default from
|
||||
// the bounds check before it (a cmplwi and a bgt/ble within 32
|
||||
// instructions). A table with no bounds check of its own got the compare of
|
||||
// the code before it, across a return: in v0.1.2 sub_822E7CE0 (the
|
||||
// Performance Tuning row's label, GitHub issue #8) switched on r3 over one
|
||||
// label instead of r4 over seven, so every row read "Steering"; two more
|
||||
// leaf functions (sub_822C7720, sub_822C89F8) had the same fault.
|
||||
// survey/fix_switch_tables.py now drops such tables and
|
||||
// survey/find_missing_tables.py decodes them from the dispatch. Checked
|
||||
// here, with no game files:
|
||||
// - every table inside a leaf function the config bounds keeps its labels
|
||||
// and its default (0 = a conditional return) inside that function: a
|
||||
// borrowed default lies before the function's start;
|
||||
// - the three tables as the guest code dispatches them;
|
||||
// - the v0.1.2 entries, planted, fail the first check.
|
||||
// From the repo root:
|
||||
// clang++ -std=c++20 -O2 tests/switch_tables_test.cpp -o build/switch_tables_test
|
||||
// build/switch_tables_test config/switch_tables.toml config/nfsmw.toml
|
||||
#include <cinttypes>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <regex>
|
||||
#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)
|
||||
|
||||
struct Table
|
||||
{
|
||||
uint32_t base = 0;
|
||||
uint32_t r = 99;
|
||||
bool hasDefault = false;
|
||||
uint32_t defaultLabel = 0;
|
||||
std::vector<uint32_t> labels;
|
||||
};
|
||||
|
||||
struct Function
|
||||
{
|
||||
uint32_t address = 0;
|
||||
uint32_t size = 0;
|
||||
bool Contains(uint32_t a) const { return a >= address && a < address + size; }
|
||||
};
|
||||
|
||||
static std::string ReadFile(const char* path)
|
||||
{
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
std::stringstream s;
|
||||
s << f.rdbuf();
|
||||
return s.str();
|
||||
}
|
||||
|
||||
// The subset of TOML the generators write: [[switch]] blocks of
|
||||
// `base = 0x..`, `r = N`, `default = 0x..` and a `labels = [ 0x.., ]` list.
|
||||
static std::vector<Table> ParseTables(const std::string& text)
|
||||
{
|
||||
std::vector<Table> tables;
|
||||
std::istringstream in(text);
|
||||
std::string line;
|
||||
bool inLabels = false;
|
||||
std::smatch m;
|
||||
static const std::regex kKey(R"(^\s*(base|r|default)\s*=\s*(0x[0-9A-Fa-f]+|\d+)\s*$)");
|
||||
static const std::regex kLabel(R"(^\s*(0x[0-9A-Fa-f]+)\s*,?\s*$)");
|
||||
while (std::getline(in, line))
|
||||
{
|
||||
if (line.rfind("[[switch]]", 0) == 0)
|
||||
{
|
||||
tables.emplace_back();
|
||||
inLabels = false;
|
||||
continue;
|
||||
}
|
||||
if (tables.empty() || line.empty() || line[0] == '#')
|
||||
continue;
|
||||
Table& t = tables.back();
|
||||
if (inLabels)
|
||||
{
|
||||
if (line.find(']') != std::string::npos)
|
||||
inLabels = false;
|
||||
else if (std::regex_match(line, m, kLabel))
|
||||
t.labels.push_back(uint32_t(std::stoul(m[1].str(), nullptr, 16)));
|
||||
continue;
|
||||
}
|
||||
if (line.rfind("labels", 0) == 0)
|
||||
{
|
||||
inLabels = true;
|
||||
continue;
|
||||
}
|
||||
if (std::regex_match(line, m, kKey))
|
||||
{
|
||||
uint32_t v = uint32_t(std::stoul(m[2].str(), nullptr, 0));
|
||||
if (m[1] == "base")
|
||||
t.base = v;
|
||||
else if (m[1] == "r")
|
||||
t.r = v;
|
||||
else
|
||||
{
|
||||
t.hasDefault = true;
|
||||
t.defaultLabel = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
static std::vector<Function> ParseFunctions(const std::string& text)
|
||||
{
|
||||
std::vector<Function> functions;
|
||||
static const std::regex kFunction(R"(\{\s*address\s*=\s*(0x[0-9A-Fa-f]+)\s*,\s*size\s*=\s*(0x[0-9A-Fa-f]+)\s*\})");
|
||||
for (std::sregex_iterator it(text.begin(), text.end(), kFunction), end; it != end; ++it)
|
||||
functions.push_back({ uint32_t(std::stoul((*it)[1].str(), nullptr, 16)), uint32_t(std::stoul((*it)[2].str(), nullptr, 16)) });
|
||||
return functions;
|
||||
}
|
||||
|
||||
// Tables inside a bounded leaf function with a label or a default outside
|
||||
// it, as "base: what" lines.
|
||||
static std::vector<std::string> Escapes(const std::vector<Table>& tables, const std::vector<Function>& functions)
|
||||
{
|
||||
std::vector<std::string> out;
|
||||
char buf[160];
|
||||
for (const Table& t : tables)
|
||||
for (const Function& f : functions)
|
||||
{
|
||||
if (!f.Contains(t.base))
|
||||
continue;
|
||||
if (t.hasDefault && t.defaultLabel != 0 && !f.Contains(t.defaultLabel))
|
||||
{
|
||||
snprintf(buf, sizeof(buf), "%08X: default %08X outside %08X+%X", t.base, t.defaultLabel, f.address, f.size);
|
||||
out.push_back(buf);
|
||||
}
|
||||
for (uint32_t label : t.labels)
|
||||
if (!f.Contains(label))
|
||||
{
|
||||
snprintf(buf, sizeof(buf), "%08X: label %08X outside %08X+%X", t.base, label, f.address, f.size);
|
||||
out.push_back(buf);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The table XenonRecomp uses at a bctr: the last [[switch]] keyed at or
|
||||
// before it within the dispatch (XenonAnalyse keys the lis 20 bytes before
|
||||
// the bctr, find_missing_tables.py the rlwinm 12 bytes before).
|
||||
static const Table* TableAt(const std::vector<Table>& tables, uint32_t bctr)
|
||||
{
|
||||
const Table* best = nullptr;
|
||||
for (const Table& t : tables)
|
||||
if (t.base <= bctr && bctr - t.base <= 0x20 && (!best || t.base > best->base))
|
||||
best = &t;
|
||||
return best;
|
||||
}
|
||||
|
||||
static const Function* FunctionAt(const std::vector<Function>& functions, uint32_t a)
|
||||
{
|
||||
for (const Function& f : functions)
|
||||
if (f.Contains(a))
|
||||
return &f;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The guest's dispatch: index register, table entries, last instruction.
|
||||
struct Dispatch
|
||||
{
|
||||
const char* what;
|
||||
uint32_t function;
|
||||
uint32_t bctr;
|
||||
uint32_t index;
|
||||
std::vector<uint32_t> labels;
|
||||
uint32_t end; // one past the last case's blr
|
||||
};
|
||||
|
||||
static const Dispatch kDispatches[] = {
|
||||
// Performance Tuning: the row's label string hash by slider (r4, 0-6:
|
||||
// Steering ... Nitrous, then Turbo or Supercharger by r3).
|
||||
{ "tuning row label", 0x822E7CE0, 0x822E7CF4, 4,
|
||||
{ 0x822E7D14, 0x822E7D20, 0x822E7D2C, 0x822E7D38, 0x822E7D44, 0x822E7D50, 0x822E7D5C }, 0x822E7D80 },
|
||||
// A count and its value from one of ten fields of the record in r3
|
||||
// (x2000, x450, x250, ...), stored to *r4 and *r5.
|
||||
{ "count and value by r6", 0x822C7720, 0x822C7734, 6,
|
||||
{ 0x822C7760, 0x822C7774, 0x822C7788, 0x822C779C, 0x822C77B0,
|
||||
0x822C77C4, 0x822C77D8, 0x822C77EC, 0x822C7800, 0x822C7814 }, 0x822C7828 },
|
||||
// 0-6 -> 0-6 (the identity, written as a table).
|
||||
{ "enum by r3", 0x822C89F8, 0x822C8A0C, 3,
|
||||
{ 0x822C8A2C, 0x822C8A34, 0x822C8A5C, 0x822C8A3C, 0x822C8A44, 0x822C8A4C, 0x822C8A54 }, 0x822C8A64 },
|
||||
};
|
||||
|
||||
// v0.1.2's entries for those dispatches (XenonAnalyse's output) and their
|
||||
// function bounds.
|
||||
static const char kReleased[] = R"(
|
||||
[[switch]]
|
||||
base = 0x822C7720
|
||||
r = 11
|
||||
default = 0x822C76E4
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C89F8
|
||||
r = 10
|
||||
default = 0x822C89D0
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822E7CE0
|
||||
r = 3
|
||||
default = 0x822E7BC4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
]
|
||||
)";
|
||||
static const char kReleasedFunctions[] = R"(
|
||||
{ address = 0x822C7720, size = 0x68 },
|
||||
{ address = 0x822C89F8, size = 0x44 },
|
||||
{ address = 0x822E7CE0, size = 0x40 },
|
||||
)";
|
||||
|
||||
static bool Matches(const Table* t, const Function* f, const Dispatch& d)
|
||||
{
|
||||
return t && f && t->r == d.index && t->labels == d.labels && f->address == d.function && f->address + f->size >= d.end;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
if (argc < 3)
|
||||
{
|
||||
fprintf(stderr, "usage: switch_tables_test <config/switch_tables.toml> <config/nfsmw.toml>\n");
|
||||
return 2;
|
||||
}
|
||||
std::vector<Table> tables = ParseTables(ReadFile(argv[1]));
|
||||
std::vector<Function> functions = ParseFunctions(ReadFile(argv[2]));
|
||||
printf("switch_tables_test: %zu tables, %zu bounded leaf functions\n", tables.size(), functions.size());
|
||||
CHECK(tables.size() > 500, "%zu tables: the file wasn't read", tables.size());
|
||||
CHECK(functions.size() > 100, "%zu functions: the file wasn't read", functions.size());
|
||||
for (const Table& t : tables)
|
||||
CHECK(t.base != 0 && t.r < 32 && !t.labels.empty(), "an entry at %08X didn't parse (r %u, %zu labels)", t.base, t.r, t.labels.size());
|
||||
|
||||
for (const std::string& e : Escapes(tables, functions))
|
||||
CHECK(false, "%s", e.c_str());
|
||||
|
||||
for (const Dispatch& d : kDispatches)
|
||||
{
|
||||
const Table* t = TableAt(tables, d.bctr);
|
||||
const Function* f = FunctionAt(functions, d.bctr);
|
||||
CHECK(t != nullptr, "%s: no table for the bctr at %08X", d.what, d.bctr);
|
||||
CHECK(f != nullptr, "%s: no bounded function holds %08X", d.what, d.bctr);
|
||||
if (!t || !f)
|
||||
continue;
|
||||
CHECK(t->r == d.index, "%s: switches on r%u, the guest indexes with r%u", d.what, t->r, d.index);
|
||||
CHECK(t->labels == d.labels, "%s: %zu labels, the guest's table has %zu", d.what, t->labels.size(), d.labels.size());
|
||||
CHECK(!t->hasDefault, "%s: a default label %08X, the guest has no bounds check", d.what, t->defaultLabel);
|
||||
CHECK(f->address == d.function && f->address + f->size >= d.end,
|
||||
"%s: function %08X+%X, the cases run %08X..%08X", d.what, f->address, f->size, d.function, d.end);
|
||||
}
|
||||
|
||||
// Planted: the released entries must fail both checks.
|
||||
std::vector<Table> released = ParseTables(kReleased);
|
||||
std::vector<Function> releasedFunctions = ParseFunctions(kReleasedFunctions);
|
||||
std::vector<std::string> escapes = Escapes(released, releasedFunctions);
|
||||
CHECK(escapes.size() == 3, "the v0.1.2 entries escape their functions %zu times, expected 3 (one default each)", escapes.size());
|
||||
for (const Dispatch& d : kDispatches)
|
||||
CHECK(!Matches(TableAt(released, d.bctr), FunctionAt(releasedFunctions, d.bctr), d),
|
||||
"%s: the v0.1.2 entry passes as the guest's dispatch", d.what);
|
||||
|
||||
if (g_failures)
|
||||
{
|
||||
printf("switch_tables_test: %d FAILED\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("switch_tables_test: all passed\n");
|
||||
return 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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -33,16 +34,133 @@ namespace
|
||||
return std::abs(measuredMs / vblankMs - double(vblanks)) > 0.5 ? 0 : vblanks;
|
||||
}
|
||||
|
||||
// The rule from 2026-10-07 to 2026-10-11: each frame its own count,
|
||||
// game time level with the vblanks after every frame (a frame that saw
|
||||
// none kept its measured time and was given back by the next ones).
|
||||
struct CountRule
|
||||
{
|
||||
double ahead = 0.0;
|
||||
uint64_t Give(double measuredMs, uint64_t vblanks, double vblankMs)
|
||||
{
|
||||
if (vblanks > 6)
|
||||
{
|
||||
ahead = 0.0;
|
||||
return 0;
|
||||
}
|
||||
if (vblanks == 0)
|
||||
{
|
||||
ahead = std::min(ahead + std::max(measuredMs, 0.0) / vblankMs, 6.0);
|
||||
return 0;
|
||||
}
|
||||
double owed = double(vblanks) - ahead;
|
||||
int64_t give = std::clamp<int64_t>(std::llround(owed), 1, 6);
|
||||
ahead = double(give) - owed;
|
||||
return uint64_t(give);
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
// probability `hiccupP` 15 ms or more longer (streaming, a page fault):
|
||||
// up to `hiccupMs`. The loop starts a frame when the last one is done
|
||||
// and the queue has room (a vblank took a frame from it). Returns the
|
||||
// frames' measured times and counts, and how many vblanks found the
|
||||
// queue empty (the display showed a frame again).
|
||||
std::vector<Frame> QueuedLoop(int queue, double hiccupP, double hiccupMs, int count, uint32_t seed, int& dry)
|
||||
{
|
||||
std::mt19937 rng(seed);
|
||||
std::uniform_real_distribution<double> unit(0.0, 1.0);
|
||||
std::vector<double> starts, done;
|
||||
std::vector<int> shownAt; // the vblank each frame was first shown at
|
||||
double t = 0.0;
|
||||
int nextShown = 0; // frames shown so far
|
||||
int vblank = 1; // the next vblank to fire, at vblank * kRefreshMs
|
||||
dry = 0;
|
||||
auto fireUntil = [&](double until) {
|
||||
for (; double(vblank) * kRefreshMs <= until; vblank++)
|
||||
{
|
||||
if (nextShown < int(done.size()) && done[size_t(nextShown)] <= double(vblank) * kRefreshMs)
|
||||
shownAt.push_back(vblank), nextShown++;
|
||||
else if (nextShown > 0)
|
||||
dry++;
|
||||
}
|
||||
};
|
||||
for (int k = 0; k <= count; k++)
|
||||
{
|
||||
// Room in the queue: frames done but not shown fewer than `queue`.
|
||||
while (int(done.size()) - nextShown >= queue)
|
||||
{
|
||||
double next = double(vblank) * kRefreshMs;
|
||||
fireUntil(next);
|
||||
t = std::max(t, next);
|
||||
}
|
||||
fireUntil(t);
|
||||
starts.push_back(t);
|
||||
double work = 4.0 + 6.0 * unit(rng);
|
||||
if (unit(rng) < hiccupP)
|
||||
work += 15.0 + (hiccupMs - 15.0) * unit(rng);
|
||||
t += work;
|
||||
done.push_back(t);
|
||||
fireUntil(t);
|
||||
}
|
||||
std::vector<Frame> out;
|
||||
for (int k = 0; k < count; k++)
|
||||
{
|
||||
double a = starts[size_t(k)], b = starts[size_t(k) + 1];
|
||||
uint64_t n = uint64_t(std::floor(b / kRefreshMs) - std::floor(a / kRefreshMs));
|
||||
out.push_back({ b - a, n });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -50,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));
|
||||
@@ -106,32 +230,102 @@ namespace
|
||||
return out;
|
||||
}
|
||||
|
||||
// Issue #9: the queued loop at 60 fps held. With no vblank finding the
|
||||
// queue empty, every frame is shown for one vblank and must get one.
|
||||
void QueuedLoops()
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
const char* name;
|
||||
int queue;
|
||||
double hiccupP, hiccupMs;
|
||||
};
|
||||
const Case cases[] = {
|
||||
{ "queue 2, a 15-30 ms hiccup every ~3 s", 2, 1.0 / 180, 30.0 },
|
||||
{ "queue 3, a 15-45 ms hiccup every ~3 s", 3, 1.0 / 180, 45.0 },
|
||||
{ "queue 3, a 15-45 ms hiccup every ~0.5 s (a Deck-like CPU)", 3, 1.0 / 30, 45.0 },
|
||||
{ "queue 2, a 15-80 ms hiccup every ~1 s (some run the queue dry)", 2, 1.0 / 60, 80.0 },
|
||||
};
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
uint64_t frames = 0, newOff = 0, oldOff = 0, dryTotal = 0;
|
||||
double worst = 0;
|
||||
for (int p = 0; p < 8; p++)
|
||||
{
|
||||
int dry = 0;
|
||||
std::vector<Frame> loop = QueuedLoop(c.queue, c.hiccupP, c.hiccupMs, 6000, 77u + uint32_t(p), dry);
|
||||
game::WholeVblanks rule;
|
||||
CountRule old;
|
||||
double given = 0, fired = 0;
|
||||
for (const Frame& f : loop)
|
||||
{
|
||||
uint64_t k = rule.Give(f.ms, f.vblanks, kRefreshMs), o = old.Give(f.ms, f.vblanks, kRefreshMs);
|
||||
newOff += k != 1;
|
||||
oldOff += o != 1;
|
||||
given += k ? double(k) : f.ms / kRefreshMs;
|
||||
fired += double(f.vblanks);
|
||||
}
|
||||
frames += loop.size();
|
||||
dryTotal += uint64_t(dry);
|
||||
worst = std::max(worst, std::abs(given - fired));
|
||||
}
|
||||
printf(" %-62s frames not given 1: %llu (the rule until 2026-10-11: %llu) of %llu; vblanks with the queue empty %llu; "
|
||||
"game time within %.2f vblanks\n", c.name, (unsigned long long)newOff, (unsigned long long)oldOff,
|
||||
(unsigned long long)frames, (unsigned long long)dryTotal, worst);
|
||||
CHECK(oldOff > 0, "%s: the old rule's hitches are there to remove (%llu)", c.name, (unsigned long long)oldOff);
|
||||
if (dryTotal == 0)
|
||||
CHECK(newOff == 0, "%s: issue #9: every frame given 1 while the display shows one a vblank (%llu not)", c.name,
|
||||
(unsigned long long)newOff);
|
||||
else
|
||||
CHECK(newOff <= dryTotal && newOff < oldOff, "%s: only frames after an empty queue given more (%llu, %llu empty, old %llu)",
|
||||
c.name, (unsigned long long)newOff, (unsigned long long)dryTotal, (unsigned long long)oldOff);
|
||||
CHECK(worst <= game::WholeVblanks::kSlack + 1.0, "%s: game time within kSlack + 1 vblanks (%.2f)", c.name, worst);
|
||||
}
|
||||
}
|
||||
|
||||
void Synthetic()
|
||||
{
|
||||
// Frame by frame, from a fresh start.
|
||||
{
|
||||
game::WholeVblanks r;
|
||||
CHECK(r.Give(16.6, 1, kRefreshMs) == 1, "a 60 fps frame: 1");
|
||||
CHECK(r.Give(44.0, 3, kRefreshMs) == 3, "44 ms, 3 fired (2.64 rounded up): 3");
|
||||
CHECK(r.Give(44.0, 2, kRefreshMs) == 2, "44 ms, 2 fired (2.64 rounded down): 2; the half-vblank rule kept 44 ms");
|
||||
CHECK(HalfVblankRule(44.0, 2, kRefreshMs) == 0, "the old rule's miss");
|
||||
CHECK(r.ahead == 0.0, "nothing ahead");
|
||||
CHECK(HalfVblankRule(44.0, 2, kRefreshMs) == 0, "the old rule's miss: 44 ms, 2 fired, kept 44 ms");
|
||||
// GPU-bound 44 ms frames (2.64 refreshes, 2 or 3 fired): game time
|
||||
// falls kSlack behind first, then each gets its count.
|
||||
CHECK(r.Give(44.0, 3, kRefreshMs) == 1 && r.ahead == -2.0, "first slow frame: 1 (%.2f behind)", -r.ahead);
|
||||
CHECK(r.Give(44.0, 2, kRefreshMs) == 1 && r.ahead == -3.0, "then 1, kSlack behind (%.2f)", -r.ahead);
|
||||
CHECK(r.Give(44.0, 3, kRefreshMs) == 3, "3 fired, kSlack behind: 3");
|
||||
CHECK(r.Give(44.0, 2, kRefreshMs) == 2, "2 fired: 2");
|
||||
CHECK(r.ahead == -game::WholeVblanks::kSlack, "and stays kSlack behind (%.2f)", r.ahead);
|
||||
CHECK(r.Give(44.0, 1, 2 * kRefreshMs) == 1 && r.Give(44.0, 2, 2 * kRefreshMs) == 2,
|
||||
"Frame Rate 30: 44 ms is 1.32 guest vblanks of 33.3 ms; 1 or 2 fired, given");
|
||||
// A late vblank: an 18.7 ms frame sees none (its measured time),
|
||||
// the next sees 2 and is given 1; then 1 each.
|
||||
CHECK(r.Give(18.7, 0, kRefreshMs) == 0, "none fired: measured time");
|
||||
CHECK(std::abs(r.ahead - 18.7 / kRefreshMs) < 1e-9, "ahead by the measured time (%.3f)", r.ahead);
|
||||
CHECK(r.Give(18.7, 2, kRefreshMs) == 1, "the catch-up gives back the vblank");
|
||||
CHECK(r.Give(18.7, 1, kRefreshMs) == 1, "then one each");
|
||||
CHECK(std::abs(r.ahead - (18.7 / kRefreshMs - 1.0)) < 1e-9, "the fraction stays (%.3f)", r.ahead);
|
||||
// Ahead by more than half a vblank: a frame of 1 still gets 1.
|
||||
CHECK(r.Give(16.0, 0, kRefreshMs) == 0 && r.ahead > 1.0, "ahead by more than a vblank");
|
||||
CHECK(r.Give(16.6, 1, kRefreshMs) == 1, "never fewer than one");
|
||||
r.Reset();
|
||||
CHECK(r.ahead == 0.0, "reset");
|
||||
// GitHub issue #9: at 60 fps held, the main loop's frames drift
|
||||
// against the vblanks while the display shows one frame a vblank
|
||||
// from its queue: a slow frame sees 2 or 3, the next ones none.
|
||||
// Each is shown for one vblank and gets one.
|
||||
const uint64_t drift[] = { 1, 2, 0, 1, 1, 3, 0, 0, 1, 2, 1, 0, 1 };
|
||||
bool allOne = true;
|
||||
for (uint64_t k : drift)
|
||||
allOne &= r.Give(k == 0 ? 4.0 : 16.6 * double(k), k, kRefreshMs) == 1;
|
||||
CHECK(allOne && r.ahead == 0.0, "issue #9: every drifting frame given 1, nothing left over (%.2f)", r.ahead);
|
||||
// A slow frame of 6 (100 ms: the queue ran dry, the display waited
|
||||
// 3 vblanks): it gets the 3 beyond kSlack, the 3 catching up one each.
|
||||
CHECK(r.Give(100.0, 6, kRefreshMs) == 3, "6 fired, 3 beyond kSlack: 3");
|
||||
CHECK(r.Give(3.0, 0, kRefreshMs) == 1 && r.Give(3.0, 0, kRefreshMs) == 1 && r.Give(3.0, 0, kRefreshMs) == 1 && r.ahead == 0.0,
|
||||
"the frames catching up: 1 each, back level (%.2f)", r.ahead);
|
||||
// More frames than vblanks (the queue filling): one each up to
|
||||
// kSlack ahead, then their measured time.
|
||||
CHECK(r.Give(4.0, 0, kRefreshMs) == 1 && r.Give(4.0, 0, kRefreshMs) == 1 && r.Give(4.0, 0, kRefreshMs) == 1, "kSlack frames ahead: 1 each");
|
||||
CHECK(r.Give(4.0, 0, kRefreshMs) == 0 && r.ahead == game::WholeVblanks::kSlack, "then measured time, never more than kSlack ahead");
|
||||
CHECK(r.Give(16.6, 1, kRefreshMs) == 1 && r.ahead == game::WholeVblanks::kSlack, "kSlack ahead, a frame of 1: 1 (it fits)");
|
||||
CHECK(r.Give(33.3, 2, kRefreshMs) == 1 && r.ahead == game::WholeVblanks::kSlack - 1.0, "a frame of 2: 1, giving one back");
|
||||
CHECK(r.Give(120.0, 7, kRefreshMs) == 0 && r.ahead == 0.0, "7 vblanks (loading): measured time, and a fresh start");
|
||||
for (int i = 0; i < 20; i++)
|
||||
r.Give(30.0, 0, kRefreshMs);
|
||||
CHECK(r.ahead == game::WholeVblanks::kMaxAhead, "ahead at most %.0f vblanks", game::WholeVblanks::kMaxAhead);
|
||||
CHECK(r.ahead == game::WholeVblanks::kSlack, "ahead at most kSlack (%.2f)", r.ahead);
|
||||
r.Reset();
|
||||
CHECK(r.ahead == 0.0 && r.Give(16.6, 1, 0.0) == 0, "no vblank period: measured time");
|
||||
}
|
||||
@@ -174,9 +368,9 @@ namespace
|
||||
printf(" %-48s new rule within %.2f vblanks of the vblanks' time; half-vblank rule x%.4f; %llu of %llu frames given whole vblanks\n",
|
||||
c.name, worst, oldMean, (unsigned long long)given, (unsigned long long)frames);
|
||||
// Game time against the vblanks that fired, over 4000 frames:
|
||||
// within what can be ahead at the end (half a vblank, and a last
|
||||
// frame that saw none), whatever the frames did.
|
||||
CHECK(worst <= 2.0, "%s: the new rule within 2 vblanks of the vblanks (%.2f)", c.name, worst);
|
||||
// within kSlack (and a last frame that saw none), whatever the
|
||||
// frames did.
|
||||
CHECK(worst <= game::WholeVblanks::kSlack + 1.0, "%s: the new rule within kSlack + 1 vblanks of the vblanks (%.2f)", c.name, worst);
|
||||
CHECK(oldMean >= c.oldMin && oldMean <= c.oldMax, "%s: the half-vblank rule's known bias (x%.4f)", c.name, oldMean);
|
||||
if (c.paced)
|
||||
CHECK(pacedOne == paced && given == frames, "%s: every paced frame given one vblank (%llu of %llu)", c.name,
|
||||
@@ -185,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)
|
||||
@@ -210,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +603,10 @@ int main(int argc, char** argv)
|
||||
{
|
||||
printf("synthetic frames:\n");
|
||||
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++)
|
||||
|
||||