Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2cb93f2d | ||
|
|
ad14799d14 | ||
|
|
d4c206ab98 | ||
|
|
b49fc321d1 | ||
|
|
34e1f75901 | ||
|
|
e128baca5e | ||
|
|
a4a72ce103 |
No files matched your search
@@ -4,6 +4,253 @@ What changed in each SpeedBreaker release, newest first. Each release's notes on
|
||||
its entry here, and the game shows the entry once, the first time a new version starts (offline:
|
||||
it's built into the game).
|
||||
|
||||
## v0.1.6
|
||||
|
||||
### What's new
|
||||
|
||||
- No more lost sound followed by a freeze. If your audio output stopped taking sound for a while,
|
||||
the game's sound stopped for good, and the game later froze, for example on the save screen
|
||||
after an event. Every version so far had this. Now the game keeps running, unheard, and the
|
||||
sound comes back as soon as the output takes audio again. Thanks to Omega3komma3 for the report
|
||||
and the two bug reports (GitHub issue #20).
|
||||
- No more rectangular borders in the Showcase (where you rotate and zoom around a car, in the car
|
||||
lot, the customize shop and on a Blacklist rival's car) or around a Blacklist rival's bio. The
|
||||
Showcase darkens the edges of the garage, and that darkening stopped short of the edges of the
|
||||
screen; the bio's black panel did the same. On a 16:9 screen that left a thin border, as on an
|
||||
Xbox 360, whose TV hid it past the edge of its screen; on a taller screen such as a MacBook or a
|
||||
Steam Deck the Showcase's border was wide at the top and bottom. Both now reach the edges of the
|
||||
screen, on every screen shape. Thanks to DoubleDips, who reported it, and m9cy884mv6-a11y, who
|
||||
confirmed it on the Steam Deck (GitHub issue #19).
|
||||
- Mac: in a window, the picture now runs up under a transparent title bar, and the window's
|
||||
buttons show only while you move the mouse over it. Thanks to aleszczynskig, who wrote it
|
||||
(GitHub pull request #23).
|
||||
- Mac: on a MacBook Pro's 120 Hz ProMotion screen, switching fullscreen on or off, or resizing the
|
||||
window, could close the game now and then (since v0.1.4; its crash report named "mutex lock
|
||||
failed"). It no longer does.
|
||||
- A quit asked for from outside the game is now a normal quit: the stop signal a launcher sends,
|
||||
Ctrl+C, or closing the terminal the game was started from. Your settings are saved, as with
|
||||
Quit Game. Closing its terminal used to end it on the spot, and every session, even one ended
|
||||
with Quit Game, left a crash marker in its logs folder until the next start.
|
||||
|
||||
### More about it
|
||||
|
||||
- The sound: the log now says when the audio output stopped taking audio and when it came back,
|
||||
and a hang report includes the audio's state.
|
||||
- The borders: the few black frames between two shots of the career's opening scene are now
|
||||
black to the edges of the screen too.
|
||||
- The title bar: the window's saved size now takes in the title bar, so a window from an earlier
|
||||
version opens a title bar shorter, with the same picture.
|
||||
- Quitting: a crash still saves a crash report, and the next start still offers it. A session
|
||||
that ended without quitting (stopped by force, or the power going) now leaves its marker, and
|
||||
the next start's log says so.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.5
|
||||
|
||||
### What's new
|
||||
|
||||
- Settings > Controls: every keyboard control can be reassigned, and each control has two keys.
|
||||
Choose a row, Left or Right for its first or second key, press A or Enter, then the new key; Y
|
||||
clears a key, X puts a row's defaults back, Reset page the whole table. The default keys are
|
||||
unchanged (the arrows steer as well as A and D), and the left stick click gets a key of its own,
|
||||
Z. Thanks to love-gamer, who wrote it (GitHub pull request #12).
|
||||
- Settings > Graphics > Upscaling has a third choice, FSR 1 (AMD FidelityFX Super Resolution 1),
|
||||
for a window much larger than the Internal Resolution (1x on a 1440p, MacBook or 4K screen):
|
||||
curves, wires and diagonals stay smooth where bicubic shows steps, such as lamp posts against
|
||||
the sky or the speedometer's needle. It is used only when the picture is enlarged. Bicubic stays
|
||||
the default. Not on iPhone and iPad.
|
||||
- The race results screen no longer shows a thin strip of the race above and below it. An Xbox 360
|
||||
draws that strip too, and a TV of the time hid it past the edge of its screen; today's screens
|
||||
show the whole picture, so it is now black, on every screen shape (Steam Deck, 16:9 and others).
|
||||
|
||||
### More about it
|
||||
|
||||
- The Controls page: the keys are saved by name in settings.toml's `[keys]` table (`w`,
|
||||
`left_shift`, `none`). F1, Esc (it cancels a wait) and the Cmd and Windows keys can't be
|
||||
assigned. If you go back to v0.1.4 and change a setting there, it forgets the Controls page's
|
||||
keys.
|
||||
- FSR 1: Sharpening sets its sharpening pass too (0.3 of its full strength by default). Bicubic
|
||||
stays the default because FSR 1 makes foliage look a little painted, costs more GPU time (about
|
||||
1 ms a frame on an M1 Pro at 1080p, 0.1 ms on a Steam Machine), and with Internal Resolution on
|
||||
Auto most screens aren't enlarged at all.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.4
|
||||
|
||||
### What's new
|
||||
|
||||
- The picture now matches a real Xbox 360 on a TV: slightly deeper shadows and slightly brighter
|
||||
mid-tones. The game sets a gamma ramp for the console's TV output, and SpeedBreaker never
|
||||
applied it until now. The new Settings > Display > Xbox 360 Display Gamma is on by default, also
|
||||
after an update: to get the previous look back, turn it off.
|
||||
- Settings > Display > Brightness: lighter or darker shadows and mid-tones, from -5 to +5, a
|
||||
gentle step at a time, like a TV's brightness. Black and white stay put, and only the game's
|
||||
picture changes, never the menus. At 0, the default, it leaves the picture as it is.
|
||||
- The game's own Brightness slider (pause menu > Options > Video) now changes the picture as you
|
||||
move it, as on an Xbox 360: the game writes a new gamma ramp for each step, and SpeedBreaker
|
||||
applies it. It needs Settings > Display > Xbox 360 Display Gamma, which is on by default; with
|
||||
that off it does nothing, as before. If you moved the slider in an earlier version and the game
|
||||
has saved since, that brightness now takes effect: a slider left at maximum gives a much
|
||||
brighter, washed-out picture, as a 360 with that save would show. Set the slider back to the
|
||||
middle, or press Y (Defaults) and choose Yes. Thanks to m9cy884mv6-a11y for the report (GitHub
|
||||
issue #18).
|
||||
- Keyboard: the right stick has keys, O and L for up and down, K and ; for left and right. The
|
||||
game shifts gears with the right stick, so you can now shift from the keyboard and finish a
|
||||
drag race. Thanks to love-gamer, who wrote it (GitHub pull request #11).
|
||||
- Settings > Quit Game, at the bottom of the menu after Close: quits SpeedBreaker with a controller,
|
||||
the mouse or the keyboard, so a fullscreen game no longer needs a keyboard shortcut to leave. It
|
||||
asks first, with Cancel chosen, so no single press quits. Not on iPhone and iPad, where apps are
|
||||
closed from the app switcher.
|
||||
- Settings > Display > Fullscreen now decides how the game starts, every time. On a Mac, the
|
||||
window's green button (or Ctrl+Cmd+F) switches fullscreen until you quit, so the game no longer
|
||||
sometimes starts fullscreen and sometimes not.
|
||||
- Mac: the mouse pointer no longer sits over the game. It hides after two seconds without moving
|
||||
while you play, and comes back as soon as you move the mouse; in the settings menu and the
|
||||
installer it stays. Thanks to DoubleDips for the report (GitHub issue #15).
|
||||
- Mac: smoother frame pacing. The game's 60 Hz clock now keeps time on macOS (it ran up to 9 ms
|
||||
late), and on a MacBook Pro's 120 Hz ProMotion screen each frame now follows the last by 1/60 s
|
||||
instead of arriving whenever the GPU finished it. Other screens show frames as before, and the
|
||||
timing turns itself off if a screen doesn't keep it.
|
||||
- On slow GPUs, below 15 fps, the game no longer runs a few percent slow: its clock now keeps up
|
||||
with the screen (a laptop with Intel graphics at Internal Resolution 1.5x, about 12 fps, ran 4%
|
||||
slow).
|
||||
- Changing the window's shape while a movie plays (for example the green button during the
|
||||
opening movie) no longer shows a stray "EVENT HANDLER" label above the picture.
|
||||
|
||||
### More about it
|
||||
|
||||
- The gamma ramp: the game sets it when it starts, and the console applies it to every frame on
|
||||
its way to the TV. Two real Xbox 360 recordings of the intro movie and the title screen match it
|
||||
to within half a level, against about two levels without it. With Xbox 360 Display Gamma off
|
||||
and Brightness at 0, the picture is exactly the one of earlier versions.
|
||||
- The game's slider: each step writes a new ramp, which shows on that frame. At its lowest, its
|
||||
default and its highest, the picture's average brightness moves by about -13, +1 and +24 levels
|
||||
(of 255) against Xbox 360 Display Gamma off. The game keeps the slider in your alias's save the
|
||||
next time it saves the profile; without an alias it starts at the middle every time.
|
||||
- The right-stick keys: holding both keys of a pair centres the stick. No other key changed.
|
||||
- Fullscreen: a switch the game didn't ask for (on a Mac, the green button or Ctrl+Cmd+F) lasts
|
||||
until you quit and never changes the setting. While the window differs from the setting,
|
||||
Settings > Display > Fullscreen says NOW FULLSCREEN or NOW WINDOWED; choosing On or Off there
|
||||
saves it and switches the window.
|
||||
- The pointer hides only over the game's window, so in a window it still shows over the desktop,
|
||||
and the mouse is never captured.
|
||||
- The pacing: macOS let the thread that drives the game's 60 Hz clock wake up to 9 ms late; it
|
||||
now runs with real-time priority. And a ProMotion screen shows each frame as soon as it's ready,
|
||||
so frames that took a little longer to draw arrived unevenly: SpeedBreaker now asks for each
|
||||
frame to be shown at a set time after its tick. Measured on a MacBook Pro (M1 Pro) in
|
||||
fullscreen: 99.9% of frames 1/60 s apart in menus and about 94% while driving (99-100% on the
|
||||
open road, less in the heaviest scenes, where a frame takes longer than the time allowed),
|
||||
against about a quarter without it, for about 5 ms more latency.
|
||||
- The slow clock: when a frame took longer than six screen refreshes, SpeedBreaker started its
|
||||
count over and dropped up to three refreshes of game time each time; below 15 fps that happened
|
||||
every second or so. That time is now given back over the next frames. Above 20 fps nothing
|
||||
changes. Checked by replaying frame timings recorded on that laptop: game time went from 96% to
|
||||
99.9% of real time.
|
||||
- The label: it belongs to a menu the game keeps just above the picture. After a window change
|
||||
mid-movie the game kept the old window's layout for it, so the label slipped into view; it now
|
||||
gets the new layout at once.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.3
|
||||
|
||||
### What's new
|
||||
|
||||
- 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
|
||||
|
||||
- Internal Resolution 1.5x (1080p), in Settings > Graphics: a much sharper picture than 1x, with
|
||||
a little over half the pixels of 2x: for a GPU with room above 1x but not for 2x. Thanks to the
|
||||
Reddit player who asked for it.
|
||||
- Laptops with two GPUs: the game now runs on the dedicated graphics card (NVIDIA or AMD), not on
|
||||
the one built into the processor. On Linux with NVIDIA's driver, the launcher also turns on
|
||||
NVIDIA's PRIME render offload: thanks to lydacious for the idea and the first version (GitHub
|
||||
pull request #6).
|
||||
- Portable mode on Steam Deck, Steam Machine and Linux: put a file named `portable.txt` next to
|
||||
`speedbreaker.sh`, and SpeedBreaker keeps your settings, saves, logs and caches in that folder,
|
||||
and installs the game there too. Move or copy the whole folder to another machine and play on
|
||||
(GitHub issue #1).
|
||||
- Taller and wider screens: the sun's glare is round again (it was stretched up to 1.33 times
|
||||
taller on the Steam Deck, 4:3 and 3:2, and wider on ultrawides); no faint line of a letterboxed
|
||||
movie shows in the black past the 16:9 picture; and the race results screen is black past its
|
||||
16:9 picture, as on a 16:9 TV, instead of showing the road behind it.
|
||||
- iPhone: after a screenshot, the picture no longer shrinks into a corner of the screen with the
|
||||
settings menu blown up.
|
||||
- Experimental: an IPA for iPhone and iPad, to sideload with your own Apple Account.
|
||||
|
||||
### More about it
|
||||
|
||||
- 1.5x renders 2.25 times the pixels of 1x, where 2x renders 4 times. Auto still chooses between
|
||||
whole scales (1x, 2x or 3x) as before.
|
||||
- The game picks a dedicated (discrete) GPU over a built-in (integrated) one, and of two alike the
|
||||
one with more video memory. The log's `[video] Vulkan devices:` line lists every GPU it found,
|
||||
the one it uses, and why. To use another, put `NFSMW_GPU=<its number or part of its name>` in
|
||||
the launch options, e.g. `NFSMW_GPU=intel %command%`. On the Steam Deck, Steam Machine, Steam
|
||||
Frame, a Mac, an iPhone or an iPad (one GPU each) nothing changes. The launcher sets
|
||||
`__NV_PRIME_RENDER_OFFLOAD=1` only on a PC where NVIDIA's driver runs one GPU beside another and
|
||||
NVIDIA's Vulkan driver is installed, and keeps a value you set yourself.
|
||||
- `speedbreaker.sh --portable on` copies what you have now (settings, saves and the installed game)
|
||||
into the folder and turns portable mode on; `--portable off` copies them back. Both copy and
|
||||
never delete: your old files stay until you remove them. Not on the Mac, where an app can't keep
|
||||
its data inside itself.
|
||||
- The IPA: the game asks iOS for two memory entitlements, and a free Apple Account can give at most
|
||||
one of them, with some tools only: with a free account it may not start, or iOS may close it in
|
||||
busy scenes. scripts/ios/README.md says which tools keep what. When iOS refuses the memory, the
|
||||
game now says why on screen instead of closing.
|
||||
- iPhone and iPad: on iOS 27 the screenshot screen could leave the game laid out as if the phone
|
||||
were held upright, until the game was closed. The game now checks its picture against the
|
||||
screen after coming back from the screenshot screen, the app switcher or Control Center, and
|
||||
puts it right if they disagree; an iPhone, which plays only in landscape, never draws upright.
|
||||
- iPhone and iPad: Internal Resolution's Auto now decides chip by chip, and the log says what it
|
||||
chose and why. Every chip still gets 1x (720p) under Auto, the iPhone 18 Pro Max included: in a
|
||||
long race on a charger it got hot even at 1x. 1.5x and 2x are in Settings > Graphics > Internal
|
||||
Resolution. The log also records the battery level and charging state.
|
||||
- After a macOS, SteamOS or graphics driver update, the first drive can stutter for a moment while
|
||||
the game rebuilds its shaders for the new system. It happens once, and the game's log now says
|
||||
so.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.1
|
||||
|
||||
### What's new
|
||||
@@ -30,7 +277,7 @@ it's built into the game).
|
||||
already holds files gets a new SpeedBreaker Game folder inside. The game remembers the folder,
|
||||
and says so if its drive isn't connected. On SteamOS the folder picker opens only in Desktop
|
||||
Mode: in Game Mode, switch to Desktop Mode and choose the folder there, or run
|
||||
speedbreaker.sh --install <image> --dest <folder> from Konsole in Desktop Mode (or over SSH).
|
||||
`speedbreaker.sh --install <image> --dest <folder>` from Konsole in Desktop Mode (or over SSH).
|
||||
- Check for Updates asks GitHub whether a newer SpeedBreaker is out. If one is, it shows what
|
||||
changed, opens the download page, and shows that page as a QR code to scan with a phone (for
|
||||
Steam Deck Game Mode and the Steam Frame, where no browser may open). Nothing is checked online
|
||||
|
||||
@@ -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.1 LANGUAGES C CXX)
|
||||
project(SpeedBreaker VERSION 0.1.6 LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
AMD FidelityFX Super Resolution 1 (FSR 1): ffx_a.h and ffx_fsr1.h
|
||||
https://github.com/GPUOpen-Effects/FidelityFX-FSR (commit a21ffb8f6c13233ba336352bdff293894c706575, files ffx-fsr/ffx_a.h and ffx-fsr/ffx_fsr1.h)
|
||||
Vendored unmodified in runtime/thirdparty/fsr1; their shader code is compiled into SpeedBreaker.
|
||||
|
||||
Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -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">
|
||||
@@ -60,8 +60,8 @@ and a settings menu you can open mid-race.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Up to 4K.** The 3D scene renders at 1x, 2x or 3x the console's 720p (3x is 3840x2160).
|
||||
*Auto* picks a scale for your screen and device.
|
||||
- **Up to 4K.** The 3D scene renders at 1x, 1.5x, 2x or 3x the console's 720p (1.5x is 1080p,
|
||||
3x is 3840x2160). *Auto* picks a scale for your screen and device.
|
||||
- **Ultrawide done right.** On 21:9 screens the view gets wider instead of stretched, and the
|
||||
HUD and the rear-view mirror stay in place. A 4:3 iPad, a 3:2 screen or the 16:10 screens of
|
||||
the Steam Deck and many Macs show more above and below instead, and fill the screen.
|
||||
@@ -69,15 +69,16 @@ and a settings menu you can open mid-race.
|
||||
speed, or *Auto*, which drops to 30 only while a device is hot or can't hold 60.
|
||||
- **The sun glare is back.** The low sun glows in open sky and fades behind buildings, as it did
|
||||
on the console.
|
||||
- **The original look:** sunlit roads and shadows as on the console, correct colours on Apple
|
||||
devices, proper car paint and mirror reflections, and distant textures that don't shimmer. Anti-aliasing, sharpening and up to 16x
|
||||
anisotropic filtering are also available.
|
||||
- **The original look:** sunlit roads and shadows as on the console, the picture a real Xbox 360
|
||||
sends to a TV (the game's own display gamma), correct colours on Apple devices, proper car paint
|
||||
and mirror reflections, and distant textures that don't shimmer. Anti-aliasing, sharpening,
|
||||
FSR 1 upscaling, a Brightness setting and up to 16x anisotropic filtering are also available.
|
||||
- **A guided installer.** It finds your disc image, checks that it is the right game, and
|
||||
verifies all 49 files as it copies them. An interrupted install never leaves a broken game.
|
||||
- **Your controller, or the keyboard.** Controllers work as an Xbox 360 pad, with hot-plugging
|
||||
and rumble support.
|
||||
and rumble support. Every keyboard control can be reassigned in the settings.
|
||||
- **A settings menu that pauses the game.** Open it with F1, Back + Start, or a three-finger
|
||||
tap. Every change applies at once.
|
||||
tap. Every change applies at once, and *Quit Game* leaves the game from a controller too.
|
||||
- **Suspend and resume on iPhone and iPad.** Switch away and the game freezes; come back and it
|
||||
carries on where it stopped.
|
||||
- **One-button bug reports.** Logs, settings and a screenshot go into one zip file. Nothing is
|
||||
@@ -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 only so far. Other distributions and NVIDIA or Intel GPUs are untested: reports welcome. |
|
||||
| **Mac** (Apple silicon) | [Download][dl-macos]<br>`SpeedBreaker-macos.dmg` | MacBook Pro (M1 Pro) 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** | [Build it yourself](#iphone-and-ipad) with Xcode | iPhone 15 Pro Max (iOS 27). iPad Pro 12.9-inch (M2, iPadOS 27) with a Backbone Pro. Not on the App Store. |
|
||||
| **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. 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
|
||||
@@ -146,7 +149,8 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
|
||||
the extracted disc files. No title update is needed. SpeedBreaker doesn't provide game files
|
||||
or disc images, or link to them.
|
||||
2. **Get SpeedBreaker.** [Download it](#download) for Steam Deck, Steam Machine and Steam Frame
|
||||
(SteamOS), Linux or Mac. On iPhone and iPad you build it yourself. Then follow the
|
||||
(SteamOS), Linux or Mac. On iPhone and iPad you sideload the experimental IPA or build it
|
||||
yourself. Then follow the
|
||||
[setup for your platform](#setup-by-platform).
|
||||
3. **Start it and install.** On first run, SpeedBreaker looks for your disc image, checks that
|
||||
it is the right game and version, then copies and verifies every file. This takes a few
|
||||
@@ -212,6 +216,13 @@ right of the screen).
|
||||
**Uninstall:** delete `~/Games/SpeedBreaker/`, `~/.local/share/speedbreaker/` (this deletes your
|
||||
saves too) and `~/.cache/speedbreaker/`, and remove the shortcut from Steam.
|
||||
|
||||
**Portable mode:** to keep everything in the SpeedBreaker folder (saves, settings, logs, shader
|
||||
caches and the installed game), so the whole folder can be moved or copied to another Steam Deck,
|
||||
Steam Machine or Linux PC, make an empty file named `portable.txt` next to `speedbreaker.sh`. That
|
||||
starts fresh; to bring your current settings, saves and game along, run
|
||||
`~/Games/SpeedBreaker/speedbreaker.sh --portable on` in Konsole instead, and `--portable off` to
|
||||
go back. Both copy and never delete. The folder's README says more.
|
||||
|
||||
### Steam Frame
|
||||
|
||||
The Steam Frame has its own download. You set it up on the Frame's desktop: open the
|
||||
@@ -252,14 +263,21 @@ 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 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
|
||||
|
||||
@@ -282,13 +300,27 @@ one, use `--dest`, below).
|
||||
upload it and can remove its files from the Mac to save space.
|
||||
|
||||
**Settings:** **F1** or **Cmd+,**, or Back + Start on a controller. The game starts in a window;
|
||||
Display > Fullscreen switches to full screen. SpeedBreaker needs macOS 15 (Sequoia) or later.
|
||||
Display > Fullscreen switches to full screen and decides how the game starts from then on. The
|
||||
window's green button (or Ctrl+Cmd+F) switches only until you quit. In a window, the picture
|
||||
runs up under a transparent title bar, and the window's buttons show while you move the mouse
|
||||
over it. While you play, the mouse pointer hides after two seconds without moving. SpeedBreaker needs macOS 15 (Sequoia) or later.
|
||||
|
||||
### iPhone and iPad
|
||||
|
||||
There's no download for iPhone and iPad, and SpeedBreaker isn't on the App Store. You build it
|
||||
yourself with Xcode on a Mac and install it on your own device, signed with your own Apple
|
||||
developer account: [scripts/ios/README.md](scripts/ios/README.md) walks you through it.
|
||||
SpeedBreaker isn't on the App Store. There are two ways to get it onto your iPhone or iPad:
|
||||
|
||||
- **Sideload the IPA (experimental).** Each release has an IPA, `SpeedBreaker-<version>-ios.ipa`,
|
||||
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. 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.
|
||||
|
||||
Once SpeedBreaker is on your device:
|
||||
|
||||
@@ -315,7 +347,7 @@ is B, Square is X and Triangle is Y.
|
||||
|
||||
| Xbox 360 control | Controller | Keyboard |
|
||||
|---|---|---|
|
||||
| Left stick | Left stick | A / D steer (the arrow keys also move it) |
|
||||
| Left stick | Left stick | A / D or Left / Right steer; Up / Down push it up and down |
|
||||
| Right trigger | Right trigger | W |
|
||||
| Left trigger | Left trigger | S |
|
||||
| A / B / X / Y | Bottom / right / left / top face button | Space / Backspace / Left Shift / Left Ctrl |
|
||||
@@ -323,13 +355,20 @@ is B, Square is X and Triangle is Y.
|
||||
| D-pad | D-pad | Arrow keys |
|
||||
| Start | Start / Menu / Options | Enter |
|
||||
| Back | Back / View / Create | Esc |
|
||||
| Right stick, stick clicks | Same on the controller | C (right stick click) |
|
||||
| Right stick | Right stick | O / L up and down, K / ; left and right |
|
||||
| Stick clicks | Press a stick in | Z (left), C (right) |
|
||||
|
||||
- One player, one controller: the first one connected is used, and you can plug one in at any
|
||||
time. The keyboard always works as well.
|
||||
- Because Back + Start opens the settings, the game sees Back a moment late: a tap arrives when
|
||||
you let go, a hold after a quarter of a second.
|
||||
- There is no button remapping and no mouse control in game.
|
||||
- The game shifts gears with the right stick, so from the keyboard you shift with O and L.
|
||||
Holding both keys of a pair centres the stick.
|
||||
- Every keyboard control can be reassigned: **Settings > Controls** (F1). Each control has two
|
||||
keys: choose a row, Left or Right for its first or second key, press A or Enter, then the key
|
||||
you want (Esc or B cancels). Y or Backspace clears a key, X or Delete puts the row's defaults
|
||||
back, and *Reset page* restores this whole table. A key may drive several controls, as the
|
||||
arrows do. There is no mouse control in game.
|
||||
|
||||
**To open the settings:**
|
||||
|
||||
@@ -346,7 +385,9 @@ is B, Square is X and Triangle is Y.
|
||||
The game pauses while the settings are open. The D-pad (or arrow keys) chooses and changes a row,
|
||||
LB and RB (or Q and E) turn pages, and B (or Esc) closes the menu. On a touchscreen, tap and
|
||||
drag. The mouse works too. Every change applies at once and is saved when you close the menu.
|
||||
Each page has a *Reset page* button.
|
||||
The bottom row has *Reset page* (the page's defaults), *Close*, and *Quit Game*, which asks
|
||||
first, with *Cancel* chosen (not on iPhone and iPad, where apps are closed from the app
|
||||
switcher).
|
||||
|
||||
<details>
|
||||
<summary><b>All settings and their defaults</b></summary>
|
||||
@@ -355,22 +396,28 @@ Each page has a *Reset page* button.
|
||||
|
||||
| Option | Choices | Default |
|
||||
|---|---|---|
|
||||
| Fullscreen | On / Off | Off: a 1280x720 window you can resize. Always on in SteamOS Game Mode. Not shown on iPhone and iPad, which are always full screen. |
|
||||
| Fullscreen | On / Off | Off: a 1280x720 window you can resize. The game always starts as set here; on a Mac, the window's green button switches only until you quit, and the row then says NOW FULLSCREEN or NOW WINDOWED. Always on in SteamOS Game Mode. Not shown on iPhone and iPad, which are always full screen. |
|
||||
| V-Sync | On / Off | On. Off can tear, but shows frames sooner. |
|
||||
| Frame Rate | Auto / 60 fps / 30 fps | 60 fps on most devices. 30 fps keeps the game at full speed. Auto runs at 60, or at a steady 30 while the device is hot or can't hold 60. |
|
||||
| Screen Resolution | Full / 75% | Full. iPhone and iPad only: 75% is slightly softer and saves graphics work. |
|
||||
| Aspect Ratio | Fill Screen / 16:9 | Fill Screen: wider screens get a wider view; 4:3, 3:2 and 16:10 screens (the Steam Deck, many Macs) a taller one. 16:9 always shows the classic picture with bars. |
|
||||
| Brightness | -5 to +5 | 0, the picture as it is. Each step makes the shadows and mid-tones a little lighter or darker, like a TV's brightness; black and white stay put, and the menus never change. |
|
||||
| Xbox 360 Display Gamma | On / Off | On: the gamma ramp the game sets for a real Xbox 360's TV output, with slightly deeper shadows and slightly brighter mid-tones. Off: the flatter picture of v0.1.3 and earlier. |
|
||||
|
||||
The game's own Brightness slider, in the pause menu's Options > Video, works too while Xbox 360
|
||||
Display Gamma is on (with it off, it does nothing). Its middle is the default look; Y (Defaults),
|
||||
then Yes, puts it back.
|
||||
|
||||
**Graphics**
|
||||
|
||||
| Option | Choices | Default |
|
||||
|---|---|---|
|
||||
| Anti-Aliasing | On / Off | On |
|
||||
| Upscaling | Bicubic / Bilinear | Bicubic |
|
||||
| Sharpening | 0.00 to 1.00 | 0.30 (0 turns it off) |
|
||||
| Upscaling | Bicubic / Bilinear / FSR 1 | Bicubic. FSR 1 (AMD FidelityFX Super Resolution 1) keeps curves, wires and diagonals smooth when the picture is enlarged a lot, such as 1x on a 1440p or 4K screen; it is used only when the picture is enlarged, costs a little more GPU time, and makes foliage look slightly painted. Not on iPhone and iPad. |
|
||||
| Sharpening | 0.00 to 1.00 | 0.30 (0 turns it off). With FSR 1, the share of its sharpening pass's full strength. |
|
||||
| Anisotropic Filtering | Auto / Off / 2x / 4x / 8x / 16x / Original | Auto: 16x on dedicated graphics cards and M-series iPads, 4x elsewhere. Original uses each texture's own setting, as on the 360. |
|
||||
| Mipmaps | On / Off | On, so distant textures don't shimmer |
|
||||
| Internal Resolution | Auto / 1x (720p) / 2x (1440p) / 3x (2160p) | Auto: enough to cover your screen, within what the device can afford. 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.
|
||||
@@ -378,6 +425,10 @@ game while its window isn't in front.
|
||||
**Input:** Stick Deadzone (8%; raise it if the car steers by itself), Trigger Deadzone (2%), and
|
||||
Vibration (On).
|
||||
|
||||
**Controls:** the keyboard keys for each Xbox 360 control, two per control, as in the table under
|
||||
[Controls](#controls). The keys are saved by name in `settings.toml`'s `[keys]` table (`w`,
|
||||
`left_shift`, `keypad_1`, or `none`; `runtime/user/key_names.h` lists them).
|
||||
|
||||
**Advanced:** Asynchronous Shaders (On: an object is skipped for a frame or two while its shader
|
||||
compiles, instead of the game stuttering), Performance Overlay (Off: fps, frame times, and CPU
|
||||
and GPU time per frame), **Save Bug Report**, and **Check for Updates** (the only thing that goes
|
||||
@@ -389,12 +440,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, 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. 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.
|
||||
|
||||
@@ -441,6 +500,11 @@ 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 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.
|
||||
|
||||
@@ -462,6 +526,17 @@ says so: connect it and start the game again.
|
||||
|
||||
**The car steers by itself.** Raise Settings > Input > Stick Deadzone.
|
||||
|
||||
**The picture looks different since v0.1.4.** v0.1.4 applies the gamma ramp the game sets for a
|
||||
real Xbox 360's TV output: slightly deeper shadows and slightly brighter mid-tones, as on the
|
||||
console. For the previous, flatter picture, turn off Settings > Display > Xbox 360 Display Gamma.
|
||||
For lighter or darker shadows either way, use Settings > Display > Brightness. If the picture is
|
||||
much brighter and washed out, the game's own Brightness slider (pause menu > Options > Video) was
|
||||
probably moved up in an earlier version, where it did nothing, and saved: it now takes effect. Set
|
||||
the slider back to the middle, or press Y (Defaults) and choose Yes.
|
||||
|
||||
**How do I quit in fullscreen without a keyboard?** Open the settings and choose *Quit Game* on
|
||||
the bottom row; it asks first.
|
||||
|
||||
**The game ignores my touches on iPhone or iPad.** The game needs a controller or a keyboard.
|
||||
Touch only works in SpeedBreaker's own screens; a three-finger tap opens the settings.
|
||||
|
||||
@@ -474,7 +549,9 @@ Touch only works in SpeedBreaker's own screens; a three-finger tap opens the set
|
||||
| Shader caches (safe to delete) | `~/.cache/speedbreaker/` | `~/Library/Caches/SpeedBreaker/` | Inside the app |
|
||||
|
||||
Deleting that folder, or deleting the iPhone or iPad app, deletes your saves. A game you
|
||||
installed in a folder of your own stays there until you delete that folder.
|
||||
installed in a folder of your own stays there until you delete that folder. In
|
||||
[portable mode](#steam-deck-and-steam-machine) (SteamOS and Linux) all of it is in the
|
||||
SpeedBreaker folder instead: `data/`, `data/game` and `cache/`.
|
||||
|
||||
### Reporting a bug
|
||||
|
||||
@@ -482,7 +559,7 @@ installed in a folder of your own stays there until you delete that folder.
|
||||
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.1-<date>_<time>.zip`.
|
||||
version you were running, for example `speedbreaker-bug-report-v0.1.6-<date>_<time>.zip`.
|
||||
3. Open an [issue][issues], attach the zip, and say what you were doing, what you expected, what
|
||||
happened, and roughly how long into the session it happened.
|
||||
|
||||
@@ -516,10 +593,12 @@ native program for x86-64 or arm64. A runtime written for this project stands in
|
||||
overlay are drawn with Dear ImGui.
|
||||
- **Audio:** XMA decoding done the way the hardware does it, frame by frame, with a patched,
|
||||
decoder-only FFmpeg.
|
||||
- **Resolution:** the 3D scene renders at 1x, 2x or 3x; the bloom and haze stay at the original
|
||||
- **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.
|
||||
|
||||
@@ -537,6 +616,7 @@ SpeedBreaker --install <image or folder> [--dest <dir>] # install (into exactl
|
||||
SpeedBreaker --verify <image or folder> # check every file without copying
|
||||
SpeedBreaker --find-images # list the disc images it can find
|
||||
SpeedBreaker --where # show where the game is installed
|
||||
speedbreaker.sh --portable on|off # SteamOS and Linux: portable mode (copies, never deletes)
|
||||
```
|
||||
|
||||
Without `--dest` the game goes to the default folder, as from the installer screen. `--dest`
|
||||
@@ -678,6 +758,7 @@ research.
|
||||
| [SDL3](https://www.libsdl.org/) | Windows, input, audio, app lifecycle | zlib |
|
||||
| [Dear ImGui](https://github.com/ocornut/imgui) (Omar Cornut) | The installer and settings UI | MIT |
|
||||
| [Roboto](https://fonts.google.com/specimen/Roboto) (Google) | The UI font | Apache-2.0 |
|
||||
| [FidelityFX Super Resolution 1](https://github.com/GPUOpen-Effects/FidelityFX-FSR) (AMD) | The FSR 1 Upscaling choice | MIT |
|
||||
| [o1heap](https://github.com/pavel-kirienko/o1heap) (Pavel Kirienko) | The guest heap allocator | MIT |
|
||||
| [glslang](https://github.com/KhronosGroup/glslang) and [SPIRV-Tools](https://github.com/KhronosGroup/SPIRV-Tools) (Khronos) | Shader compilation at runtime | BSD-3-Clause and others; Apache-2.0 |
|
||||
| [MoltenVK](https://github.com/KhronosGroup/MoltenVK) and the [Vulkan Loader](https://github.com/KhronosGroup/Vulkan-Loader) (Khronos) | Vulkan on Mac, iPhone and iPad | Apache-2.0 |
|
||||
@@ -691,9 +772,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`.
|
||||
|
||||
@@ -715,7 +796,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)
|
||||
|
||||
@@ -28,16 +28,17 @@ collected when the bundle is made).
|
||||
| **Roboto** (Medium): Copyright 2011 Google Inc. | The menus' font, embedded in the program. Roboto is a trademark of Google. | Apache-2.0 | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt) |
|
||||
| **o1heap**: Copyright (c) 2020 Pavel Kirienko | The game's heap allocator (`runtime/thirdparty/o1heap`). | MIT | [`LICENSES/o1heap-MIT.txt`](LICENSES/o1heap-MIT.txt) |
|
||||
| **QR Code generator library** (C) 1.8.0: Copyright (c) 2022 Project Nayuki | The QR code of the download page in Settings > Advanced > Check for Updates (`runtime/thirdparty/qrcodegen`). | MIT | [`LICENSES/qrcodegen-MIT.txt`](LICENSES/qrcodegen-MIT.txt) |
|
||||
| **AMD FidelityFX Super Resolution 1** (FSR 1, `ffx_a.h` and `ffx_fsr1.h` at commit a21ffb8): Copyright (c) 2021 Advanced Micro Devices, Inc. | Settings > Graphics > Upscaling's FSR 1: its EASU and RCAS shader code, compiled into the game's shaders, and its constant setup (`runtime/thirdparty/fsr1`, unmodified). | MIT | [`LICENSES/FidelityFX-FSR-MIT.txt`](LICENSES/FidelityFX-FSR-MIT.txt) |
|
||||
| **FFmpeg** 7.1.1: the FFmpeg developers | The XMA1, XMA2 and WMA Pro audio decoders, linked statically. Built without any GPL parts, with one SpeedBreaker change: [`patches/ffmpeg/0001-xma-single-stream-no-holdback.patch`](patches/ffmpeg/0001-xma-single-stream-no-holdback.patch) (no sample hold-back for single-stream XMA, which the game's audio mixer depends on). Source: `https://ffmpeg.org/releases/ffmpeg-7.1.1.tar.xz` plus that patch; [`scripts/build_ffmpeg_xma.sh`](scripts/build_ffmpeg_xma.sh) builds it. | LGPL-2.1-or-later | [`LICENSES/FFmpeg-LGPL-2.1.txt`](LICENSES/FFmpeg-LGPL-2.1.txt) |
|
||||
|
||||
## Libraries in the downloads
|
||||
|
||||
| Project | Where | License | Text |
|
||||
|---|---|---|---|
|
||||
| **SDL3**: Copyright (C) 1997-2026 Sam Lantinga | Windows, input, audio and the app's lifecycle. In the Mac app and both SteamOS downloads. It includes HIDAPI (controllers; Copyright (c) 2010 Alan Ott, Signal 11 Software; under its BSD-style license, one of the three it offers) and yuv2rgb (Copyright (c) 2016 Adrien Descamps). | zlib; HIDAPI and yuv2rgb: BSD-3-Clause | [`LICENSES/SDL3-Zlib.txt`](LICENSES/SDL3-Zlib.txt); [`LICENSES/HIDAPI-BSD-3-Clause.txt`](LICENSES/HIDAPI-BSD-3-Clause.txt); [`LICENSES/yuv2rgb-BSD-3-Clause.txt`](LICENSES/yuv2rgb-BSD-3-Clause.txt) |
|
||||
| **glslang**: The Khronos Group Inc. and others | Compiles the game's shaders while it runs. In the Mac app and both SteamOS downloads. | BSD-3-Clause and others (its own list) | [`LICENSES/glslang.txt`](LICENSES/glslang.txt) |
|
||||
| **SDL3**: Copyright (C) 1997-2026 Sam Lantinga | Windows, input, audio and the app's lifecycle. In the Mac app, both SteamOS downloads and the iPhone and iPad IPA. It includes HIDAPI (controllers; Copyright (c) 2010 Alan Ott, Signal 11 Software; under its BSD-style license, one of the three it offers) and yuv2rgb (Copyright (c) 2016 Adrien Descamps). | zlib; HIDAPI and yuv2rgb: BSD-3-Clause | [`LICENSES/SDL3-Zlib.txt`](LICENSES/SDL3-Zlib.txt); [`LICENSES/HIDAPI-BSD-3-Clause.txt`](LICENSES/HIDAPI-BSD-3-Clause.txt); [`LICENSES/yuv2rgb-BSD-3-Clause.txt`](LICENSES/yuv2rgb-BSD-3-Clause.txt) |
|
||||
| **glslang**: The Khronos Group Inc. and others | Compiles the game's shaders while it runs. In the Mac app, both SteamOS downloads and the iPhone and iPad IPA (without its optimizer). | BSD-3-Clause and others (its own list) | [`LICENSES/glslang.txt`](LICENSES/glslang.txt) |
|
||||
| **SPIRV-Tools**: The Khronos Group Inc. and others | Used by glslang. In the Mac app and both SteamOS downloads. | Apache-2.0 | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt) |
|
||||
| **MoltenVK** (with SPIRV-Cross): Copyright (c) 2015-2026 The Brenwill Workshop Ltd., The Khronos Group Inc. | Vulkan on Apple's Metal. In the Mac app (and an iPhone or iPad build). It includes cereal (Copyright (c) 2013-2022 Randolph Voorhies, Shane Grant; BSD-3-Clause), which saves its shader conversions, and SPIRV-Tools (below). | Apache-2.0; cereal: BSD-3-Clause | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt); [`LICENSES/cereal-BSD-3-Clause.txt`](LICENSES/cereal-BSD-3-Clause.txt) |
|
||||
| **MoltenVK** (with SPIRV-Cross): Copyright (c) 2015-2026 The Brenwill Workshop Ltd., The Khronos Group Inc. | Vulkan on Apple's Metal. In the Mac app and the iPhone and iPad IPA (and an iPhone or iPad build). It includes cereal (Copyright (c) 2013-2022 Randolph Voorhies, Shane Grant; BSD-3-Clause), which saves its shader conversions, and SPIRV-Tools (below). | Apache-2.0; cereal: BSD-3-Clause | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt); [`LICENSES/cereal-BSD-3-Clause.txt`](LICENSES/cereal-BSD-3-Clause.txt) |
|
||||
| **Vulkan Loader**: The Khronos Group Inc., Valve Corporation, LunarG, Inc. | In the Mac app (SteamOS has its own). It includes cJSON (Copyright (c) 2009-2017 Dave Gamble and cJSON contributors). | Apache-2.0; cJSON: MIT | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt); [`LICENSES/cJSON-MIT.txt`](LICENSES/cJSON-MIT.txt) |
|
||||
| **Vulkan-Headers** and **SPIRV-Headers**: The Khronos Group Inc. | Headers the game, the Vulkan Loader, glslang and SPIRV-Tools are built with. | Apache-2.0 or MIT (Vulkan-Headers); MIT (SPIRV-Headers) | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt) or [`LICENSES/Vulkan-Headers-MIT.txt`](LICENSES/Vulkan-Headers-MIT.txt); [`LICENSES/SPIRV-Headers.txt`](LICENSES/SPIRV-Headers.txt) |
|
||||
| **GNU C Library** (glibc) and **GCC's runtime libraries** (libstdc++, libgcc_s) | In the SteamOS x86-64 download only, from the Arch Linux packages it was built with (the download's `licenses/README.txt` names each package and version). The Steam Frame download uses SteamOS's own. | glibc: LGPL-2.1-or-later. GCC's runtime: GPL-3.0 with the GCC Runtime Library Exception | in the download's `licenses/` |
|
||||
|
||||
|
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
|
||||
|
||||
@@ -22,6 +22,9 @@ from you.
|
||||
buttons on either side of the screen).
|
||||
- The D-pad picks a row, left/right changes it, A selects, LB/RB switch
|
||||
pages, B closes. The game pauses while the menu is open.
|
||||
- The **Controls** page: choose a row, Left or Right for its first or second key, press A (or
|
||||
Enter), then the key you want; Esc or B cancels. Y (Backspace) clears a key, X (Delete) restores
|
||||
the row, *Reset page* the whole table.
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
@@ -57,7 +60,8 @@ date and time it started, next to any `crash-*.txt` and `hang-*.txt`
|
||||
reports:
|
||||
|
||||
- Linux / SteamOS: `~/.local/share/speedbreaker/logs/`
|
||||
(`$XDG_DATA_HOME/speedbreaker/logs/` if that is set)
|
||||
(`$XDG_DATA_HOME/speedbreaker/logs/` if that is set; in portable mode,
|
||||
`data/logs/` in the game's folder, next to `portable.txt`)
|
||||
- macOS: `~/Library/Application Support/SpeedBreaker/logs/`
|
||||
|
||||
On SteamOS the launcher also leaves the latest session's output in
|
||||
|
||||
@@ -79,6 +79,20 @@ target_link_libraries(imgui PUBLIC SDL3::SDL3 Vulkan::Vulkan)
|
||||
file(READ ${IMGUI}/fonts/Roboto-Medium.ttf UI_FONT_HEX HEX)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," UI_FONT_BYTES "${UI_FONT_HEX}")
|
||||
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/generated/ui_font.inc "${UI_FONT_BYTES}\n")
|
||||
# AMD FidelityFX Super Resolution 1 (thirdparty/fsr1, vendored, unmodified):
|
||||
# the text of its two headers, compiled in (generated/fsr1_headers.inc) for
|
||||
# Upscaling's FSR 1 shaders, which glslang compiles while the game runs
|
||||
# (video/fsr1.cpp). An edit of either reconfigures.
|
||||
set(FSR1 ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/fsr1)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${FSR1}/ffx_a.h ${FSR1}/ffx_fsr1.h)
|
||||
set(fsr1_inc "// Generated from runtime/thirdparty/fsr1 by runtime/CMakeLists.txt. Do not edit.\n")
|
||||
foreach(name ffx_a ffx_fsr1)
|
||||
file(READ ${FSR1}/${name}.h fsr1_hex HEX)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," fsr1_bytes "${fsr1_hex}")
|
||||
string(APPEND fsr1_inc "constexpr unsigned char k_${name}[] = { ${fsr1_bytes}0 };\n")
|
||||
endforeach()
|
||||
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/generated/fsr1_headers.inc.new "${fsr1_inc}")
|
||||
configure_file(${CMAKE_CURRENT_BINARY_DIR}/generated/fsr1_headers.inc.new ${CMAKE_CURRENT_BINARY_DIR}/generated/fsr1_headers.inc COPYONLY)
|
||||
target_include_directories(SpeedBreaker PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||
# The "What's new" note (ui/whats_new.cpp): this version's entry in
|
||||
# CHANGELOG.md, from its "## v<version>" heading to the next "## " one,
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
// left every voice empty for half of each burst: gameplay audio came out
|
||||
// chopped 50% (two frames on, two off), thin and "tinny". SDL's queue is
|
||||
// kept near two device buffers to absorb the device's pulls, and the
|
||||
// cadence gains or skips a frame to follow the device clock. NFSMW_MUTE=1
|
||||
// cadence gains or skips a frame to follow the device clock, but never stops
|
||||
// (apu/audio_cadence.h): all of the game's sound, its sound commands
|
||||
// included, runs on this callback, so a device that stops taking audio must
|
||||
// not stop it (GitHub issue #20: the sound stopped for good, then the game
|
||||
// hung after the race). What such a device can't take is dropped, and the
|
||||
// log says when it stopped and started taking audio again. NFSMW_MUTE=1
|
||||
// (or no audio device) keeps the same cadence with no output. The driver
|
||||
// handle and wrapped argument follow Xenia.
|
||||
//
|
||||
@@ -24,6 +29,7 @@
|
||||
// frames owed.
|
||||
#include <stdafx.h>
|
||||
#include "audio.h"
|
||||
#include "audio_cadence.h"
|
||||
#include <kernel/function.h>
|
||||
#include <cpu/guest_thread.h>
|
||||
#include <cpu/guest_time.h>
|
||||
@@ -33,6 +39,7 @@
|
||||
#endif
|
||||
#include <user/settings.h>
|
||||
#include <video/presenter.h>
|
||||
#include <report/report.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -54,6 +61,19 @@ namespace
|
||||
constexpr int FRAME_BYTES = SAMPLES_PER_FRAME * CHANNELS * sizeof(float);
|
||||
std::atomic<int> s_targetFrames{ 4 }; // SDL queue level to hold (frames of 256 samples)
|
||||
int s_deviceFrames = 0; // the device's buffer at the stream's rate (FollowDevice)
|
||||
// What the hang report says about the audio (apu::DescribeState): when
|
||||
// the render callback last ran, when the game's mix last had sound, and
|
||||
// since when the device hasn't taken audio (0: it has); steady-clock ns.
|
||||
std::atomic<int64_t> s_lastRenderNs{ 0 }, s_lastSoundNs{ 0 }, s_deviceStalledNs{ 0 };
|
||||
std::atomic<uint64_t> s_dropped{ 0 }; // frames the device didn't take (dropped)
|
||||
// The cadence (the audio thread; the frames it renders are submitted on
|
||||
// that thread too).
|
||||
apu::AudioCadence s_cadence;
|
||||
|
||||
int64_t SteadyNs()
|
||||
{
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
// The queue level for the stream's device as it is now: two device
|
||||
// buffers (at the stream's rate), at least 4 frames. Logs it when it
|
||||
@@ -140,9 +160,42 @@ namespace
|
||||
paused = guesttime::PausedNs();
|
||||
next = steady_clock::now();
|
||||
} while (guesttime::Suspended() || guesttime::PausedNs() != paused);
|
||||
// NFSMW_TEST_AUDIO_DEVICE_STALL=<s>[+<secs>] (tests): the output
|
||||
// device stops taking audio <s> seconds after this thread starts
|
||||
// (paused, as a device that stalls or that another app holds), for
|
||||
// good or for <secs>.
|
||||
double stallAt = -1, stallFor = 0;
|
||||
if (const char* v = std::getenv("NFSMW_TEST_AUDIO_DEVICE_STALL"))
|
||||
{
|
||||
char* end = nullptr;
|
||||
stallAt = std::strtod(v, &end);
|
||||
if (end && *end == '+')
|
||||
stallFor = std::strtod(end + 1, nullptr);
|
||||
}
|
||||
int stallState = 0; // 0 before, 1 stalled, 2 over
|
||||
const auto started = steady_clock::now();
|
||||
while (s_running)
|
||||
{
|
||||
std::this_thread::sleep_until(next);
|
||||
if (stallAt >= 0 && stallState < 2 && s_stream)
|
||||
{
|
||||
const double t = duration<double>(steady_clock::now() - started).count();
|
||||
if (stallState == 0 && t >= stallAt)
|
||||
{
|
||||
stallState = 1;
|
||||
fprintf(stderr, "[test] NFSMW_TEST_AUDIO_DEVICE_STALL: the output device stops taking audio (%.1f s)\n", t);
|
||||
std::lock_guard lock(s_streamMutex);
|
||||
SDL_PauseAudioStreamDevice(s_stream);
|
||||
}
|
||||
else if (stallState == 1 && stallFor > 0 && t >= stallAt + stallFor)
|
||||
{
|
||||
stallState = 2;
|
||||
fprintf(stderr, "[test] NFSMW_TEST_AUDIO_DEVICE_STALL: the output device takes audio again (%.1f s)\n", t);
|
||||
std::lock_guard lock(s_streamMutex);
|
||||
if (!guesttime::Suspended())
|
||||
SDL_ResumeAudioStreamDevice(s_stream);
|
||||
}
|
||||
}
|
||||
// No guest audio time passes while the game is suspended: render
|
||||
// nothing (the main thread has paused the device). After it the
|
||||
// cadence moves on by exactly the time suspended, keeping its
|
||||
@@ -172,17 +225,31 @@ namespace
|
||||
FollowDevice(false);
|
||||
}
|
||||
// Follow the device clock: one frame more or less when the
|
||||
// queue drifts from its target.
|
||||
int queued = SDL_GetAudioStreamQueued(s_stream) / FRAME_BYTES;
|
||||
if (queued > s_targetFrames + 2)
|
||||
frames = 0;
|
||||
else if (queued < s_targetFrames - 2)
|
||||
frames = 2;
|
||||
// queue drifts from its target, never stopping the game's
|
||||
// sound (audio_cadence.h).
|
||||
int queued = std::max(0, SDL_GetAudioStreamQueued(s_stream)) / FRAME_BYTES;
|
||||
frames = s_cadence.Frames(queued, s_targetFrames);
|
||||
if (s_cadence.Stalled() && !s_deviceStalledNs.load(std::memory_order_relaxed))
|
||||
{
|
||||
s_deviceStalledNs.store(SteadyNs(), std::memory_order_relaxed);
|
||||
fprintf(stderr, "[audio] the output device has taken no audio for %.1f s (%.0f ms queued): the game's sound "
|
||||
"carries on, unheard, until it does\n", apu::AudioCadence::StallTicks(s_targetFrames) * apu::kFrameNs / 1e9,
|
||||
1000.0 * queued * SAMPLES_PER_FRAME / SAMPLE_RATE);
|
||||
}
|
||||
if (s_cadence.Resumed())
|
||||
{
|
||||
// What was queued before the stall is stale.
|
||||
SDL_ClearAudioStream(s_stream);
|
||||
const int64_t since = s_deviceStalledNs.exchange(0, std::memory_order_relaxed);
|
||||
fprintf(stderr, "[audio] the output device takes audio again after %.1f s (%llu frames dropped in all)\n",
|
||||
since ? double(SteadyNs() - since) / 1e9 : 0.0, (unsigned long long)s_dropped.load());
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < frames && !guesttime::Suspended(); i++)
|
||||
{
|
||||
RenderFrame(ctx);
|
||||
s_rendered.fetch_add(1, std::memory_order_relaxed);
|
||||
s_lastRenderNs.store(SteadyNs(), std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +276,22 @@ namespace apu
|
||||
return s_rendered.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::string DescribeState()
|
||||
{
|
||||
const int64_t now = SteadyNs();
|
||||
auto ago = [now](int64_t t) { return t ? std::format("{:.1f} s ago", double(now - t) / 1e9) : std::string("never"); };
|
||||
std::string text = std::format("Render callbacks: {} (the last {}); frames from the game: {}; the game's mix last had sound {}.\n",
|
||||
s_rendered.load(), ago(s_lastRenderNs.load()), s_framesSubmitted.load(), ago(s_lastSoundNs.load()));
|
||||
if (!s_stream)
|
||||
return text + "Output device: none (muted by NFSMW_MUTE, or no device).\n";
|
||||
const int64_t stalled = s_deviceStalledNs.load();
|
||||
text += std::format("Output device: {:.1f} ms queued (target {:.1f} ms); {}; {} frames dropped in all.\n", QueuedMs(),
|
||||
1000.0 * s_targetFrames * SAMPLES_PER_FRAME / SAMPLE_RATE,
|
||||
stalled ? std::format("not taking audio for {:.1f} s", double(now - stalled) / 1e9) : std::string("taking audio"),
|
||||
s_dropped.load());
|
||||
return text;
|
||||
}
|
||||
|
||||
double QueuedMs()
|
||||
{
|
||||
std::lock_guard lock(s_streamMutex);
|
||||
@@ -256,6 +339,34 @@ uint32_t XAudioUnregisterRenderDriverClient(uint32_t driver)
|
||||
uint32_t XAudioSubmitRenderDriverFrame(uint32_t driver, void* samples)
|
||||
{
|
||||
s_framesSubmitted++;
|
||||
// When the game's mix last had sound (the hang report), and a line once it
|
||||
// has been silent for 10 s in a row (its sound stopped, or a silent
|
||||
// screen) and when it sounds again: a player's log shows when it dropped.
|
||||
if (samples)
|
||||
{
|
||||
static uint64_t silentFrames = 0;
|
||||
static double silentFrom = 0;
|
||||
const auto* words = static_cast<const uint32_t*>(samples);
|
||||
bool silent = true;
|
||||
for (uint32_t i = 0; i < SAMPLES_PER_FRAME * CHANNELS && silent; i++)
|
||||
silent = (words[i] & 0xFFFFFF7Fu) == 0; // +0.0 or -0.0, big-endian
|
||||
constexpr uint64_t kSilentLog = 10 * SAMPLE_RATE / SAMPLES_PER_FRAME;
|
||||
if (!silent)
|
||||
{
|
||||
s_lastSoundNs.store(SteadyNs(), std::memory_order_relaxed);
|
||||
if (silentFrames >= kSilentLog)
|
||||
fprintf(stderr, "[audio] the game's mix sounds again after %.1f s of silence\n",
|
||||
double(silentFrames) * SAMPLES_PER_FRAME / SAMPLE_RATE);
|
||||
silentFrames = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (silentFrames == 0)
|
||||
silentFrom = report::SessionSeconds();
|
||||
if (++silentFrames == kSilentLog)
|
||||
fprintf(stderr, "[audio] the game's mix has been silent for 10 s (since %.1f s)\n", silentFrom);
|
||||
}
|
||||
}
|
||||
if (!s_stream || !samples)
|
||||
return 0;
|
||||
// Volume, mute and mute-in-background, when they or the focus change.
|
||||
@@ -322,6 +433,13 @@ uint32_t XAudioSubmitRenderDriverFrame(uint32_t driver, void* samples)
|
||||
nextLog = now + std::chrono::seconds(2);
|
||||
}
|
||||
}
|
||||
// A device that isn't taking audio (audio_cadence.h): the frame is
|
||||
// dropped, and the game's sound carries on, unheard.
|
||||
if (s_cadence.Stalled())
|
||||
{
|
||||
s_dropped.fetch_add(1, std::memory_order_relaxed);
|
||||
return 0;
|
||||
}
|
||||
SDL_PutAudioStreamData(s_stream, out, sizeof(out));
|
||||
|
||||
// NFSMW_AUDIO_DUMP=<file>: the final mix as raw interleaved 6-channel
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// cpu/guest_time.h).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace apu
|
||||
{
|
||||
@@ -24,4 +25,9 @@ namespace apu
|
||||
// and the audio queued for the device (ms; 0 without one).
|
||||
uint64_t RenderedFrames();
|
||||
double QueuedMs();
|
||||
|
||||
// Any thread: the audio's state for a hang report, a line or two: the
|
||||
// render callbacks (the game's sound runs on them), when the game's mix
|
||||
// last had sound, and the output device (GitHub issue #20).
|
||||
std::string DescribeState();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The render callback's cadence against the output device (apu/audio.cpp),
|
||||
// header only so that tests/audio_cadence_test.cpp runs it.
|
||||
//
|
||||
// All of the game's sound runs on the render callback. XAudio processes its
|
||||
// voices once per call, and NFSMW's own mixer thread (sub_825E3C30) mixes its
|
||||
// next 256 samples only when XAudio's pass over its voice wakes it; that
|
||||
// thread also carries out the game's sound commands (play, stop, unload:
|
||||
// sub_825CF588). The console's hardware calls the callback every 5.33 ms,
|
||||
// whatever the TV or speakers do. Here the cadence follows the output
|
||||
// device's clock: a frame more or less when SDL's queue strays more than
|
||||
// kSlack frames from its target. Up to v0.1.5 that was all, so a device that
|
||||
// stopped taking audio (one that stalls, another app holding it) kept the
|
||||
// queue over its target and the callback skipped for good: the game's sound
|
||||
// stopped, its sound commands with it, and the game hung the next time it
|
||||
// waited for a sound to stop (sub_821BD920 polls until the sound system no
|
||||
// longer lists a stream it stopped, as when it unloads an event's sounds
|
||||
// after the race): GitHub issue #20.
|
||||
//
|
||||
// Now a device that has taken nothing for StallTicks ticks (half a second,
|
||||
// or four queue targets: far longer than any device takes between its
|
||||
// pulls) is stalled: the callback runs at real time again, every frame is
|
||||
// dropped (audio.cpp), and the stall is over the moment the device takes
|
||||
// some: what was queued before it is stale and cleared (Resumed), and the
|
||||
// queue fills up again as at the start. A device that does take audio, at
|
||||
// whatever pace, is followed exactly as before.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
namespace apu
|
||||
{
|
||||
class AudioCadence
|
||||
{
|
||||
public:
|
||||
static constexpr int kSlack = 2; // frames the queue may stray from its target
|
||||
static constexpr int kStallTicks = 94; // ~0.5 s
|
||||
|
||||
// Ticks without the device taking anything before it counts as
|
||||
// stalled, for a queue target of `target` frames.
|
||||
static constexpr int StallTicks(int target) { return std::max(kStallTicks, 4 * target); }
|
||||
|
||||
// One tick (every 5.33 ms): how many frames to render now (0, 1 or
|
||||
// 2), with `queued` frames waiting for the device and a queue target
|
||||
// of `target` frames.
|
||||
int Frames(int queued, int target)
|
||||
{
|
||||
// What the device took since the last tick: the queue then, plus
|
||||
// what was queued since, less the queue now.
|
||||
m_idle = m_primed && m_expected - queued <= 0 ? m_idle + 1 : 0;
|
||||
m_primed = true;
|
||||
if (m_stalled)
|
||||
{
|
||||
if (m_idle > 0)
|
||||
{
|
||||
m_expected = queued; // the frame is dropped
|
||||
return 1;
|
||||
}
|
||||
// It took some: over. The caller clears what is queued.
|
||||
m_stalled = false;
|
||||
m_resumed = true;
|
||||
queued = 0;
|
||||
}
|
||||
else if (m_idle >= StallTicks(target))
|
||||
{
|
||||
m_stalled = true;
|
||||
m_stalls++;
|
||||
m_expected = queued;
|
||||
return 1;
|
||||
}
|
||||
const int frames = queued > target + kSlack ? 0 : queued < target - kSlack ? 2 : 1;
|
||||
m_expected = queued + frames;
|
||||
return frames;
|
||||
}
|
||||
|
||||
// The device isn't taking audio: every frame rendered is dropped,
|
||||
// not queued.
|
||||
bool Stalled() const { return m_stalled; }
|
||||
// True once after a stall ends: the audio still queued from before
|
||||
// it is stale (clear it).
|
||||
bool Resumed()
|
||||
{
|
||||
const bool r = m_resumed;
|
||||
m_resumed = false;
|
||||
return r;
|
||||
}
|
||||
uint64_t Stalls() const { return m_stalls; }
|
||||
|
||||
private:
|
||||
bool m_primed = false, m_stalled = false, m_resumed = false;
|
||||
int m_expected = 0; // the queue expected now if the device took nothing
|
||||
int m_idle = 0; // ticks in a row the device took nothing
|
||||
uint64_t m_stalls = 0;
|
||||
};
|
||||
}
|
||||
@@ -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,110 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See glare.h.
|
||||
//
|
||||
// Two function overrides (the generated functions are weak; no ppc/
|
||||
// regeneration):
|
||||
// - sub_82225730, the sun code (projects the sun, counts its occlusion,
|
||||
// draws its glow): keeps its view argument for the glow. r4 is the view's
|
||||
// entry in the per-view table (0x82C3F590 + id * 384; logged: view 1 at
|
||||
// 0x82C3F710, view 4 at 0x82C3FB90), its id at +0, which the code itself
|
||||
// tests for the reflection views 4 and 5.
|
||||
// - sub_822251C8, the quad builder: after the game builds the glow's quad
|
||||
// (called from the sun code, ctx.lr = glare::kGlowCall) in a view the
|
||||
// projection hook widens, its corners are fitted to the view scales.
|
||||
// NFSMW_GLARE_FIT=0 leaves the glow as the game builds it (an A/B switch).
|
||||
#include <stdafx.h>
|
||||
#include "glare.h"
|
||||
#include "ultrawide.h"
|
||||
|
||||
#include <kernel/function.h>
|
||||
|
||||
extern "C" PPC_FUNC(__imp__sub_82225730);
|
||||
extern "C" PPC_FUNC(__imp__sub_822251C8);
|
||||
|
||||
namespace
|
||||
{
|
||||
const bool s_fit = [] {
|
||||
const char* v = std::getenv("NFSMW_GLARE_FIT");
|
||||
const bool on = !(v && v[0] == '0');
|
||||
if (!on)
|
||||
fprintf(stderr, "[glare] NFSMW_GLARE_FIT=0: the sun's glow is drawn as the game builds it (stretched off 16:9)\n");
|
||||
return on;
|
||||
}();
|
||||
|
||||
// The view the sun code is drawing for (its r4), on the thread running
|
||||
// it; 0 outside it.
|
||||
thread_local uint32_t t_sunView = 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);
|
||||
}
|
||||
}
|
||||
|
||||
PPC_FUNC(sub_82225730)
|
||||
{
|
||||
const uint32_t outer = t_sunView;
|
||||
t_sunView = ctx.r4.u32;
|
||||
__imp__sub_82225730(ctx, base);
|
||||
t_sunView = outer;
|
||||
}
|
||||
|
||||
PPC_FUNC(sub_822251C8)
|
||||
{
|
||||
const uint32_t lr = static_cast<uint32_t>(ctx.lr), verts = ctx.r5.u32;
|
||||
__imp__sub_822251C8(ctx, base);
|
||||
if (lr != game::glare::kGlowCall || !t_sunView)
|
||||
return;
|
||||
const float sx = game::AspectScaleX(), sy = game::AspectScaleY();
|
||||
const uint32_t id = PPC_LOAD_U32(t_sunView);
|
||||
const bool fit = s_fit && (sx != 1.0f || sy != 1.0f) && game::glare::WidenedViewId(id);
|
||||
// Each view's first glow (and again when the scales change), in every
|
||||
// log: the view, its corners as built, and what was done.
|
||||
{
|
||||
static std::mutex mutex;
|
||||
static std::unordered_map<uint32_t, uint64_t> logged;
|
||||
uint32_t sxBits, syBits;
|
||||
memcpy(&sxBits, &sx, 4);
|
||||
memcpy(&syBits, &sy, 4);
|
||||
const uint64_t key = (uint64_t(sxBits) << 32) ^ syBits ^ (fit ? 1ull << 63 : 0);
|
||||
std::lock_guard lock(mutex);
|
||||
if (logged.size() < 64)
|
||||
{
|
||||
auto [it, added] = logged.try_emplace(id, ~key);
|
||||
if (it->second != key)
|
||||
{
|
||||
it->second = key;
|
||||
fprintf(stderr, "[glare] sun glow in view %u: corners (%.1f,%.1f) (%.1f,%.1f) (%.1f,%.1f) (%.1f,%.1f); view scale x %.4f y %.4f: %s\n",
|
||||
id, LoadFloat(base, verts), LoadFloat(base, verts + 4), LoadFloat(base, verts + 16),
|
||||
LoadFloat(base, verts + 20), LoadFloat(base, verts + 32), LoadFloat(base, verts + 36), LoadFloat(base, verts + 48),
|
||||
LoadFloat(base, verts + 52), sx, sy,
|
||||
fit ? "fitted to the view scale" : !s_fit ? "left as built (NFSMW_GLARE_FIT=0)"
|
||||
: (sx == 1.0f && sy == 1.0f) ? "left as built (16:9)" : "left as built (a view the projection hook doesn't widen)");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fit)
|
||||
return;
|
||||
// The corners: x, y at +0 and +4 of four 16-byte vertices.
|
||||
float x[4], y[4];
|
||||
for (uint32_t i = 0; i < 4; i++)
|
||||
{
|
||||
x[i] = LoadFloat(base, verts + i * 16);
|
||||
y[i] = LoadFloat(base, verts + i * 16 + 4);
|
||||
}
|
||||
game::glare::FitSprite(x, y, sx, sy);
|
||||
for (uint32_t i = 0; i < 4; i++)
|
||||
{
|
||||
StoreFloat(base, verts + i * 16, x[i]);
|
||||
StoreFloat(base, verts + i * 16 + 4, y[i]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The sun's glare on a screen of another shape than 16:9 (game/glare.cpp
|
||||
// hooks it; tests/glare_fit_test.cpp checks this header alone).
|
||||
//
|
||||
// The glare is a screen-space sprite, not a view's geometry: the sun code
|
||||
// (sub_82225730) projects the sun through the view, turns the result into
|
||||
// pixels of the 1280x720 frame (view 0's target), and its quad builder
|
||||
// (sub_822251C8) makes a rotated square of a fixed size in those pixels
|
||||
// around that point: v0 = (cx - a, cy - b), v1 = (cx + b, cy - a),
|
||||
// v2 = (cx + a, cy + b), v3 = (cx - b, cy + a), a = R (cos + sin),
|
||||
// b = R (cos - sin). A pixel-to-clip matrix of its own (not the front
|
||||
// end's) draws it. Under Hor+ and Vert+ the presenter stretches the frame by
|
||||
// 1/scaleX in x or 1/scaleY in y (video::PlacePicture), so a square of frame
|
||||
// pixels came out 1/scaleX wide or 1/scaleY tall: 1.33x taller at 4:3,
|
||||
// 1.11x on a 16:10 Steam Deck, 1.34x wider at 3440x1440. The view's own
|
||||
// geometry doesn't, because its projection was narrowed by the same scale
|
||||
// (game/ultrawide.cpp).
|
||||
//
|
||||
// The fix scales the glow's corners about their centre by the view scales,
|
||||
// x by scaleX and y by scaleY (one of them is 1), after the game built them:
|
||||
// the stretch then gives back the square the game meant, with its angle, at
|
||||
// the size it has against the 16:9 band (the world's, the HUD's). The centre,
|
||||
// the sun's projected position, is already right. Only the glow is changed,
|
||||
// and only in the views the projection hook widens: the sun's occlusion
|
||||
// quad (the same builder, called from sub_82225438) keeps its 32x32 pixels,
|
||||
// so the share of it that is visible is still counted against the game's
|
||||
// expected 2048 samples; and in the environment cube's faces (views 18-23,
|
||||
// square targets sampled by direction, never stretched) the glow stays as
|
||||
// the game draws it.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace game::glare
|
||||
{
|
||||
// Return addresses of sub_822251C8's two calls (ctx.lr at entry): the
|
||||
// glow, from the sun code sub_82225730, and the occlusion quad, from
|
||||
// sub_82225438.
|
||||
constexpr uint32_t kGlowCall = 0x82225A40;
|
||||
constexpr uint32_t kOcclusionQuadCall = 0x822254B0;
|
||||
|
||||
// The views game/ultrawide.cpp widens (the projection hook's set): the
|
||||
// player's camera (1), 2, the road reflection (4, 5) and 17. The rest
|
||||
// (the shadow maps 13-16, the environment cube's faces 18-23) keep the
|
||||
// 16:9 projection.
|
||||
inline bool WidenedViewId(uint32_t id)
|
||||
{
|
||||
return id == 1 || id == 2 || id == 4 || id == 5 || id == 17;
|
||||
}
|
||||
|
||||
// The sprite's four corners (frame pixels), scaled about their centre:
|
||||
// x by scaleX, y by scaleY. The centre is the mean of the corners (the
|
||||
// builder's quad is symmetric about it).
|
||||
inline void FitSprite(float x[4], float y[4], float scaleX, float scaleY)
|
||||
{
|
||||
const float cx = (x[0] + x[1] + x[2] + x[3]) * 0.25f, cy = (y[0] + y[1] + y[2] + y[3]) * 0.25f;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
x[i] = cx + (x[i] - cx) * scaleX;
|
||||
y[i] = cy + (y[i] - cy) * scaleY;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,8 @@
|
||||
// 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>
|
||||
|
||||
@@ -88,11 +94,12 @@ namespace
|
||||
fprintf(stderr, "[uw] view %u: target %ux%u%s\n", id, w, h, widened ? ", widened" : "");
|
||||
}
|
||||
|
||||
// The view at `view` (guest address) is one the game widens for 16:9.
|
||||
// The view at `view` (guest address) is one the game widens for 16:9
|
||||
// (game/glare.h keeps the set: the sun's glow is fitted in the same views).
|
||||
bool WidenedView(uint32_t view)
|
||||
{
|
||||
uint32_t id = ByteSwap(*static_cast<const uint32_t*>(g_memory.Translate(view + 4)));
|
||||
bool widen = id == 1 || id == 2 || id == 4 || id == 5 || id == 17;
|
||||
bool widen = game::glare::WidenedViewId(id);
|
||||
LogView(id, widen);
|
||||
return widen;
|
||||
}
|
||||
@@ -108,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
|
||||
@@ -186,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)
|
||||
@@ -221,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";
|
||||
}
|
||||
@@ -23,14 +23,27 @@
|
||||
// a thin border of the world shows around it, as on the console; a TV's
|
||||
// overscan hid it). Under Vert+ the world showed above and below it (63
|
||||
// rows on the Deck), under Hor+ beside it (720 px a side at 2880x810). The
|
||||
// intro's start (~57 s) has another, 0.920 of the frame's height.
|
||||
// intro's start (~57 s) has another, 0.920 of the frame's height. The
|
||||
// Blacklist's rival bio (SafeHouseRivalBio.fng, "View Rival Movie / View
|
||||
// Rival Car") sets its text and picture on one more, 0.976 x 0.985 of the
|
||||
// frame (FF000000, fading in from 11000000), for as long as the screen is
|
||||
// up: a thin rectangular border of the scene behind it all round it, on
|
||||
// every screen shape (GitHub #19).
|
||||
//
|
||||
// The fix leaves those draws as they are and, right after each side bar or
|
||||
// black frame, draws it once or twice more, stretched over the whole frame
|
||||
// and scissored to (its half of) what lies outside the 16:9 band: the rows
|
||||
// above and below it (Vert+), or the columns beyond its side (Hor+; for a
|
||||
// bar only when it ends short of the screen edge). Nothing inside the band
|
||||
// changes, and the strips get the quad's own colour, alpha and blend.
|
||||
// black frame, draws it once or more again, stretched over the whole frame
|
||||
// and scissored to what it must fill: for a bar, (its half of) what lies
|
||||
// outside the 16:9 band, the rows above and below it (Vert+) or the columns
|
||||
// beyond its side (Hor+; only when it ends short of the screen edge), and
|
||||
// nothing inside the band changes; for a black frame, everything outside
|
||||
// the quad itself, the band's thin border too, on every screen shape, 16:9
|
||||
// included (the period TV's look, as the results screen's overscan rows).
|
||||
// The copies get the quad's own colour, alpha and blend.
|
||||
//
|
||||
// Two pictures the front end draws just short of the 16:9 frame's edges
|
||||
// get the screen's edges instead, on every screen shape: around the results
|
||||
// screen's backdrop the rows are cleared to black (PlanBackdrop), and the
|
||||
// Showcase's vignette is stretched to the screen's edges (PlanVignette).
|
||||
//
|
||||
// Units: the frame's clip space after the front-end matrix (w = 1), and
|
||||
// "band units" = clip / view scale (x / scaleX, y / scaleY): +-1 are the
|
||||
@@ -157,13 +170,16 @@ namespace gpu::bars
|
||||
}
|
||||
|
||||
// A black frame's thresholds, in band units. Logged on the Deck and at
|
||||
// 32:9: x +-0.970, y +-0.939, centred, FF000000 (the cut at ~95 s), and
|
||||
// x +-0.970, y +-0.920, FE000000 (the intro's start, ~57 s). The nearest
|
||||
// draws it must not take: the fades and dims (+-1.29: past the band,
|
||||
// the widen, stretch and cover rules' own), the title key art (x
|
||||
// +-1.104, y +-0.981, grey), the overlay at ~57 s (x +-1.104, B8 alpha),
|
||||
// the vid cam's noise and dark overlays (x +-0.70; 3C alpha), its dialog
|
||||
// boxes (x +-0.58), a full-band black layer (+-1.0, the cover rule's).
|
||||
// 32:9: x +-0.970, y +-0.939, centred, FF000000 (the cut at ~95 s), and x
|
||||
// +-0.970, y +-0.920, FE000000 (the intro's start, ~57 s); x +-0.976, y
|
||||
// +-0.985, FB000000 then FF000000 (the Blacklist bio's panel, its fade in
|
||||
// from 11000000 short of minAlpha: the border shows for those ~15 frames,
|
||||
// as before). The nearest draws it must not take: the fades and dims
|
||||
// (+-1.29: past the band, the widen, stretch and cover rules' own), the
|
||||
// title key art (x +-1.104, y +-0.981, grey), the overlay at ~57 s (x
|
||||
// +-1.104, B8 alpha), the vid cam's noise and dark overlays (x +-0.70; 3C
|
||||
// alpha), its dialog boxes (x +-0.58), a full-band black layer (+-1.0, the
|
||||
// cover rule's).
|
||||
struct BlackoutRule
|
||||
{
|
||||
float centreMax = 0.01f; // |the box's centre|, each axis
|
||||
@@ -286,32 +302,175 @@ namespace gpu::bars
|
||||
}
|
||||
|
||||
// What a black frame fills, inside the draw's scissor: everything
|
||||
// outside the band, the rows above and below it across the whole width
|
||||
// (Vert+) or the columns beside it down the whole height (Hor+; at most
|
||||
// one of the view scales is below 1). The far edges reach kFillEdge.
|
||||
inline uint32_t BlackoutRects(const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[2])
|
||||
// outside its box, on every screen shape (16:9 too): the rows above and
|
||||
// below it across the whole width, then the columns beside it within its
|
||||
// rows; up to 4 rectangles, which never overlap and meet the box on the
|
||||
// same pixel edges (Edge) as its own rasterised pixels. The far edges
|
||||
// reach kFillEdge. (v0.1.1 to v0.1.5 filled only past the band: the
|
||||
// thin border inside it showed what was behind, as a 360's own output
|
||||
// does and a period TV's overscan hid: GitHub #19's Blacklist bio.)
|
||||
inline uint32_t BlackoutRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, Rect out[4])
|
||||
{
|
||||
uint32_t n = 0;
|
||||
auto add = [&](const Rect& r) {
|
||||
const Rect c = Intersect(r, scissor);
|
||||
if (!Empty(c))
|
||||
auto add = [&](float cx0, float cx1, float cy0, float cy1) {
|
||||
if (cx1 <= cx0 || cy1 <= cy0)
|
||||
return;
|
||||
const Rect c = Intersect(ClipToHost(cx0, cx1, cy0, cy1, ps, hostW, hostH), scissor);
|
||||
if (!Empty(c) && n < 4)
|
||||
out[n++] = c;
|
||||
};
|
||||
const float e = kFillEdge;
|
||||
if (scaleY < 1.0f)
|
||||
add(-e, e, b.y1, e); // past its top in clip y (either way up on screen)
|
||||
add(-e, e, -e, b.y0); // past its bottom
|
||||
add(-e, b.x0, b.y0, b.y1); // left of it, within its rows
|
||||
add(b.x1, e, b.y0, b.y1); // right of it
|
||||
return n;
|
||||
}
|
||||
|
||||
// A full-band backdrop: the results screen's picture after a race (the
|
||||
// "WINNER" polaroid over asphalt and leaves; the title's and Controls'
|
||||
// key art have the same shape) is one opaque textured quad of the front
|
||||
// end, centred, x +-1.104 and y +-0.981 of the 16:9 band. On a 16:9
|
||||
// screen it hides the world but for a thin border at the top and bottom
|
||||
// (y 0.981 to 1: ~7 of 720 rows each), where the race behind it shows;
|
||||
// a real 360's own output has that border too (HDMI captures of the
|
||||
// results screen), and a TV's overscan hid it. Past the band (Hor+
|
||||
// beside it, Vert+ above and below it) the world rendered behind it
|
||||
// showed as well: the race's road and cars at the sides of the results
|
||||
// on an iPhone (2868x1320). The art's own pixels are all kept, past the
|
||||
// band too; everything else is filled black on every screen shape, 16:9
|
||||
// too: past the band (what no 16:9 screen shows, as a 16:9 picture's
|
||||
// letterbox) and, inside it, the rows between the picture's top or
|
||||
// bottom edge and the band's (what a period TV's overscan hid). The
|
||||
// title's and Controls' key art have black around them already, so their
|
||||
// fill changes nothing on screen. Thresholds in band units. The nearest
|
||||
// draws it must not take: the fades and dims
|
||||
// (+-1.29: flat, and the widen, stretch and cover rules' own), the pause
|
||||
// dim (C8 alpha) and its gradients, the vid cam's noise and dark overlays
|
||||
// (3C alpha, x +-0.70), the overlay at the intro's start (x +-1.104, B8
|
||||
// alpha), the black frames (the 32x32 white texture), a menu backdrop the
|
||||
// cover rule zooms under Vert+ (not left alone), the HUD's pieces (none
|
||||
// covers the band).
|
||||
struct BackdropRule
|
||||
{
|
||||
float centreMax = 0.01f; // |the box's centre|, each axis
|
||||
float extentMinX = 0.99f; // half its width: the whole band's (to 1%)
|
||||
float extentMinY = 0.95f; // half its height: nearly all of it
|
||||
float extentMax = 1.20f; // each half extent: short of the fades' +-1.29
|
||||
uint32_t minAlpha = 0xF0; // vertex alpha, all four
|
||||
uint32_t minTexels = 128; // t0 at least this wide and tall (not the 32x32 white texture)
|
||||
float cornerTolerance = 1e-3f;
|
||||
};
|
||||
|
||||
// A backdrop: one axis-aligned rectangle, every vertex's alpha at least
|
||||
// minAlpha, centred in the band and covering all of its width and nearly
|
||||
// all of its height, not reaching the fades' extent. Its box goes to
|
||||
// `box`. (The texture's size is a register check: BackdropCandidate.)
|
||||
inline bool MatchBackdrop(const Vertex v[4], float scaleX, float scaleY, const BackdropRule& rule = {}, Box* box = nullptr)
|
||||
{
|
||||
if (!(scaleX > 0.0f && scaleY > 0.0f))
|
||||
return false;
|
||||
for (int i = 0; i < 4; i++)
|
||||
if ((v[i].argb >> 24) < rule.minAlpha)
|
||||
return false;
|
||||
Box b;
|
||||
if (!RectangleBox(v, rule.cornerTolerance, b))
|
||||
return false;
|
||||
const float midX = (b.x0 + b.x1) * 0.5f / scaleX, midY = (b.y0 + b.y1) * 0.5f / scaleY;
|
||||
const float halfX = (b.x1 - b.x0) * 0.5f / scaleX, halfY = (b.y1 - b.y0) * 0.5f / scaleY;
|
||||
if (!(std::fabs(midX) <= rule.centreMax && std::fabs(midY) <= rule.centreMax))
|
||||
return false;
|
||||
if (!(halfX >= rule.extentMinX && halfX < rule.extentMax && halfY >= rule.extentMinY && halfY < rule.extentMax))
|
||||
return false;
|
||||
if (box)
|
||||
*box = b;
|
||||
return true;
|
||||
}
|
||||
|
||||
// What a backdrop fills past the band, inside the draw's scissor: what
|
||||
// lies past it (the columns beside it under Hor+, the rows above and
|
||||
// below it under Vert+; at most one of the view scales is below 1) and
|
||||
// outside the backdrop's own box, up to kFillEdge: per side, the part
|
||||
// beyond the box and, where the box reaches into that side, the parts
|
||||
// above and below it (Hor+; left and right of it under Vert+). At most
|
||||
// 6; none at 16:9.
|
||||
inline uint32_t PastBandRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[6])
|
||||
{
|
||||
uint32_t n = 0;
|
||||
auto add = [&](float cx0, float cx1, float cy0, float cy1) {
|
||||
if (cx1 <= cx0 || cy1 <= cy0)
|
||||
return;
|
||||
const Rect c = Intersect(ClipToHost(cx0, cx1, cy0, cy1, ps, hostW, hostH), scissor);
|
||||
if (!Empty(c) && n < 6)
|
||||
out[n++] = c;
|
||||
};
|
||||
const float e = kFillEdge;
|
||||
if (scaleX < 1.0f)
|
||||
{
|
||||
add(ClipToHost(-e, e, scaleY, e, ps, hostW, hostH));
|
||||
add(ClipToHost(-e, e, -e, -scaleY, ps, hostW, hostH));
|
||||
// Right of the band: [scaleX, e] x [-e, e]; left: [-e, -scaleX].
|
||||
const float in = std::min(std::max(b.x1, scaleX), e); // where the box ends in the right strip
|
||||
add(in, e, -e, e);
|
||||
add(scaleX, in, b.y1, e);
|
||||
add(scaleX, in, -e, b.y0);
|
||||
const float inL = std::max(std::min(b.x0, -scaleX), -e);
|
||||
add(-e, inL, -e, e);
|
||||
add(inL, -scaleX, b.y1, e);
|
||||
add(inL, -scaleX, -e, b.y0);
|
||||
}
|
||||
else if (scaleX < 1.0f)
|
||||
else if (scaleY < 1.0f)
|
||||
{
|
||||
add(ClipToHost(scaleX, e, -e, e, ps, hostW, hostH));
|
||||
add(ClipToHost(-e, -scaleX, -e, e, ps, hostW, hostH));
|
||||
const float in = std::min(std::max(b.y1, scaleY), e);
|
||||
add(-e, e, in, e);
|
||||
add(b.x1, e, scaleY, in);
|
||||
add(-e, b.x0, scaleY, in);
|
||||
const float inB = std::max(std::min(b.y0, -scaleY), -e);
|
||||
add(-e, e, -e, inB);
|
||||
add(b.x1, e, inB, -scaleY);
|
||||
add(-e, b.x0, inB, -scaleY);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// The overscan rows, inside the draw's scissor: inside the band, the
|
||||
// rows between the backdrop's top edge and the band's and between its
|
||||
// bottom edge and the band's (y +-0.981 to +-1 of the band for the
|
||||
// results picture), across the band's width. Where the band's edge is
|
||||
// the screen's (y at 16:9 and under Hor+, x at 16:9 and under Vert+)
|
||||
// they reach kFillEdge, as the fills past the band do (at 2x with
|
||||
// half-pixel centres the outermost row's centre lies past clip 1); where
|
||||
// it isn't, they stop at it and PastBandRects' rectangles go on from
|
||||
// there, meeting them on the same pixel edge (gpu::bars::Edge). None
|
||||
// where the box reaches the band's edge. At most 2.
|
||||
inline uint32_t OverscanRows(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[2])
|
||||
{
|
||||
uint32_t n = 0;
|
||||
auto add = [&](float cx0, float cx1, float cy0, float cy1) {
|
||||
if (cx1 <= cx0 || cy1 <= cy0)
|
||||
return;
|
||||
const Rect c = Intersect(ClipToHost(cx0, cx1, cy0, cy1, ps, hostW, hostH), scissor);
|
||||
if (!Empty(c) && n < 2)
|
||||
out[n++] = c;
|
||||
};
|
||||
const float e = kFillEdge;
|
||||
const float bandX = scaleX < 1.0f ? scaleX : e, bandY = scaleY < 1.0f ? scaleY : e;
|
||||
add(-bandX, bandX, b.y1, bandY);
|
||||
add(-bandX, bandX, -bandY, b.y0);
|
||||
return n;
|
||||
}
|
||||
|
||||
// What a backdrop fills, inside the draw's scissor: everything outside
|
||||
// its box but the columns inside the band beside it (none for a picture
|
||||
// as wide as the band): PastBandRects' and then OverscanRows'
|
||||
// rectangles, which never overlap. At most 8.
|
||||
inline uint32_t BackdropRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
|
||||
Rect out[8])
|
||||
{
|
||||
const uint32_t n = PastBandRects(b, ps, hostW, hostH, scissor, scaleX, scaleY, out);
|
||||
return n + OverscanRows(b, ps, hostW, hostH, scissor, scaleX, scaleY, out + n);
|
||||
}
|
||||
|
||||
// The posScale that stretches the quad's box over [-edge, edge] in clip
|
||||
// x and y (x' = ax x + bx, likewise y), folded in: NDC = x' ps0 + ps2 =
|
||||
// x (ax ps0) + (ps2 + bx ps0). Orientation is kept (ax, ay > 0) whatever
|
||||
@@ -344,23 +503,27 @@ namespace gpu::bars
|
||||
uint32_t blend0 = 0; // RB_BLENDCONTROL0 & 0x1FFF1FFF
|
||||
bool t0Sampled = false; // the pixel shader samples t0
|
||||
uint32_t t0Width = 0, t0Height = 0; // t0's size in texels
|
||||
uint32_t t0Format = 0; // t0's fetch constant format (6: k_8_8_8_8, 32-bit texels)
|
||||
float posScale[4] = {}; // the draw's own: NDC = clip * ps.xy + ps.zw
|
||||
float hostW = 0.0f, hostH = 0.0f; // the host viewport: the target's size times the render scale
|
||||
Rect scissor{}; // the draw's scissor (host pixels)
|
||||
};
|
||||
|
||||
enum class Fill : uint8_t { None, LeftBar, RightBar, Blackout };
|
||||
enum class Fill : uint8_t { None, LeftBar, RightBar, Blackout, Backdrop, Vignette };
|
||||
|
||||
// What to draw after the draw: `rectCount` more copies of it (none for a
|
||||
// bar that reaches the screen edge), with `posScale` and each rectangle
|
||||
// as the scissor.
|
||||
// What to draw after the draw: for a bar or a black frame, `rectCount`
|
||||
// more copies of it (none for a bar that reaches the screen edge), with
|
||||
// `posScale` and each rectangle as the scissor; for a backdrop, each
|
||||
// rectangle cleared to opaque black (its picture must not be copied);
|
||||
// for a vignette, nothing after it: the draw itself is drawn with
|
||||
// `posScale` instead of its own (no rectangles).
|
||||
struct Plan
|
||||
{
|
||||
Fill fill = Fill::None;
|
||||
Box box{}; // the quad's, clip space
|
||||
uint32_t argb = 0; // its colour
|
||||
uint32_t rectCount = 0;
|
||||
Rect rects[2]{};
|
||||
Rect rects[8]{}; // a bar's 2, a black frame's 4, a backdrop's 8
|
||||
float posScale[4]{};
|
||||
};
|
||||
|
||||
@@ -382,6 +545,191 @@ namespace gpu::bars
|
||||
d.t0Sampled && d.t0Width <= 32 && d.t0Height <= 32;
|
||||
}
|
||||
|
||||
// The register checks for a backdrop, before any vertex is read:
|
||||
// Candidate's, but over a large t0 (a picture, not the white texture),
|
||||
// at every screen shape (16:9 too: its overscan rows).
|
||||
inline bool BackdropCandidate(const Draw& d, const BackdropRule& rule = {})
|
||||
{
|
||||
const float* c = d.c;
|
||||
const bool yScale = d.frontEndY > 0.0f ? std::fabs(std::fabs(c[5]) - d.frontEndY) <= d.frontEndY * 1e-5f
|
||||
: d.frontEndY == 0.0f && d.scaleY >= 1.0f;
|
||||
return !d.occlusion && d.prim == 13 && d.count == 4 && d.frontEndShader && d.leftAlone &&
|
||||
c[3] == 0.0f && c[4] == 0.0f && c[6] == 0.0f && c[7] == 0.0f && yScale && (d.vte & 0xF) == 0xF &&
|
||||
(d.blend0 == 0x07060706u || d.blend0 == 0x00010001u) && d.t0Sampled && d.t0Width >= rule.minTexels && d.t0Height >= rule.minTexels;
|
||||
}
|
||||
|
||||
// The plan for a backdrop (PlanFill's for bars and black frames):
|
||||
// vertex 0's alpha first, then the rest.
|
||||
template <class ReadWord>
|
||||
Plan PlanBackdrop(const Draw& d, ReadWord&& read, const BackdropRule& rule = {})
|
||||
{
|
||||
Plan p;
|
||||
if (!BackdropCandidate(d, rule))
|
||||
return p;
|
||||
uint32_t colour = 0;
|
||||
if (!read(0u, 3u, colour) || (colour >> 24) < rule.minAlpha)
|
||||
return p;
|
||||
const float* c = d.c;
|
||||
Vertex v[4];
|
||||
for (uint32_t k = 0; k < 4; k++)
|
||||
{
|
||||
uint32_t w[4];
|
||||
for (uint32_t j = 0; j < 4; j++)
|
||||
if (!read(k, j, w[j]))
|
||||
return p;
|
||||
float q[3];
|
||||
std::memcpy(q, w, sizeof(q));
|
||||
v[k] = { q[0] * c[0] + q[1] * c[1] + q[2] * c[2] + c[3], q[0] * c[4] + q[1] * c[5] + q[2] * c[6] + c[7], w[3] };
|
||||
}
|
||||
Box box{};
|
||||
if (!MatchBackdrop(v, d.scaleX, d.scaleY, rule, &box))
|
||||
return p;
|
||||
p.fill = Fill::Backdrop;
|
||||
p.box = box;
|
||||
p.argb = v[0].argb;
|
||||
p.rectCount = BackdropRects(box, d.posScale, d.hostW, d.hostH, d.scissor, d.scaleX, d.scaleY, p.rects);
|
||||
return p;
|
||||
}
|
||||
|
||||
// The Showcase's vignette (Showcase.fng: the "Rotate / Zoom Out / Zoom In"
|
||||
// car viewer of the car lot, the customize shop and the Blacklist's rival
|
||||
// cars; GitHub #19): one front-end quad over the 3D garage, centred, x
|
||||
// +-0.970 and y +-0.958 of the 16:9 band (ten of the front end's 640x480
|
||||
// units in from each edge), its 256x256 k_8_8_8_8 texture black, clear in
|
||||
// the middle (every texel there 00000000) and darkening steeply toward its
|
||||
// edges (alpha 0.1-0.8 at the edge itself, 1.0 toward the corners), alpha-
|
||||
// blended (vertex colour 7F7F7F, its alpha fading in from 04 to FE). On a
|
||||
// 16:9 screen the garage shows undimmed in a thin border all round it; a
|
||||
// real 360's own output has that border too (an HDMI capture of the car
|
||||
// lot's Showcase: the same step at x +-0.970), and a period TV's overscan
|
||||
// (2.5-5% a side) hid it. Under Vert+ the garage rendered above and below
|
||||
// the band showed undimmed as well (7% of a MacBook's height at the top and
|
||||
// at the bottom, 55 rows of a Deck's 800): a bright rectangular frame round
|
||||
// the dimmed picture. The quad is stretched to the screen's edges (its box
|
||||
// to +-kFillEdge: CoverFrame), on every screen shape, 16:9 too, as a period
|
||||
// TV showed it (the results screen's overscan rows are filled the same way:
|
||||
// OverscanRows). Its middle is clear, so only its dark edges move out:
|
||||
// 3.1% in x and 4.4% in y at 16:9, y by 1/sv more under Vert+, x by 1/sx
|
||||
// more under Hor+. Thresholds in band units. The nearest draws it must not
|
||||
// take: the intro's black frames (x +-0.970, y +-0.939/0.920: opaque black
|
||||
// over the 32x32 white texture, and the black frame rule's), the title's
|
||||
// key art and the results picture (x +-1.104), the vid cam's noise and dark
|
||||
// overlays (x +-0.70), a full-band picture (+-1.0), an inset picture
|
||||
// (opaque in the middle: the texel check).
|
||||
struct VignetteRule
|
||||
{
|
||||
float centreMax = 0.01f; // |the box's centre|, each axis
|
||||
float extentMin = 0.90f, extentMax = 0.99f; // half its width and half its height
|
||||
uint32_t minTexels = 128; // t0 at least this wide and tall
|
||||
float cornerTolerance = 1e-3f; // clip units, as Rule's
|
||||
};
|
||||
|
||||
// A vignette's shape: one axis-aligned rectangle, one colour on all four
|
||||
// vertices (any alpha: it fades in), centred in the band and covering
|
||||
// most of it in x and y while stopping short of its edges. Its box goes
|
||||
// to `box`. (Its texture is the plan's check: PlanVignette.)
|
||||
inline bool MatchVignette(const Vertex v[4], float scaleX, float scaleY, const VignetteRule& rule = {}, Box* box = nullptr)
|
||||
{
|
||||
if (!(scaleX > 0.0f && scaleY > 0.0f))
|
||||
return false;
|
||||
for (int i = 1; i < 4; i++)
|
||||
if (v[i].argb != v[0].argb)
|
||||
return false;
|
||||
Box b;
|
||||
if (!RectangleBox(v, rule.cornerTolerance, b))
|
||||
return false;
|
||||
const float midX = (b.x0 + b.x1) * 0.5f / scaleX, midY = (b.y0 + b.y1) * 0.5f / scaleY;
|
||||
const float halfX = (b.x1 - b.x0) * 0.5f / scaleX, halfY = (b.y1 - b.y0) * 0.5f / scaleY;
|
||||
if (!(std::fabs(midX) <= rule.centreMax && std::fabs(midY) <= rule.centreMax))
|
||||
return false;
|
||||
if (!(halfX >= rule.extentMin && halfX <= rule.extentMax && halfY >= rule.extentMin && halfY <= rule.extentMax))
|
||||
return false;
|
||||
if (box)
|
||||
*box = b;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The byte offset of texel (x, y) in a 32-bit 2D texture: Xenos tiling
|
||||
// (gpu/renderer.cpp's GLSL tiled2D, pitch in texels, a multiple of 32)
|
||||
// or linear rows of `pitch` texels.
|
||||
inline uint32_t TexelOffset32(uint32_t x, uint32_t y, uint32_t pitch, bool tiled)
|
||||
{
|
||||
if (!tiled)
|
||||
return (y * pitch + x) * 4u;
|
||||
const uint32_t outer = (((y >> 5) * (pitch >> 5)) + (x >> 5)) << 6;
|
||||
const uint32_t inner = (((y >> 1) & 7u) << 3) | (x & 7u);
|
||||
const uint32_t oi = (outer | inner) << 2;
|
||||
const uint32_t bank = (y >> 4) & 1u;
|
||||
const uint32_t pipe = ((x >> 3) & 3u) ^ (((y >> 3) & 1u) << 1);
|
||||
return ((y & 1u) << 4) | (pipe << 6) | (bank << 11) | (oi & 0xFu) | (((oi >> 4) & 1u) << 5) | (((oi >> 5) & 7u) << 8) |
|
||||
((oi >> 8) << 12);
|
||||
}
|
||||
|
||||
// The texels a vignette's middle must hold clear (00000000, whatever the
|
||||
// endian): the centre and halfway from it to each edge. An inset
|
||||
// picture, opaque there, is no vignette.
|
||||
constexpr uint32_t kClearSamples = 5;
|
||||
inline void ClearSample(uint32_t i, uint32_t width, uint32_t height, uint32_t& x, uint32_t& y)
|
||||
{
|
||||
const uint32_t xs[kClearSamples] = { width / 2, width / 4, width * 3 / 4, width / 2, width / 2 };
|
||||
const uint32_t ys[kClearSamples] = { height / 2, height / 2, height / 2, height / 4, height * 3 / 4 };
|
||||
x = xs[i % kClearSamples];
|
||||
y = ys[i % kClearSamples];
|
||||
}
|
||||
|
||||
// The register checks for a vignette, before any vertex is read:
|
||||
// Candidate's, but alpha-blended (07060706: it is translucent), over a
|
||||
// large 32-bit t0 (format 6, k_8_8_8_8), at every screen shape.
|
||||
inline bool VignetteCandidate(const Draw& d, const VignetteRule& rule = {})
|
||||
{
|
||||
const float* c = d.c;
|
||||
const bool yScale = d.frontEndY > 0.0f ? std::fabs(std::fabs(c[5]) - d.frontEndY) <= d.frontEndY * 1e-5f
|
||||
: d.frontEndY == 0.0f && d.scaleY >= 1.0f;
|
||||
return !d.occlusion && d.prim == 13 && d.count == 4 && d.frontEndShader && d.leftAlone && c[3] == 0.0f && c[4] == 0.0f &&
|
||||
c[6] == 0.0f && c[7] == 0.0f && yScale && (d.vte & 0xF) == 0xF && d.blend0 == 0x07060706u && d.t0Sampled && d.t0Format == 6 &&
|
||||
d.t0Width >= rule.minTexels && d.t0Height >= rule.minTexels;
|
||||
}
|
||||
|
||||
// The plan for a vignette: the draw's posScale stretched so its box
|
||||
// reaches past every screen edge (CoverFrame), no rectangles. read: as
|
||||
// PlanFill's; texel(x, y, word): t0's 32-bit texel (x, y), false if it
|
||||
// can't be read. Vertices first (as PlanBackdrop: one quad over a large
|
||||
// t0 gets this far), then the texels of its middle.
|
||||
template <class ReadWord, class ReadTexel>
|
||||
Plan PlanVignette(const Draw& d, ReadWord&& read, ReadTexel&& texel, const VignetteRule& rule = {})
|
||||
{
|
||||
Plan p;
|
||||
if (!VignetteCandidate(d, rule))
|
||||
return p;
|
||||
const float* c = d.c;
|
||||
Vertex v[4];
|
||||
for (uint32_t k = 0; k < 4; k++)
|
||||
{
|
||||
uint32_t w[4];
|
||||
for (uint32_t j = 0; j < 4; j++)
|
||||
if (!read(k, j, w[j]))
|
||||
return p;
|
||||
float q[3];
|
||||
std::memcpy(q, w, sizeof(q));
|
||||
v[k] = { q[0] * c[0] + q[1] * c[1] + q[2] * c[2] + c[3], q[0] * c[4] + q[1] * c[5] + q[2] * c[6] + c[7], w[3] };
|
||||
}
|
||||
Box box{};
|
||||
if (!MatchVignette(v, d.scaleX, d.scaleY, rule, &box))
|
||||
return p;
|
||||
for (uint32_t i = 0; i < kClearSamples; i++)
|
||||
{
|
||||
uint32_t x, y, word = 1;
|
||||
ClearSample(i, d.t0Width, d.t0Height, x, y);
|
||||
if (!texel(x, y, word) || word != 0)
|
||||
return p;
|
||||
}
|
||||
p.fill = Fill::Vignette;
|
||||
p.box = box;
|
||||
p.argb = v[0].argb;
|
||||
CoverFrame(box, d.posScale, p.posScale);
|
||||
return p;
|
||||
}
|
||||
|
||||
// The plan for a draw. read(k, j, word): word j of the draw's vertex k
|
||||
// (k 0..3 in draw order, through its indices; an ePoly's words 0..3 are
|
||||
// x, y, z and the colour), false past the vertex buffer. Register checks
|
||||
@@ -417,7 +765,7 @@ namespace gpu::bars
|
||||
else if (MatchBlackout(v, d.scaleX, d.scaleY, {}, &box))
|
||||
{
|
||||
p.fill = Fill::Blackout;
|
||||
p.rectCount = BlackoutRects(d.posScale, d.hostW, d.hostH, d.scissor, d.scaleX, d.scaleY, p.rects);
|
||||
p.rectCount = BlackoutRects(box, d.posScale, d.hostW, d.hostH, d.scissor, p.rects);
|
||||
}
|
||||
else
|
||||
return p;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The pipeline cache file's header (GetCachePath()/pipelines.bin), checked
|
||||
// against this device before the file goes to the driver. Vulkan-free:
|
||||
// tests/pipeline_cache_header_test.cpp checks it alone; gpu/renderer.cpp
|
||||
// hands it the file's bytes and the device's VkPhysicalDeviceProperties.
|
||||
//
|
||||
// vkGetPipelineCacheData's data starts with VkPipelineCacheHeaderVersionOne,
|
||||
// 32 bytes, every field least significant byte first on any host:
|
||||
// 0 headerSize (32) 4 headerVersion (1) 8 vendorID 12 deviceID
|
||||
// 16 pipelineCacheUUID[16]
|
||||
// The spec puts it there so an application can tell when its saved data no
|
||||
// longer fits the device. A driver sets such data aside without a word
|
||||
// (MoltenVK and Mesa compare the whole header), so the cache starts empty
|
||||
// and every pipeline compiles again on first use: a brief stutter, once,
|
||||
// and the next save writes this device's header. The header has no
|
||||
// driverVersion field: drivers fold their version into pipelineCacheUUID
|
||||
// (MoltenVK its revision, Mesa its build ID, NVIDIA its driver version), so
|
||||
// a driver update shows up as a UUID change.
|
||||
//
|
||||
// MoltenVK's device ID on an Apple GPU encodes the OS version: OS major (8
|
||||
// bits), OS minor (8), Mac GPU family (8), Apple GPU family (8)
|
||||
// (MVKPhysicalDevice::getHighestGPUCapability; its UUID carries the same
|
||||
// word). An M1 Pro reads 0x1a050207 on macOS 26.5 and 0x1b000207 on 27.0,
|
||||
// so every macOS update discards the cache: on the 27.0.1 upgrade, one
|
||||
// 103 ms frame at the first world entry. On SteamOS each Mesa update does
|
||||
// the same through the UUID.
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace gpu::pipeline_cache
|
||||
{
|
||||
constexpr size_t kHeaderBytes = 32;
|
||||
constexpr uint32_t kHeaderVersionOne = 1; // VK_PIPELINE_CACHE_HEADER_VERSION_ONE
|
||||
constexpr uint32_t kAppleVendor = 0x106B; // MoltenVK on Apple GPUs (Intel and AMD Macs report PCI IDs)
|
||||
|
||||
// Who made a cache: a file's header, or this device's properties
|
||||
// (vendorID, deviceID, pipelineCacheUUID).
|
||||
struct Maker
|
||||
{
|
||||
uint32_t vendorID = 0, deviceID = 0;
|
||||
std::array<uint8_t, 16> uuid{};
|
||||
};
|
||||
|
||||
enum class Verdict : uint8_t
|
||||
{
|
||||
Empty, // no data: nothing to load
|
||||
Usable, // this GPU, driver and OS made it: the driver takes it
|
||||
OtherMaker, // another GPU, driver or OS version made it: the driver would set it aside
|
||||
Unreadable, // shorter than a header, or not a version-one header
|
||||
};
|
||||
|
||||
struct HeaderCheck
|
||||
{
|
||||
Verdict verdict = Verdict::Empty;
|
||||
size_t bytes = 0;
|
||||
uint32_t headerSize = 0, headerVersion = 0; // as the file says (0 when shorter than a header)
|
||||
Maker file; // Usable and OtherMaker: the file's header
|
||||
bool vendorDiffers = false, deviceDiffers = false, uuidDiffers = false;
|
||||
};
|
||||
|
||||
inline uint32_t ReadLE32(const uint8_t* p)
|
||||
{
|
||||
return uint32_t(p[0]) | uint32_t(p[1]) << 8 | uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24;
|
||||
}
|
||||
|
||||
inline HeaderCheck CheckHeader(const uint8_t* data, size_t bytes, const Maker& device)
|
||||
{
|
||||
HeaderCheck c;
|
||||
c.bytes = bytes;
|
||||
if (bytes == 0)
|
||||
return c;
|
||||
c.verdict = Verdict::Unreadable;
|
||||
if (bytes < kHeaderBytes)
|
||||
return c;
|
||||
c.headerSize = ReadLE32(data);
|
||||
c.headerVersion = ReadLE32(data + 4);
|
||||
// MoltenVK and Mesa take exactly this header (Mesa compares all 32
|
||||
// bytes, headerSize included): a longer or newer one is unreadable.
|
||||
if (c.headerSize != kHeaderBytes || c.headerVersion != kHeaderVersionOne)
|
||||
return c;
|
||||
c.file.vendorID = ReadLE32(data + 8);
|
||||
c.file.deviceID = ReadLE32(data + 12);
|
||||
std::memcpy(c.file.uuid.data(), data + 16, c.file.uuid.size());
|
||||
c.vendorDiffers = c.file.vendorID != device.vendorID;
|
||||
c.deviceDiffers = c.file.deviceID != device.deviceID;
|
||||
c.uuidDiffers = c.file.uuid != device.uuid;
|
||||
c.verdict = c.vendorDiffers || c.deviceDiffers || c.uuidDiffers ? Verdict::OtherMaker : Verdict::Usable;
|
||||
return c;
|
||||
}
|
||||
|
||||
// The UUID as 32 hex digits in byte order, as `xxd -p` shows the file
|
||||
// (MoltenVK's: its revision, the device ID's word, then flags). Not
|
||||
// 8-4-4-4-12, so a log line isn't taken for a device's identifier.
|
||||
inline std::string UuidText(const std::array<uint8_t, 16>& uuid)
|
||||
{
|
||||
std::string out;
|
||||
char hex[3];
|
||||
for (uint8_t byte : uuid)
|
||||
{
|
||||
std::snprintf(hex, sizeof(hex), "%02x", byte);
|
||||
out += hex;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// What differs, file -> device: "OS 26.5 -> 27.0, device ID 1a050207 ->
|
||||
// 1b000207, cache UUID ... -> ...". The OS part only for MoltenVK on an
|
||||
// Apple GPU, whose device ID carries it.
|
||||
inline std::string DescribeChange(const HeaderCheck& c, const Maker& device)
|
||||
{
|
||||
std::string out;
|
||||
char buf[96];
|
||||
auto add = [&](const std::string& part) { out += (out.empty() ? "" : ", ") + part; };
|
||||
if (c.vendorDiffers)
|
||||
{
|
||||
std::snprintf(buf, sizeof(buf), "vendor %04x -> %04x", c.file.vendorID, device.vendorID);
|
||||
add(buf);
|
||||
}
|
||||
if (c.deviceDiffers)
|
||||
{
|
||||
if (!c.vendorDiffers && device.vendorID == kAppleVendor && (c.file.deviceID >> 16) != (device.deviceID >> 16))
|
||||
{
|
||||
std::snprintf(buf, sizeof(buf), "OS %u.%u -> %u.%u", c.file.deviceID >> 24, (c.file.deviceID >> 16) & 0xFF,
|
||||
device.deviceID >> 24, (device.deviceID >> 16) & 0xFF);
|
||||
add(buf);
|
||||
}
|
||||
std::snprintf(buf, sizeof(buf), "device ID %08x -> %08x", c.file.deviceID, device.deviceID);
|
||||
add(buf);
|
||||
}
|
||||
if (c.uuidDiffers)
|
||||
add("cache UUID " + UuidText(c.file.uuid) + " -> " + UuidText(device.uuid));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The log's line for a file the driver would set aside, "" for Empty
|
||||
// and Usable.
|
||||
inline std::string LogLine(const HeaderCheck& c, const Maker& device)
|
||||
{
|
||||
constexpr const char* kRebuild = "rebuilding shaders, expect a brief stutter on first use";
|
||||
const size_t kb = c.bytes / 1024;
|
||||
char buf[160];
|
||||
switch (c.verdict)
|
||||
{
|
||||
case Verdict::OtherMaker:
|
||||
return "[renderer] pipeline cache from another GPU, driver or OS version: " + std::string(kRebuild) + " (" +
|
||||
DescribeChange(c, device) + "; " + std::to_string(kb) + " KB set aside)";
|
||||
case Verdict::Unreadable:
|
||||
if (c.bytes < kHeaderBytes)
|
||||
std::snprintf(buf, sizeof(buf), "%zu bytes, shorter than its header", c.bytes);
|
||||
else
|
||||
std::snprintf(buf, sizeof(buf), "header size %u, version %u; %zu KB set aside", c.headerSize, c.headerVersion, kb);
|
||||
return "[renderer] pipeline cache file not readable: " + std::string(kRebuild) + " (" + buf + ")";
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The internal resolution as a ratio: on each axis `num` host pixels for
|
||||
// every `den` guest pixels. 1x is 1/1, 2x 2/1, 3x 3/1, and 1.5x is 3/2:
|
||||
// 1280x720 becomes 1920x1080. Vulkan-free; gpu/renderer.cpp uses it for
|
||||
// every guest <-> host pixel mapping, and tests/render_scale_test.cpp checks
|
||||
// it alone.
|
||||
//
|
||||
// Whole scales map guest pixel g to host pixels [g s, g s + s). At 3/2 a
|
||||
// guest pixel is a pixel and a half, so the mapping works on blocks: every
|
||||
// two guest pixels (an even one and the odd one after it) are exactly three
|
||||
// host pixels. On each axis:
|
||||
// - Edges: guest edge g is host edge floor(g num / den) (HostEdge). A
|
||||
// guest rectangle's host one is its edges', so rectangles that share a
|
||||
// guest edge share the host edge, with no gap and no overlap: draw
|
||||
// scissors, clears, pass areas and what a resolve reads all use it. It is
|
||||
// also the rasteriser's own rule for a pixel-aligned quad (a host pixel
|
||||
// whose centre lies exactly on an odd guest edge belongs to the pixel
|
||||
// after it, the top-left rule), so a scissor and the quad it clips agree.
|
||||
// Even guest coordinates land exactly (g 3/2 is whole); the game's
|
||||
// resolves are 8-pixel aligned and its tile offsets even, so every
|
||||
// resolve, clear of a resolve and tile band maps exactly.
|
||||
// - Ownership: guest pixel g owns host pixels [HostEdge(g), HostEdge(g+1)):
|
||||
// at 3/2 an even pixel owns one, the odd one after it two. Each host
|
||||
// pixel has exactly one owner (sbOwner).
|
||||
// - The 1x average a resolve writes into guest memory weighs every host
|
||||
// pixel by how much of it lies inside the guest pixel (sbOverlap: a box
|
||||
// filter, 2/3 and 1/3 at 3/2), so the mean over any whole number of
|
||||
// blocks is the host mean, and an edge between two colours lands where
|
||||
// it was. Whole scales give every host pixel weight 1, as before.
|
||||
// - A depth resolve keeps one sample per guest pixel: the first at a whole
|
||||
// scale (as before), at 3/2 the host pixel holding the guest pixel's
|
||||
// centre (sbCenterHost).
|
||||
//
|
||||
// The scaled resolve memory (renderer.cpp's ScaledMemory) keeps each host
|
||||
// sample of a 32-bit tiled surface, found from the guest address of the texel
|
||||
// that owns it, so a resolve (the writer) and a texture or the front buffer
|
||||
// (the readers) meet whatever the pitch and base of each. In Xenos's 32-bit
|
||||
// tiling a texel's word in its 4 KB page has x & 1 in bit 0 and y & 1 in bit 2
|
||||
// (the other bits depend only on x >> 1 and y >> 1), so a 2x2 block of texels
|
||||
// at even (x, y) is the words w, w + 1, w + 4, w + 5 of one page, whatever the
|
||||
// pitch. A page's slot holds its blocks in order of their word with the
|
||||
// sub-texel bits taken out (sbBlockIndex), each block's num.x x num.y host
|
||||
// samples row by row. At 3/2: 256 blocks of 9 samples, 9 KB a 4 KB page; at
|
||||
// a whole scale a block is one texel and this is the layout from before
|
||||
// (word x s + j x sx + i).
|
||||
//
|
||||
// So 1.5x only covers surfaces whose texels come in whole blocks: a scaled
|
||||
// texture or front buffer needs an even width and height at 3/2 (Exact),
|
||||
// else it is used at 1x; a resolve needs an even origin, which the 8-pixel
|
||||
// alignment gives (else it isn't kept at host resolution, logged once).
|
||||
//
|
||||
// The block arithmetic is written once, in tokens that are both GLSL and
|
||||
// C++ (SB_SCALE_FUNCTIONS, as gpu/depth24_glsl.h does for its pack): the shaders
|
||||
// get it as text (GLSL_SCALE_CODE, spliced into renderer.cpp's resolve,
|
||||
// fill, untile and front-buffer shaders) and the test runs the same code.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
// uint arithmetic only (wrapping 32-bit in both languages); `1u` literals,
|
||||
// min/max of two uints, if, ?:. Every function's name starts "sb".
|
||||
#define SB_SCALE_FUNCTIONS \
|
||||
/* Host edge of guest edge g: floor(g num / den). */ \
|
||||
uint sbHostEdge(uint g, uint num, uint den) { return g * num / den; } \
|
||||
/* The host pixels covering guest pixels [0, g): ceil(g num / den), a target's or image's size. */ \
|
||||
uint sbHostSize(uint g, uint num, uint den) { return (g * num + den - 1u) / den; } \
|
||||
/* The guest edge whose host edge is h (h from sbHostEdge): ceil(h den / num). */ \
|
||||
uint sbGuestEdge(uint h, uint num, uint den) { return (h * den + num - 1u) / num; } \
|
||||
/* The guest pixel owning host pixel h: the g with HostEdge(g) <= h < HostEdge(g + 1). */ \
|
||||
uint sbOwner(uint h, uint num, uint den) { return ((h + 1u) * den + num - 1u) / num - 1u; } \
|
||||
/* How much of host pixel h lies in guest pixel g, in 1/den of a pixel (num for the whole guest pixel). */ \
|
||||
uint sbOverlap(uint g, uint h, uint num, uint den) \
|
||||
{ \
|
||||
uint lo = max(g * num, h * den); \
|
||||
uint hi = min((g + 1u) * num, (h + 1u) * den); \
|
||||
return hi > lo ? hi - lo : 0u; \
|
||||
} \
|
||||
/* The one host pixel a depth resolve reads for guest pixel g. */ \
|
||||
uint sbCenterHost(uint g, uint num, uint den) { return den == 1u ? g * num : ((2u * g + 1u) * num) / (2u * den); } \
|
||||
/* A word of a 4 KB page of 32-bit tiled texels (0..1023): its block (the sub-texel bits out). */ \
|
||||
uint sbBlockIndex(uint w, uint denX, uint denY) \
|
||||
{ \
|
||||
if (denY == 2u) \
|
||||
w = (w & 3u) | ((w >> 3u) << 2u); \
|
||||
if (denX == 2u) \
|
||||
w = w >> 1u; \
|
||||
return w; \
|
||||
} \
|
||||
/* A block's first word (its texel at even x and y). */ \
|
||||
uint sbBlockWord(uint b, uint denX, uint denY) \
|
||||
{ \
|
||||
if (denX == 2u) \
|
||||
b = b << 1u; \
|
||||
if (denY == 2u) \
|
||||
b = (b & 3u) | ((b >> 2u) << 3u); \
|
||||
return b; \
|
||||
} \
|
||||
/* A word's texel within its block: x & 1 (bit 0) and y & 1 (bit 2), at 3/2 only. */ \
|
||||
uint sbSubX(uint w, uint denX) { return denX == 2u ? (w & 1u) : 0u; } \
|
||||
uint sbSubY(uint w, uint denY) { return denY == 2u ? ((w >> 2u) & 1u) : 0u; } \
|
||||
/* A 4 KB page's samples: its blocks' x theirs. */ \
|
||||
uint sbSlotWords(uint numX, uint numY, uint denX, uint denY) { return 1024u / (denX * denY) * (numX * numY); } \
|
||||
/* Host sample (i, j) of word w's block, i < numX and j < numY from the block's corner: its word in the slot. */ \
|
||||
uint sbSampleWord(uint w, uint i, uint j, uint numX, uint numY, uint denX, uint denY) \
|
||||
{ \
|
||||
return sbBlockIndex(w, denX, denY) * (numX * numY) + j * numX + i; \
|
||||
}
|
||||
|
||||
#define SB_SCALE_STRINGIFY_(...) #__VA_ARGS__
|
||||
#define SB_SCALE_STRINGIFY(...) SB_SCALE_STRINGIFY_(__VA_ARGS__)
|
||||
// The functions as the shaders have them (comments are gone by now).
|
||||
#define GLSL_SCALE_CODE "\n// Guest <-> host pixels at the internal resolution (gpu/render_scale.h).\n" \
|
||||
SB_SCALE_STRINGIFY(SB_SCALE_FUNCTIONS) "\n"
|
||||
|
||||
namespace gpu
|
||||
{
|
||||
// SB_SCALE_FUNCTIONS as C++, the shaders' own tokens: member functions
|
||||
// (defined in the class, so inline), called on a temporary, ScaleGlsl{}.
|
||||
struct ScaleGlsl
|
||||
{
|
||||
using uint = uint32_t;
|
||||
static uint min(uint a, uint b) { return a < b ? a : b; }
|
||||
static uint max(uint a, uint b) { return a > b ? a : b; }
|
||||
SB_SCALE_FUNCTIONS
|
||||
};
|
||||
|
||||
// One axis: num host pixels for every den guest pixels (den 1 or 2).
|
||||
struct AxisScale
|
||||
{
|
||||
uint32_t num = 1, den = 1;
|
||||
|
||||
bool operator==(const AxisScale&) const = default;
|
||||
bool IsOne() const { return num == den; }
|
||||
float Value() const { return float(num) / float(den); }
|
||||
uint32_t Edge(uint32_t g) const { return ScaleGlsl{}.sbHostEdge(g, num, den); }
|
||||
uint32_t Size(uint32_t g) const { return ScaleGlsl{}.sbHostSize(g, num, den); }
|
||||
uint32_t GuestEdge(uint32_t h) const { return ScaleGlsl{}.sbGuestEdge(h, num, den); }
|
||||
// Guest length g covers whole host pixels (every block whole).
|
||||
bool Exact(uint32_t g) const { return g % den == 0; }
|
||||
// A guest coordinate clamped to 0 first (rectangles' signed edges).
|
||||
int32_t EdgeAt(int32_t g) const { return g <= 0 ? 0 : int32_t(Edge(uint32_t(g))); }
|
||||
};
|
||||
|
||||
struct RenderScale
|
||||
{
|
||||
AxisScale x, y;
|
||||
|
||||
bool operator==(const RenderScale&) const = default;
|
||||
bool IsOne() const { return x.IsOne() && y.IsOne(); }
|
||||
// Host samples a block of den.x x den.y guest texels has.
|
||||
uint32_t SamplesPerBlock() const { return x.num * y.num; }
|
||||
// A 4 KB guest page's host samples (words) in the scaled memory.
|
||||
uint32_t SlotWords() const { return ScaleGlsl{}.sbSlotWords(x.num, y.num, x.den, y.den); }
|
||||
// A width x height surface maps to whole host pixels and blocks.
|
||||
bool Exact(uint32_t width, uint32_t height) const { return x.Exact(width) && y.Exact(height); }
|
||||
// Host pixels per guest pixel as a ratio (the occlusion weight):
|
||||
// pixelsNum / pixelsDen (9/4 at 1.5x).
|
||||
uint32_t PixelsNum() const { return x.num * y.num; }
|
||||
uint32_t PixelsDen() const { return x.den * y.den; }
|
||||
// Host pixels a frame of guest w x h has (sizes the scratch buffer).
|
||||
uint64_t HostPixels(uint32_t w, uint32_t h) const { return uint64_t(x.Size(w)) * y.Size(h); }
|
||||
|
||||
static RenderScale Whole(uint32_t sx, uint32_t sy) { return { { sx, 1 }, { sy, 1 } }; }
|
||||
};
|
||||
|
||||
// An axis from a number of half steps (2: 1x, 3: 1.5x, 4: 2x, 6: 3x),
|
||||
// reduced (4/2 is 2/1).
|
||||
inline AxisScale AxisFromHalves(uint32_t halves)
|
||||
{
|
||||
halves = std::max(halves, 2u);
|
||||
return halves % 2 == 0 ? AxisScale{ halves / 2, 1 } : AxisScale{ halves, 2 };
|
||||
}
|
||||
|
||||
// "1", "1.5", "2" (the log's and settings' spelling).
|
||||
inline std::string AxisName(const AxisScale& a)
|
||||
{
|
||||
if (a.den == 1)
|
||||
return std::to_string(a.num);
|
||||
char text[16];
|
||||
snprintf(text, sizeof(text), "%g", double(a.num) / double(a.den));
|
||||
return text;
|
||||
}
|
||||
|
||||
// "1x1", "1.5x1.5", "3x2": as the log has always named the scale.
|
||||
inline std::string ScaleName(const RenderScale& s)
|
||||
{
|
||||
return AxisName(s.x) + "x" + AxisName(s.y);
|
||||
}
|
||||
|
||||
// The scales the renderer takes on an axis: 1, 1.5, 2, 3, 4 (the
|
||||
// whole ones of NFSMW_RENDER_SCALE before 1.5x, and 1.5x). Other
|
||||
// fractions are refused: false.
|
||||
inline bool AxisFromNumber(double v, AxisScale& out)
|
||||
{
|
||||
if (!(v >= 0.0)) // (NaN too)
|
||||
return false;
|
||||
v = std::max(v, 1.0); // (0 was 1x, as the old whole-number parse clamped it)
|
||||
const double halves = std::round(v * 2.0);
|
||||
if (std::fabs(v * 2.0 - halves) > 1e-6)
|
||||
return false;
|
||||
const uint32_t h = uint32_t(std::clamp(halves, 2.0, 8.0));
|
||||
if (h % 2 == 1 && h != 3)
|
||||
return false; // 2.5x, 3.5x: not offered
|
||||
out = AxisFromHalves(h);
|
||||
return true;
|
||||
}
|
||||
|
||||
// NFSMW_RENDER_SCALE: "<s>" or "<sx>x<sy>", each 1, 1.5, 2, 3 or 4
|
||||
// ("2", "1.5", "3x2", "1.5x1"); whole numbers above 4 are clamped to 4,
|
||||
// as before. False for anything else (the caller logs it).
|
||||
inline bool ParseRenderScale(const char* text, RenderScale& out)
|
||||
{
|
||||
if (!text || !*text)
|
||||
return false;
|
||||
char* end = nullptr;
|
||||
const double a = std::strtod(text, &end);
|
||||
if (end == text)
|
||||
return false;
|
||||
double b = a;
|
||||
if (*end == 'x' || *end == 'X')
|
||||
{
|
||||
const char* second = end + 1;
|
||||
b = std::strtod(second, &end);
|
||||
if (end == second)
|
||||
return false;
|
||||
}
|
||||
if (*end != '\0')
|
||||
return false;
|
||||
RenderScale s;
|
||||
if (!AxisFromNumber(a, s.x) || !AxisFromNumber(b, s.y))
|
||||
return false;
|
||||
out = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See input.h.
|
||||
#include <stdafx.h>
|
||||
#include "input.h"
|
||||
#include "key_map.h"
|
||||
#include <cpu/guest_time.h>
|
||||
#include <kernel/function.h>
|
||||
#include <ui/ui.h>
|
||||
#include <user/settings.h>
|
||||
#include <user/key_names.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
// key_map.h names XInput's bits as literals (it has no kernel header): keep
|
||||
// them the bits the game reads.
|
||||
static_assert(hid::keymap::button::DpadUp == XAMINPUT_GAMEPAD_DPAD_UP && hid::keymap::button::DpadDown == XAMINPUT_GAMEPAD_DPAD_DOWN &&
|
||||
hid::keymap::button::DpadLeft == XAMINPUT_GAMEPAD_DPAD_LEFT && hid::keymap::button::DpadRight == XAMINPUT_GAMEPAD_DPAD_RIGHT &&
|
||||
hid::keymap::button::Start == XAMINPUT_GAMEPAD_START && hid::keymap::button::Back == XAMINPUT_GAMEPAD_BACK &&
|
||||
hid::keymap::button::LeftThumb == XAMINPUT_GAMEPAD_LEFT_THUMB && hid::keymap::button::RightThumb == XAMINPUT_GAMEPAD_RIGHT_THUMB &&
|
||||
hid::keymap::button::LeftShoulder == XAMINPUT_GAMEPAD_LEFT_SHOULDER && hid::keymap::button::RightShoulder == XAMINPUT_GAMEPAD_RIGHT_SHOULDER &&
|
||||
hid::keymap::button::A == XAMINPUT_GAMEPAD_A && hid::keymap::button::B == XAMINPUT_GAMEPAD_B &&
|
||||
hid::keymap::button::X == XAMINPUT_GAMEPAD_X && hid::keymap::button::Y == XAMINPUT_GAMEPAD_Y,
|
||||
"key_map.h's button bits are XInput's");
|
||||
// The key codes of user/key_names.h are SDL's scancodes (USB HID usages);
|
||||
// tests/key_names_test.cpp checks every row, this pins a few.
|
||||
static_assert(settings::keynames::Code("a") == SDL_SCANCODE_A && settings::keynames::Code("keypad_1") == SDL_SCANCODE_KP_1 &&
|
||||
settings::keynames::Code("right_alt") == SDL_SCANCODE_RALT && settings::keynames::Code("escape") == SDL_SCANCODE_ESCAPE);
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uint32_t ERROR_SUCCESS = 0;
|
||||
@@ -608,32 +625,30 @@ namespace hid
|
||||
}
|
||||
const float rawLt = lt, rawRt = rt;
|
||||
|
||||
// Keyboard (digital; overrides the analog values it touches).
|
||||
const bool* k = SDL_GetKeyboardState(nullptr);
|
||||
if (k)
|
||||
// Keyboard (digital; replaces the controller's value for what it
|
||||
// touches): each control's key is Settings > Controls (the Key
|
||||
// options), resolved by key_map.h. A key is a scancode SDL's state
|
||||
// array is indexed by; an out-of-range value (a hand edit beat
|
||||
// Load's checks) counts as not held.
|
||||
if (const bool* k = SDL_GetKeyboardState(nullptr))
|
||||
{
|
||||
static const std::pair<SDL_Scancode, uint16_t> keys[] = {
|
||||
{ SDL_SCANCODE_UP, XAMINPUT_GAMEPAD_DPAD_UP }, { SDL_SCANCODE_DOWN, XAMINPUT_GAMEPAD_DPAD_DOWN },
|
||||
{ SDL_SCANCODE_LEFT, XAMINPUT_GAMEPAD_DPAD_LEFT }, { SDL_SCANCODE_RIGHT, XAMINPUT_GAMEPAD_DPAD_RIGHT },
|
||||
{ SDL_SCANCODE_SPACE, XAMINPUT_GAMEPAD_A }, { SDL_SCANCODE_BACKSPACE, XAMINPUT_GAMEPAD_B },
|
||||
{ SDL_SCANCODE_LSHIFT, XAMINPUT_GAMEPAD_X }, { SDL_SCANCODE_LCTRL, XAMINPUT_GAMEPAD_Y },
|
||||
{ SDL_SCANCODE_Q, XAMINPUT_GAMEPAD_LEFT_SHOULDER }, { SDL_SCANCODE_E, XAMINPUT_GAMEPAD_RIGHT_SHOULDER },
|
||||
{ SDL_SCANCODE_RETURN, XAMINPUT_GAMEPAD_START }, { SDL_SCANCODE_ESCAPE, XAMINPUT_GAMEPAD_BACK },
|
||||
{ SDL_SCANCODE_C, XAMINPUT_GAMEPAD_RIGHT_THUMB },
|
||||
bool pressed[keymap::kControls] = {};
|
||||
auto down = [&](settings::Id id) {
|
||||
int code = settings::GetInt(id); // 0: no key
|
||||
return code > 0 && code < SDL_SCANCODE_COUNT && k[code];
|
||||
};
|
||||
for (auto& [key, bit] : keys)
|
||||
if (k[key])
|
||||
p.buttons |= bit;
|
||||
float kx = float(k[SDL_SCANCODE_RIGHT] || k[SDL_SCANCODE_D]) - float(k[SDL_SCANCODE_LEFT] || k[SDL_SCANCODE_A]);
|
||||
float ky = float(k[SDL_SCANCODE_UP]) - float(k[SDL_SCANCODE_DOWN]);
|
||||
if (kx != 0.0f)
|
||||
lx = kx;
|
||||
if (ky != 0.0f)
|
||||
ly = ky;
|
||||
if (k[SDL_SCANCODE_W])
|
||||
for (size_t i = 0; i < keymap::kControls; i++)
|
||||
pressed[i] = down(keymap::kEntries[i].id) || down(keymap::kEntries[i].id2);
|
||||
keymap::Resolved r = keymap::Resolve(pressed);
|
||||
p.buttons |= r.buttons;
|
||||
if (r.rightTrigger)
|
||||
rt = 1.0f;
|
||||
if (k[SDL_SCANCODE_S])
|
||||
if (r.leftTrigger)
|
||||
lt = 1.0f;
|
||||
float* axes[size_t(keymap::Axis::Count)] = { &lx, &ly, &rx, &ry }; // Axis order: LX, LY, RX, RY
|
||||
for (size_t a = 0; a < size_t(keymap::Axis::Count); a++)
|
||||
if (r.axisSet[a])
|
||||
*axes[a] = r.axis[a];
|
||||
}
|
||||
p.lx = ToShort(lx);
|
||||
p.ly = ToShort(ly);
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
// sticks rest at exactly zero while small movements still ramp up
|
||||
// smoothly from the edge of the deadzone. Triggers get their own
|
||||
// (NFSMW_TRIGGER_DEADZONE, default 0.02).
|
||||
// - Keyboard: arrows = D-pad + left stick, W/S = gas/brake (RT/LT), A/D =
|
||||
// steer, Space = A, Backspace = B, Left Shift = X, Left Ctrl = Y,
|
||||
// Q/E = LB/RB, Enter = START, Esc = BACK, C = right stick click.
|
||||
// - Keyboard: each Xbox 360 control has two keys (Settings > Controls;
|
||||
// the [keys] table of settings.toml; hid/key_map.h lists them).
|
||||
// Defaults: arrows = D-pad, A/D and Left/Right = steer, Up/Down = left
|
||||
// stick too, W/S = gas/brake (RT/LT), Space = A, Backspace = B,
|
||||
// Left Shift = X, Left Ctrl = Y, Q/E = LB/RB, Enter = START, Esc = BACK,
|
||||
// O/L/K/; = right stick, Z/C = the stick clicks. A key may drive several.
|
||||
// - NFSMW_INPUT_SCRIPT="12:START,15.5:A,30-90/4:DOWN,100~160:RT": taps
|
||||
// (150 ms) at seconds since launch, taps every `step` seconds over a
|
||||
// range, or inputs held over a range; RT/LT (triggers) and LL/LR/LU/LD
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The keyboard as an Xbox 360 controller (Settings > Controls): the 24
|
||||
// controls a key can drive, in the page's order, each with the two settings
|
||||
// that hold its keys (user/settings.h, the [keys] table of settings.toml),
|
||||
// and how a set of held keys becomes buttons, triggers and stick axes.
|
||||
// Header only and SDL-free: hid/input.cpp samples the keys and feeds the
|
||||
// result to the pad; ui/settings_menu.cpp draws one row per entry;
|
||||
// tests/key_map_test.cpp checks the rules.
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include <user/settings.h>
|
||||
|
||||
namespace hid::keymap
|
||||
{
|
||||
enum class Control : uint8_t
|
||||
{
|
||||
RightTrigger, LeftTrigger,
|
||||
LeftStickLeft, LeftStickRight, LeftStickUp, LeftStickDown,
|
||||
A, B, X, Y, LB, RB,
|
||||
DpadUp, DpadDown, DpadLeft, DpadRight,
|
||||
Start, Back,
|
||||
RightStickUp, RightStickDown, RightStickLeft, RightStickRight,
|
||||
LeftStickClick, RightStickClick,
|
||||
Count
|
||||
};
|
||||
constexpr size_t kControls = size_t(Control::Count);
|
||||
|
||||
enum class Role : uint8_t { Button, LeftTrigger, RightTrigger, AxisNeg, AxisPos };
|
||||
enum class Axis : uint8_t { LX, LY, RX, RY, Count };
|
||||
|
||||
// XInput's button bits, as the game reads them (xbox.h's
|
||||
// XAMINPUT_GAMEPAD_*; input.cpp asserts they agree). Literals here so
|
||||
// this header needs no kernel header.
|
||||
namespace button
|
||||
{
|
||||
constexpr uint16_t DpadUp = 0x0001, DpadDown = 0x0002, DpadLeft = 0x0004, DpadRight = 0x0008;
|
||||
constexpr uint16_t Start = 0x0010, Back = 0x0020, LeftThumb = 0x0040, RightThumb = 0x0080;
|
||||
constexpr uint16_t LeftShoulder = 0x0100, RightShoulder = 0x0200;
|
||||
constexpr uint16_t A = 0x1000, B = 0x2000, X = 0x4000, Y = 0x8000;
|
||||
}
|
||||
|
||||
struct Entry
|
||||
{
|
||||
Control control;
|
||||
settings::Id id; // the option holding its first key
|
||||
settings::Id id2; // ...and its second (always id + 1)
|
||||
Role role;
|
||||
uint16_t button = 0; // Role::Button
|
||||
Axis axis = Axis::LX; // Role::AxisNeg, AxisPos
|
||||
};
|
||||
|
||||
constexpr Entry kEntries[kControls] = {
|
||||
{ Control::RightTrigger, settings::Id::KeyRightTrigger, settings::Id::KeyRightTrigger2, Role::RightTrigger },
|
||||
{ Control::LeftTrigger, settings::Id::KeyLeftTrigger, settings::Id::KeyLeftTrigger2, Role::LeftTrigger },
|
||||
{ Control::LeftStickLeft, settings::Id::KeyLeftStickLeft, settings::Id::KeyLeftStickLeft2, Role::AxisNeg, 0, Axis::LX },
|
||||
{ Control::LeftStickRight, settings::Id::KeyLeftStickRight, settings::Id::KeyLeftStickRight2, Role::AxisPos, 0, Axis::LX },
|
||||
{ Control::LeftStickUp, settings::Id::KeyLeftStickUp, settings::Id::KeyLeftStickUp2, Role::AxisPos, 0, Axis::LY },
|
||||
{ Control::LeftStickDown, settings::Id::KeyLeftStickDown, settings::Id::KeyLeftStickDown2, Role::AxisNeg, 0, Axis::LY },
|
||||
{ Control::A, settings::Id::KeyA, settings::Id::KeyA2, Role::Button, button::A },
|
||||
{ Control::B, settings::Id::KeyB, settings::Id::KeyB2, Role::Button, button::B },
|
||||
{ Control::X, settings::Id::KeyX, settings::Id::KeyX2, Role::Button, button::X },
|
||||
{ Control::Y, settings::Id::KeyY, settings::Id::KeyY2, Role::Button, button::Y },
|
||||
{ Control::LB, settings::Id::KeyLB, settings::Id::KeyLB2, Role::Button, button::LeftShoulder },
|
||||
{ Control::RB, settings::Id::KeyRB, settings::Id::KeyRB2, Role::Button, button::RightShoulder },
|
||||
{ Control::DpadUp, settings::Id::KeyDpadUp, settings::Id::KeyDpadUp2, Role::Button, button::DpadUp },
|
||||
{ Control::DpadDown, settings::Id::KeyDpadDown, settings::Id::KeyDpadDown2, Role::Button, button::DpadDown },
|
||||
{ Control::DpadLeft, settings::Id::KeyDpadLeft, settings::Id::KeyDpadLeft2, Role::Button, button::DpadLeft },
|
||||
{ Control::DpadRight, settings::Id::KeyDpadRight, settings::Id::KeyDpadRight2, Role::Button, button::DpadRight },
|
||||
{ Control::Start, settings::Id::KeyStart, settings::Id::KeyStart2, Role::Button, button::Start },
|
||||
{ Control::Back, settings::Id::KeyBack, settings::Id::KeyBack2, Role::Button, button::Back },
|
||||
{ Control::RightStickUp, settings::Id::KeyRightStickUp, settings::Id::KeyRightStickUp2, Role::AxisPos, 0, Axis::RY },
|
||||
{ Control::RightStickDown, settings::Id::KeyRightStickDown, settings::Id::KeyRightStickDown2, Role::AxisNeg, 0, Axis::RY },
|
||||
{ Control::RightStickLeft, settings::Id::KeyRightStickLeft, settings::Id::KeyRightStickLeft2, Role::AxisNeg, 0, Axis::RX },
|
||||
{ Control::RightStickRight, settings::Id::KeyRightStickRight, settings::Id::KeyRightStickRight2, Role::AxisPos, 0, Axis::RX },
|
||||
{ Control::LeftStickClick, settings::Id::KeyLeftStickClick, settings::Id::KeyLeftStickClick2, Role::Button, button::LeftThumb },
|
||||
{ Control::RightStickClick, settings::Id::KeyRightStickClick, settings::Id::KeyRightStickClick2, Role::Button, button::RightThumb },
|
||||
};
|
||||
|
||||
consteval bool EntriesInOrder()
|
||||
{
|
||||
for (size_t i = 0; i < kControls; i++)
|
||||
if (size_t(kEntries[i].control) != i || size_t(kEntries[i].id) != size_t(settings::Id::KeyRightTrigger) + 2 * i ||
|
||||
size_t(kEntries[i].id2) != size_t(kEntries[i].id) + 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
static_assert(EntriesInOrder(), "one Entry per Control, in Control order, over the Key options in pairs");
|
||||
|
||||
// The control either of a Key option's keys belongs to.
|
||||
constexpr const Entry& EntryOf(settings::Id id)
|
||||
{
|
||||
return kEntries[(size_t(id) - size_t(settings::Id::KeyRightTrigger)) / 2];
|
||||
}
|
||||
|
||||
// What the held keys amount to.
|
||||
struct Resolved
|
||||
{
|
||||
uint16_t buttons = 0;
|
||||
bool leftTrigger = false, rightTrigger = false; // a trigger key is down: that trigger at full
|
||||
bool axisSet[size_t(Axis::Count)] = {}; // either key of the axis is down: the keyboard has the axis
|
||||
float axis[size_t(Axis::Count)] = {}; // +1, 0 (both keys) or -1 while set; up is positive, as XInput has it
|
||||
};
|
||||
|
||||
// pressed[i]: either key bound to Control(i) is down. Buttons OR in; an axis
|
||||
// is its positive key minus its negative one, and belongs to the
|
||||
// keyboard while either is held (both held: centred).
|
||||
constexpr Resolved Resolve(std::span<const bool, kControls> pressed)
|
||||
{
|
||||
Resolved r;
|
||||
for (size_t i = 0; i < kControls; i++)
|
||||
{
|
||||
if (!pressed[i])
|
||||
continue;
|
||||
const Entry& e = kEntries[i];
|
||||
switch (e.role)
|
||||
{
|
||||
case Role::Button:
|
||||
r.buttons |= e.button;
|
||||
break;
|
||||
case Role::LeftTrigger:
|
||||
r.leftTrigger = true;
|
||||
break;
|
||||
case Role::RightTrigger:
|
||||
r.rightTrigger = true;
|
||||
break;
|
||||
case Role::AxisNeg:
|
||||
r.axisSet[size_t(e.axis)] = true;
|
||||
r.axis[size_t(e.axis)] -= 1.0f;
|
||||
break;
|
||||
case Role::AxisPos:
|
||||
r.axisSet[size_t(e.axis)] = true;
|
||||
r.axis[size_t(e.axis)] += 1.0f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
#include "installer.h"
|
||||
#include "locate.h"
|
||||
#include "placement.h"
|
||||
#include "portable_switch.h"
|
||||
|
||||
#include <user/paths.h>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
@@ -120,7 +123,8 @@ namespace install
|
||||
"usage: SpeedBreaker --install <image|folder> [--dest <dir>]\n"
|
||||
" SpeedBreaker --verify <image|folder>\n"
|
||||
" SpeedBreaker --find-images [folder...]\n"
|
||||
" SpeedBreaker --where\n", message);
|
||||
" SpeedBreaker --where\n"
|
||||
" SpeedBreaker --portable on|off\n", message);
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -211,6 +215,17 @@ namespace install
|
||||
return images.empty() ? 1 : 0;
|
||||
}
|
||||
|
||||
// Where settings, saves and caches are, and whether that's portable mode.
|
||||
void PrintDataFolders()
|
||||
{
|
||||
const portable::Layout& layout = portable::Current();
|
||||
if (layout.Active())
|
||||
printf("Portable mode (%s in %s): data in %s, caches in %s\n", portable::kMarker, layout.root.c_str(),
|
||||
GetUserPath().c_str(), GetCachePath().c_str());
|
||||
else
|
||||
printf("Data in %s, caches in %s\n", GetUserPath().c_str(), GetCachePath().c_str());
|
||||
}
|
||||
|
||||
int RunWhere()
|
||||
{
|
||||
std::optional<GameInstall> game = FindGameInstall();
|
||||
@@ -226,11 +241,46 @@ namespace install
|
||||
if (!notice.empty())
|
||||
printf("%s\n", notice.c_str());
|
||||
printf("No game install found. Install one with --install <image|folder> [--dest <dir>].\n");
|
||||
PrintDataFolders();
|
||||
return 1;
|
||||
}
|
||||
const char* origin = game->origin == InstallOrigin::Environment ? "NFSMW_GAME_DIR"
|
||||
: game->origin == InstallOrigin::Development ? "development checkout" : "installed";
|
||||
printf("%s (%s%s)\n", game->path.c_str(), origin, game->marker ? ", verified by the installer" : "");
|
||||
PrintDataFolders();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --portable on|off (install/portable_switch.h): the copy, with the
|
||||
// game's copy shown as --install shows it.
|
||||
int RunPortable(bool on)
|
||||
{
|
||||
if (!portable::kSupported)
|
||||
{
|
||||
fprintf(stderr, "Portable mode isn't available on macOS or iOS: SpeedBreaker keeps its data in the system's "
|
||||
"folder for apps there.\n");
|
||||
return 1;
|
||||
}
|
||||
InterruptGuard guard;
|
||||
GameCopier copier = [](const fs::path& from, const fs::path& to) -> Result
|
||||
{
|
||||
std::unique_ptr<DiscSource> source;
|
||||
if (Result r = OpenDiscSource(from, source); !r.Ok())
|
||||
return r;
|
||||
printf("Copying the game from %s (%s), checking every file:\n", from.c_str(),
|
||||
FormatSize(GameManifest().totalBytes).c_str());
|
||||
ProgressPrinter printer;
|
||||
Result r = Install(*source, to, std::ref(printer), &s_cancel);
|
||||
printer.Finish();
|
||||
return r;
|
||||
};
|
||||
std::vector<std::string> said;
|
||||
Result r = on ? MakePortable(BundleFolder(), HomeUserPath(), copier, said)
|
||||
: LeavePortable(portable::Current(), HomeUserPath(), copier, said);
|
||||
for (const std::string& line : said)
|
||||
printf("%s\n", line.c_str());
|
||||
if (!r.Ok())
|
||||
return Failed(r);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -269,6 +319,14 @@ namespace install
|
||||
return RunFindImages(std::vector<fs::path>(argv + i + 1, argv + argc));
|
||||
if (arg == "--where")
|
||||
return RunWhere();
|
||||
if (arg == "--portable")
|
||||
{
|
||||
std::string_view what = i + 1 < argc ? std::string_view(argv[i + 1]) : std::string_view();
|
||||
if ((what != "on" && what != "off") || i + 2 < argc)
|
||||
return Usage("--portable takes on (copy this computer's SpeedBreaker data and game into SpeedBreaker's "
|
||||
"folder, and keep them there from now on) or off (copy them back).");
|
||||
return RunPortable(what == "on");
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// SpeedBreaker --install <image|folder> [--dest <dir>] copy and verify (default dest: GetUserPath()/game)
|
||||
// SpeedBreaker --verify <image|folder> hash-check every file without copying
|
||||
// SpeedBreaker --find-images [folder...] list disc images in the usual places (or these)
|
||||
// SpeedBreaker --where print where the game install was found
|
||||
// SpeedBreaker --where print where the game install was found, and the data folders
|
||||
// SpeedBreaker --portable on|off copy the data and the game into SpeedBreaker's folder and keep
|
||||
// them there (portable mode), or back (install/portable_switch.h)
|
||||
// Ctrl-C cancels an install cleanly (the partial copy is removed).
|
||||
#pragma once
|
||||
#include <optional>
|
||||
|
||||
@@ -186,24 +186,41 @@ namespace install
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> RecordedInstallPath()
|
||||
std::optional<std::filesystem::path> ReadInstallRecord(const std::filesystem::path& file, const portable::Layout& layout)
|
||||
{
|
||||
FILE* f = fopen(LocationFile().c_str(), "rb");
|
||||
FILE* f = fopen(file.c_str(), "rb");
|
||||
if (!f)
|
||||
return std::nullopt;
|
||||
char buffer[4096];
|
||||
size_t n = fread(buffer, 1, sizeof(buffer) - 1, f);
|
||||
fclose(f);
|
||||
std::string text(buffer, n);
|
||||
while (!text.empty() && (text.back() == '\n' || text.back() == '\r'))
|
||||
text.pop_back();
|
||||
if (text.empty() || text.front() != '/')
|
||||
return std::nullopt;
|
||||
// No trailing separator (records before v0.1.1 kept `--dest dir/`'s).
|
||||
fs::path path = fs::path(text).lexically_normal();
|
||||
while (path.has_relative_path() && path.filename().empty())
|
||||
path = path.parent_path();
|
||||
return path;
|
||||
// Absolute, or (portable mode) relative to SpeedBreaker's folder; no
|
||||
// trailing separator (records before v0.1.1 kept `--dest dir/`'s).
|
||||
return portable::ParseRecord(std::string_view(buffer, n), layout);
|
||||
}
|
||||
|
||||
bool WriteInstallRecord(const std::filesystem::path& file, const std::string& text)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::create_directories(file.parent_path(), ec);
|
||||
fs::path temp = file;
|
||||
temp += ".tmp";
|
||||
FILE* f = fopen(temp.c_str(), "wb");
|
||||
if (!f)
|
||||
return false;
|
||||
std::string line = text + "\n";
|
||||
bool ok = fwrite(line.data(), 1, line.size(), f) == line.size();
|
||||
ok = fflush(f) == 0 && ok;
|
||||
fsync(fileno(f));
|
||||
fclose(f);
|
||||
if (ok)
|
||||
fs::rename(temp, file, ec);
|
||||
return ok && !ec;
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> RecordedInstallPath()
|
||||
{
|
||||
return ReadInstallRecord(LocationFile(), portable::Current());
|
||||
}
|
||||
|
||||
bool RecordInstallPath(const std::filesystem::path& dir)
|
||||
@@ -218,25 +235,22 @@ namespace install
|
||||
fs::remove(file, ec);
|
||||
return !ec;
|
||||
}
|
||||
fs::create_directories(file.parent_path(), ec);
|
||||
fs::path temp = file;
|
||||
temp += ".tmp";
|
||||
FILE* f = fopen(temp.c_str(), "wb");
|
||||
if (!f)
|
||||
return false;
|
||||
std::string text = folder.string() + "\n";
|
||||
bool ok = fwrite(text.data(), 1, text.size(), f) == text.size();
|
||||
ok = fflush(f) == 0 && ok;
|
||||
fsync(fileno(f));
|
||||
fclose(f);
|
||||
if (ok)
|
||||
fs::rename(temp, file, ec);
|
||||
return ok && !ec;
|
||||
// In portable mode, a folder inside SpeedBreaker's is recorded
|
||||
// relative to it, so the record still holds once the folder moves.
|
||||
return WriteInstallRecord(file, portable::RecordText(folder, portable::Current()));
|
||||
}
|
||||
|
||||
std::vector<InstallDestination> InstallDestinations()
|
||||
{
|
||||
std::vector<InstallDestination> out;
|
||||
// Portable mode: SpeedBreaker's own folder (its data/game), and no
|
||||
// drive's folder, which would leave the game behind when the folder
|
||||
// moves. Choose a folder... still takes any folder.
|
||||
if (portable::Current().Active())
|
||||
{
|
||||
out.push_back({ "SpeedBreaker's folder", DefaultInstallPath(), false });
|
||||
return out;
|
||||
}
|
||||
out.push_back({ "Internal storage", DefaultInstallPath(), false });
|
||||
auto addVolume = [&](const fs::path& volume)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "manifest.h"
|
||||
#include "result.h"
|
||||
|
||||
#include <user/portable.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
@@ -53,10 +55,18 @@ namespace install
|
||||
// Where the player chose to install when it wasn't DefaultInstallPath()
|
||||
// (an SD card, a USB drive): recorded in GetUserPath()/game-location by
|
||||
// the installer, and searched by FindGameInstall after NFSMW_GAME_DIR and
|
||||
// ./game/files. Recording the default path removes the record.
|
||||
// ./game/files. Recording the default path removes the record. In
|
||||
// portable mode a folder inside SpeedBreaker's own is recorded relative to
|
||||
// it (user/portable.h), so the record moves with the folder.
|
||||
std::optional<std::filesystem::path> RecordedInstallPath();
|
||||
bool RecordInstallPath(const std::filesystem::path& dir);
|
||||
|
||||
// The same for any record file, read as `layout` says (a relative record
|
||||
// only in portable mode), and a record's text written to one, atomically
|
||||
// (install/portable_switch.h, which moves records between data folders).
|
||||
std::optional<std::filesystem::path> ReadInstallRecord(const std::filesystem::path& file, const portable::Layout& layout);
|
||||
bool WriteInstallRecord(const std::filesystem::path& file, const std::string& text);
|
||||
|
||||
struct InstallDestination
|
||||
{
|
||||
std::string label; // "Internal storage", or the drive's name
|
||||
@@ -66,6 +76,7 @@ namespace install
|
||||
|
||||
// Where the installer offers to put the game: DefaultInstallPath() first,
|
||||
// then a folder on each mounted removable drive (<drive>/speedbreaker/game; SpeedBreaker on macOS).
|
||||
// In portable mode, only DefaultInstallPath() (in SpeedBreaker's folder).
|
||||
std::vector<InstallDestination> InstallDestinations();
|
||||
|
||||
struct DiscImageCandidate
|
||||
|
||||
@@ -118,6 +118,29 @@ namespace install
|
||||
strerror(error ? error : EACCES));
|
||||
}
|
||||
|
||||
// Portable mode: the parts of SpeedBreaker's folder (`root`) the game
|
||||
// can't go in, and why ("" for the rest of it). `named`: the folder
|
||||
// as the player named it.
|
||||
std::string PortableReserved(const fs::path& gameDir, const fs::path& named, const fs::path& root)
|
||||
{
|
||||
if (gameDir == root)
|
||||
return std::format("{} is SpeedBreaker's own folder, which holds the program. Choose a folder inside it, "
|
||||
"such as {} (where the game goes by default), or another folder.", root.string(),
|
||||
(root / portable::kDataFolder / "game").string());
|
||||
const fs::path relative = gameDir.lexically_relative(root);
|
||||
const fs::path first = *relative.begin();
|
||||
if (first == "lib" || first == "licenses")
|
||||
return std::format("{} is inside SpeedBreaker's {} folder, which holds the program's own files: an update "
|
||||
"overwrites them. Choose another folder.", named.string(), first.string());
|
||||
if (first == portable::kCacheFolder)
|
||||
return std::format("{} is inside SpeedBreaker's cache folder, which can be deleted at any time. Choose "
|
||||
"another folder.", named.string());
|
||||
if (relative == portable::kDataFolder)
|
||||
return std::format("{} holds your settings and saves. Choose a folder inside it, such as {} (where the game "
|
||||
"goes by default), or another folder.", gameDir.string(), (gameDir / "game").string());
|
||||
return {};
|
||||
}
|
||||
|
||||
// The refusals every game folder gets, picked or exact. Ok if none.
|
||||
// `picked` is the folder the player chose (gameDir, or the folder
|
||||
// gameDir would be made in): the messages name it, not a subfolder
|
||||
@@ -130,7 +153,15 @@ namespace install
|
||||
if (!context.home.empty() && gameDir == Normal(context.home))
|
||||
return Refusal(Error::DestinationNotWritable, std::format("{} is your home folder itself. Choose a folder in "
|
||||
"it instead.", gameDir.string()), gameDir);
|
||||
if (fs::path bundle = Normal(context.bundle); !bundle.empty() && IsAtOrInside(gameDir, bundle))
|
||||
// Portable mode: SpeedBreaker's folder is where the player keeps
|
||||
// the game (data/game by default), except its own parts.
|
||||
if (fs::path root = Normal(context.portable); !root.empty() && IsAtOrInside(gameDir, root))
|
||||
{
|
||||
const fs::path& named = IsAtOrInside(picked, root) ? picked : gameDir;
|
||||
if (std::string message = PortableReserved(gameDir, named, root); !message.empty())
|
||||
return Refusal(Error::DestinationNotWritable, std::move(message), gameDir);
|
||||
}
|
||||
else if (fs::path bundle = Normal(context.bundle); !bundle.empty() && IsAtOrInside(gameDir, bundle))
|
||||
{
|
||||
const fs::path& named = IsAtOrInside(picked, bundle) ? picked : gameDir;
|
||||
std::string message;
|
||||
@@ -170,6 +201,9 @@ namespace install
|
||||
Result FileSystemRules(const fs::path& gameDir, const fs::path& picked, const FolderFacts& facts,
|
||||
const PlacementContext& context, std::vector<std::string>& notes)
|
||||
{
|
||||
if (fs::path root = Normal(context.portable); !root.empty() && !IsAtOrInside(gameDir, root))
|
||||
notes.push_back(std::format("This folder is outside SpeedBreaker's folder ({}): in portable mode, the game "
|
||||
"there won't move with it.", root.string()));
|
||||
if (context.macOS)
|
||||
{
|
||||
// iCloud Drive (and Desktop and Documents, when the Mac keeps
|
||||
@@ -746,6 +780,8 @@ namespace install
|
||||
context.home = Resolved(home);
|
||||
if (fs::path bundle = BundleFolder(); !bundle.empty())
|
||||
context.bundle = Resolved(bundle);
|
||||
if (const portable::Layout& layout = portable::Current(); layout.Active())
|
||||
context.portable = Resolved(layout.root);
|
||||
context.installs.push_back(Resolved(DefaultInstallPath()));
|
||||
if (std::optional<fs::path> recorded = RecordedInstallPath())
|
||||
context.installs.push_back(Resolved(*recorded));
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
// or an uninstall replaces it, and on the Mac the app's signature would
|
||||
// break) or anything inside it, or a folder inside an install (the
|
||||
// default one or the one recorded last time).
|
||||
// - Portable mode (user/portable.h): SpeedBreaker's own folder is where
|
||||
// the game goes (data/game by default), so inside it is fine, except the
|
||||
// folder's top itself (the program's), lib/ and licenses/ (the
|
||||
// download's own files, overwritten by an update), data/ itself
|
||||
// (settings and saves) and cache/ (deleted at will). A folder outside it
|
||||
// gets a note: the game there won't move with the folder.
|
||||
// - Never into an existing folder SpeedBreaker can't write: Install
|
||||
// replaces it or moves it aside, which macOS refuses for a folder its
|
||||
// owner can't write, after the whole copy.
|
||||
@@ -120,6 +126,7 @@ namespace install
|
||||
{
|
||||
std::filesystem::path home; // the home folder (empty: unknown)
|
||||
std::filesystem::path bundle; // SpeedBreaker's own folder (empty: unknown)
|
||||
std::filesystem::path portable; // the same in portable mode (user/portable.h); empty otherwise
|
||||
std::vector<std::filesystem::path> installs; // installs nothing may go inside: the default and the recorded one
|
||||
uint64_t largestFile = 0; // the manifest's biggest file
|
||||
bool macOS = false;
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See portable_switch.h.
|
||||
#include "portable_switch.h"
|
||||
#include "installer.h"
|
||||
#include "locate.h"
|
||||
#include "placement.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <format>
|
||||
#include <optional>
|
||||
#include <system_error>
|
||||
|
||||
namespace install
|
||||
{
|
||||
namespace
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
Result Fail(Error error, std::string message, const fs::path& path = {})
|
||||
{
|
||||
Result r;
|
||||
r.error = error;
|
||||
r.message = std::move(message);
|
||||
r.file = path.string();
|
||||
return r;
|
||||
}
|
||||
|
||||
// Not there, or a folder with nothing in it (hidden files count).
|
||||
bool EmptyOrMissing(const fs::path& dir)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::file_status status = fs::symlink_status(dir, ec);
|
||||
if (status.type() == fs::file_type::not_found)
|
||||
return true;
|
||||
if (ec || !fs::is_directory(status))
|
||||
return false;
|
||||
fs::directory_iterator it(dir, ec);
|
||||
return !ec && it == fs::directory_iterator();
|
||||
}
|
||||
|
||||
// An unfinished copy from an earlier attempt goes (only one carrying
|
||||
// the tag: anything else of that name is the player's).
|
||||
Result ClearPartial(const fs::path& partial, std::vector<std::string>& said)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::file_status status = fs::symlink_status(partial, ec);
|
||||
if (status.type() == fs::file_type::not_found)
|
||||
return {};
|
||||
std::error_code tagEc;
|
||||
if (!fs::is_directory(status) || !fs::is_regular_file(fs::symlink_status(partial / kPortablePartialTag, tagEc)))
|
||||
return Fail(Error::DestinationNotEmpty, std::format("{} is in the way, and it isn't an unfinished copy of "
|
||||
"SpeedBreaker's. Move it or delete it, then try again.", partial.string()), partial);
|
||||
fs::remove_all(partial, ec);
|
||||
if (ec)
|
||||
return Fail(Error::WriteError, std::format("Couldn't delete {}, an unfinished copy from an earlier attempt ({}). "
|
||||
"Delete it, then try again.", partial.string(), ec.message()), partial);
|
||||
said.push_back(std::format("Deleted {}, an unfinished copy from an earlier attempt.", partial.string()));
|
||||
return {};
|
||||
}
|
||||
|
||||
Result StartPartial(const fs::path& partial)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::create_directories(partial.parent_path(), ec);
|
||||
fs::create_directory(partial, ec);
|
||||
if (!ec)
|
||||
{
|
||||
FILE* f = fopen((partial / kPortablePartialTag).string().c_str(), "wb");
|
||||
if (f && fputs("An unfinished copy of SpeedBreaker's data (speedbreaker.sh --portable).\n", f) >= 0 && fclose(f) == 0)
|
||||
return {};
|
||||
if (f)
|
||||
fclose(f);
|
||||
ec = std::error_code(errno ? errno : EIO, std::generic_category());
|
||||
}
|
||||
return Fail(Error::DestinationNotWritable, std::format("SpeedBreaker can't write to {} ({}): nothing was copied.",
|
||||
partial.parent_path().string(), ec.message()), partial);
|
||||
}
|
||||
|
||||
// The installed game a data folder's record and default point to, as
|
||||
// FindGameInstall picks it: the recorded one if it's usable, else the
|
||||
// default one.
|
||||
struct FoundGame
|
||||
{
|
||||
fs::path dir; // the game to copy (empty: none)
|
||||
std::optional<fs::path> lost; // a recorded install that isn't usable (and no default one is)
|
||||
std::vector<fs::path> skipped; // left out of the data copy: the game folders and their in-between folders
|
||||
};
|
||||
|
||||
FoundGame FindGame(const fs::path& data, const portable::Layout& layout, const Manifest& manifest)
|
||||
{
|
||||
FoundGame found;
|
||||
fs::path fallback = data / "game";
|
||||
std::optional<fs::path> recorded = ReadInstallRecord(data / "game-location", layout);
|
||||
if (recorded && IsUsableInstall(*recorded, true, manifest))
|
||||
found.dir = *recorded;
|
||||
else if (IsUsableInstall(fallback, true, manifest))
|
||||
found.dir = fallback;
|
||||
else if (recorded)
|
||||
found.lost = recorded;
|
||||
for (const std::optional<fs::path>& game : { std::optional<fs::path>(fallback), recorded })
|
||||
if (game)
|
||||
{
|
||||
fs::path dir = ResolvedFolder(*game);
|
||||
found.skipped.push_back(dir);
|
||||
found.skipped.push_back(fs::path(dir.string() + ".partial"));
|
||||
found.skipped.push_back(fs::path(dir.string() + ".previous"));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
struct CopyStats
|
||||
{
|
||||
uintmax_t files = 0, bytes = 0;
|
||||
std::vector<std::string> skipped; // links and special files, not copied
|
||||
};
|
||||
|
||||
// `from`'s contents into the folder `to` (made by the caller): folders
|
||||
// and files, with their modification times (the bug report and the log
|
||||
// rotation sort by them). Left out: the game's record at the top, and
|
||||
// the folders in `skip` (the game and its in-between folders).
|
||||
Result CopyData(const fs::path& from, const fs::path& to, const std::vector<fs::path>& skip, CopyStats& stats, bool top)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::directory_iterator it(from, ec);
|
||||
for (fs::directory_iterator end; !ec && it != end; it.increment(ec))
|
||||
{
|
||||
const fs::path source = it->path();
|
||||
const fs::path name = source.filename();
|
||||
if (top && (name == "game-location" || name == "game-location.tmp" || name == ".write-check"))
|
||||
continue;
|
||||
if (std::any_of(skip.begin(), skip.end(), [&](const fs::path& s) { return IsAtOrInside(source, s); }))
|
||||
continue;
|
||||
std::error_code typeEc;
|
||||
fs::file_status status = it->symlink_status(typeEc);
|
||||
const fs::path target = to / name;
|
||||
if (fs::is_symlink(status) || (!fs::is_directory(status) && !fs::is_regular_file(status)))
|
||||
{
|
||||
stats.skipped.push_back(source.string());
|
||||
continue;
|
||||
}
|
||||
std::error_code copyEc;
|
||||
if (fs::is_directory(status))
|
||||
{
|
||||
fs::create_directory(target, copyEc);
|
||||
if (copyEc)
|
||||
return Fail(Error::WriteError, std::format("Couldn't make {} ({}): nothing was changed.", target.string(),
|
||||
copyEc.message()), target);
|
||||
if (Result r = CopyData(source, target, skip, stats, false); !r.Ok())
|
||||
return r;
|
||||
}
|
||||
else
|
||||
{
|
||||
fs::copy_file(source, target, fs::copy_options::none, copyEc);
|
||||
if (copyEc)
|
||||
return Fail(Error::WriteError, std::format("Couldn't copy {} to {} ({}): nothing was changed.",
|
||||
source.string(), target.string(), copyEc.message()), source);
|
||||
stats.files++;
|
||||
stats.bytes += fs::file_size(source, typeEc);
|
||||
}
|
||||
std::error_code timeEc;
|
||||
fs::last_write_time(target, fs::last_write_time(source, timeEc), timeEc);
|
||||
}
|
||||
if (ec)
|
||||
return Fail(Error::ReadError, std::format("Couldn't read {} ({}): nothing was changed.", from.string(), ec.message()),
|
||||
from);
|
||||
return {};
|
||||
}
|
||||
|
||||
void SayCopied(const CopyStats& stats, const fs::path& from, const fs::path& to, std::vector<std::string>& said)
|
||||
{
|
||||
if (stats.files == 0)
|
||||
said.push_back(std::format("{} holds no settings, saves or logs: nothing of them to copy.", from.string()));
|
||||
else
|
||||
said.push_back(std::format("Copied settings, saves and logs ({} files, {}) from {} to {}.", stats.files,
|
||||
FormatSize(stats.bytes), from.string(), to.string()));
|
||||
for (const std::string& path : stats.skipped)
|
||||
said.push_back(std::format("Not copied (a link or a special file): {}", path));
|
||||
}
|
||||
|
||||
// A name for the old folder that isn't taken: <folder>.before-portable-off-<UTC date>-<time>[-N].
|
||||
fs::path AsideName(const fs::path& folder)
|
||||
{
|
||||
std::string stamp = std::format("{:%Y%m%d-%H%M%S}",
|
||||
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now()));
|
||||
fs::path aside = folder.string() + ".before-portable-off-" + stamp;
|
||||
std::error_code ec;
|
||||
for (int n = 2; fs::exists(fs::symlink_status(aside, ec)); n++)
|
||||
aside = folder.string() + ".before-portable-off-" + stamp + "-" + std::to_string(n);
|
||||
return aside;
|
||||
}
|
||||
|
||||
bool WriteMarker(const fs::path& file)
|
||||
{
|
||||
fs::path temp = file;
|
||||
temp += ".tmp";
|
||||
FILE* f = fopen(temp.string().c_str(), "wb");
|
||||
if (!f)
|
||||
return false;
|
||||
bool ok = fputs("SpeedBreaker portable mode: with this file here, SpeedBreaker keeps its settings, saves, logs and "
|
||||
"caches in this folder (data/ and cache/) and installs the game into data/game. Delete this file to use this "
|
||||
"computer's own data folder again (README.md, \"Portable mode\").\n", f) >= 0;
|
||||
ok = fclose(f) == 0 && ok;
|
||||
std::error_code ec;
|
||||
if (ok)
|
||||
fs::rename(temp, file, ec);
|
||||
else
|
||||
fs::remove(temp, ec);
|
||||
return ok && !ec;
|
||||
}
|
||||
}
|
||||
|
||||
Result MakePortable(const std::filesystem::path& root, const std::filesystem::path& home, const GameCopier& copyGame,
|
||||
std::vector<std::string>& said, const Manifest& manifest)
|
||||
{
|
||||
if (root.empty() || !root.is_absolute())
|
||||
return Fail(Error::NotFound, "SpeedBreaker can't tell which folder it runs from, so it can't make that folder "
|
||||
"portable. Start it with the speedbreaker.sh in that folder.");
|
||||
const fs::path folder = ResolvedFolder(root);
|
||||
const fs::path data = folder / portable::kDataFolder, marker = folder / portable::kMarker;
|
||||
std::error_code ec;
|
||||
if (fs::exists(fs::symlink_status(marker, ec)))
|
||||
return Fail(Error::DestinationNotEmpty, std::format("Portable mode is already on ({} is there): nothing was copied.",
|
||||
marker.string()), marker);
|
||||
if (!EmptyOrMissing(data))
|
||||
return Fail(Error::DestinationNotEmpty, std::format("{} already holds files (from an earlier portable setup?), so "
|
||||
"nothing was copied. To use them as they are, create {} yourself. To copy this computer's data instead, move "
|
||||
"{} somewhere else first.", data.string(), marker.string(), data.string()), data);
|
||||
|
||||
const fs::path source = ResolvedFolder(home);
|
||||
FoundGame game = FindGame(source, portable::Layout{}, manifest);
|
||||
if (game.dir.empty() && game.lost)
|
||||
return Fail(Error::NotFound, std::format("The game is installed in {}, which isn't there now or isn't a current "
|
||||
"install (a drive not connected?), so nothing was copied. Connect it and try again. If it's gone for good, "
|
||||
"delete {} and try again: SpeedBreaker then installs the game into {} at its first start.",
|
||||
game.lost->string(), (source / "game-location").string(), (data / "game").string()), *game.lost);
|
||||
|
||||
const fs::path partial = folder / (std::string(portable::kDataFolder) + ".partial");
|
||||
if (Result r = ClearPartial(partial, said); !r.Ok())
|
||||
return r;
|
||||
if (Result r = StartPartial(partial); !r.Ok())
|
||||
return r;
|
||||
auto abandon = [&](Result r) {
|
||||
std::error_code removeEc;
|
||||
fs::remove_all(partial, removeEc);
|
||||
return r;
|
||||
};
|
||||
|
||||
CopyStats stats;
|
||||
if (fs::is_directory(source, ec))
|
||||
if (Result r = CopyData(source, partial, game.skipped, stats, true); !r.Ok())
|
||||
return abandon(r);
|
||||
if (!game.dir.empty())
|
||||
if (Result r = copyGame(game.dir, partial / "game"); !r.Ok())
|
||||
return abandon(r);
|
||||
|
||||
fs::remove(partial / kPortablePartialTag, ec);
|
||||
fs::remove(data, ec); // an empty one (EmptyOrMissing): replaced
|
||||
ec.clear();
|
||||
fs::rename(partial, data, ec);
|
||||
if (ec)
|
||||
return abandon(Fail(Error::WriteError, std::format("Couldn't rename {} to {} ({}): nothing was changed.",
|
||||
partial.string(), data.string(), ec.message()), data));
|
||||
if (!WriteMarker(marker))
|
||||
return Fail(Error::WriteError, std::format("The data was copied to {}, but {} couldn't be written. Create it "
|
||||
"yourself (an empty file will do) to turn portable mode on.", data.string(), marker.string()), marker);
|
||||
|
||||
SayCopied(stats, source, data, said);
|
||||
if (!game.dir.empty())
|
||||
said.push_back(std::format("Copied the game to {} and checked every file.", (data / "game").string()));
|
||||
else
|
||||
said.push_back(std::format("No installed game was found, so none was copied: SpeedBreaker installs it into {} "
|
||||
"at its first start.", (data / "game").string()));
|
||||
said.push_back(std::format("Portable mode is on ({}): SpeedBreaker keeps everything in {} now. Move or copy that "
|
||||
"whole folder to take it all along.", marker.string(), folder.string()));
|
||||
if (stats.files || !game.dir.empty())
|
||||
said.push_back(std::format("Nothing was removed: the old copies are still in {}{}. Delete them once you're happy "
|
||||
"with the portable folder, or keep them as a backup.", source.string(),
|
||||
game.dir.empty() || IsAtOrInside(ResolvedFolder(game.dir), source) ? ""
|
||||
: std::format(" (and the game in {})", game.dir.string())));
|
||||
return {};
|
||||
}
|
||||
|
||||
Result LeavePortable(const portable::Layout& layout, const std::filesystem::path& home, const GameCopier& copyGame,
|
||||
std::vector<std::string>& said, const Manifest& manifest)
|
||||
{
|
||||
if (!layout.Active())
|
||||
return Fail(Error::NotFound, std::format("Portable mode isn't on (there's no {} in SpeedBreaker's folder): nothing "
|
||||
"to do.", portable::kMarker));
|
||||
const fs::path target = ResolvedFolder(home);
|
||||
const fs::path marker = layout.root / portable::kMarker;
|
||||
if (IsAtOrInside(target, layout.root))
|
||||
return Fail(Error::DestinationNotWritable, std::format("This computer's data folder, {}, is inside SpeedBreaker's "
|
||||
"folder ({}): nothing was copied. Is XDG_DATA_HOME set to a folder in it?", target.string(),
|
||||
layout.root.string()), target);
|
||||
|
||||
FoundGame game = FindGame(layout.data, layout, manifest);
|
||||
const bool copyTheGame = !game.dir.empty() && IsAtOrInside(ResolvedFolder(game.dir), layout.root);
|
||||
|
||||
const fs::path partial = fs::path(target.string() + ".partial");
|
||||
if (Result r = ClearPartial(partial, said); !r.Ok())
|
||||
return r;
|
||||
if (Result r = StartPartial(partial); !r.Ok())
|
||||
return r;
|
||||
auto abandon = [&](Result r) {
|
||||
std::error_code removeEc;
|
||||
fs::remove_all(partial, removeEc);
|
||||
return r;
|
||||
};
|
||||
|
||||
CopyStats stats;
|
||||
std::error_code ec;
|
||||
if (fs::is_directory(layout.data, ec))
|
||||
if (Result r = CopyData(layout.data, partial, game.skipped, stats, true); !r.Ok())
|
||||
return abandon(r);
|
||||
if (copyTheGame)
|
||||
{
|
||||
if (Result r = copyGame(game.dir, partial / "game"); !r.Ok())
|
||||
return abandon(r);
|
||||
}
|
||||
else if (!game.dir.empty() && !WriteInstallRecord(partial / "game-location", portable::RecordText(game.dir, {})))
|
||||
return abandon(Fail(Error::WriteError, std::format("Couldn't write {}: nothing was changed.",
|
||||
(partial / "game-location").string()), partial));
|
||||
fs::remove(partial / kPortablePartialTag, ec);
|
||||
|
||||
// The data folder there now (usually the one from before portable
|
||||
// mode) is kept, renamed aside; an empty one is replaced.
|
||||
std::optional<fs::path> aside;
|
||||
if (fs::exists(fs::symlink_status(target, ec)))
|
||||
{
|
||||
if (EmptyOrMissing(target))
|
||||
fs::remove(target, ec);
|
||||
else
|
||||
{
|
||||
aside = AsideName(target);
|
||||
fs::rename(target, *aside, ec);
|
||||
if (ec)
|
||||
return abandon(Fail(Error::WriteError, std::format("Couldn't rename {} to {} ({}): nothing was changed.",
|
||||
target.string(), aside->string(), ec.message()), target));
|
||||
}
|
||||
}
|
||||
ec.clear();
|
||||
fs::rename(partial, target, ec);
|
||||
if (ec)
|
||||
{
|
||||
Result r = Fail(Error::WriteError, std::format("Couldn't rename {} to {} ({}): nothing was changed.", partial.string(),
|
||||
target.string(), ec.message()), target);
|
||||
std::error_code backEc;
|
||||
if (aside)
|
||||
fs::rename(*aside, target, backEc);
|
||||
return abandon(r);
|
||||
}
|
||||
fs::remove(marker, ec);
|
||||
if (ec)
|
||||
return Fail(Error::WriteError, std::format("The data was copied to {}, but {} couldn't be removed ({}). Delete it "
|
||||
"yourself to turn portable mode off.", target.string(), marker.string(), ec.message()), marker);
|
||||
|
||||
if (aside)
|
||||
said.push_back(std::format("Renamed the data folder that was already there, {}, to {}: kept as it was. Delete it "
|
||||
"once you're happy.", target.string(), aside->string()));
|
||||
SayCopied(stats, layout.data, target, said);
|
||||
if (copyTheGame)
|
||||
said.push_back(std::format("Copied the game to {} and checked every file.", (target / "game").string()));
|
||||
else if (!game.dir.empty())
|
||||
said.push_back(std::format("The game stays where it is, {} (outside SpeedBreaker's folder), and is recorded there.",
|
||||
game.dir.string()));
|
||||
else
|
||||
said.push_back("No installed game was found, so none was copied: SpeedBreaker installs it at its first start.");
|
||||
said.push_back(std::format("Portable mode is off ({} removed): SpeedBreaker uses {} again. Nothing in {} was removed: "
|
||||
"delete it once you're happy, or keep it as a backup.", marker.string(), target.string(), layout.root.string()));
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Switching an existing setup into portable mode (user/portable.h) and back,
|
||||
// with a copy: speedbreaker.sh --portable on|off (install_cli.cpp). Making or
|
||||
// deleting portable.txt by hand switches too, with nothing copied (a fresh
|
||||
// start, or this computer's data as it was).
|
||||
//
|
||||
// on: this computer's data folder (settings, saves, logs; not the game
|
||||
// folder or its record) is copied into SpeedBreaker's data/, then the
|
||||
// installed game (the default one or the recorded one) into
|
||||
// data/game, verified (GameCopier: the installer's copy, every file
|
||||
// hashed against the manifest), then portable.txt is written.
|
||||
// off: data/ (not the game) is copied into this computer's data folder,
|
||||
// and the game into its game/ when it's in SpeedBreaker's folder; a
|
||||
// game recorded outside it stays recorded where it is. A data folder
|
||||
// already there (usually the one from before "on") is renamed aside
|
||||
// to <name>.before-portable-off-<date>-<time>, then portable.txt is
|
||||
// removed.
|
||||
// The command line refuses both on Apple platforms (portable::kSupported);
|
||||
// the functions themselves work anywhere (tests run them on a Mac).
|
||||
// Copies, never moves: the source is left as it was, and the messages say
|
||||
// what can be deleted once the player is happy. Each copy is built in a
|
||||
// tagged "<folder>.partial" beside its destination and renamed into place at
|
||||
// the end, with the marker changed last, so an interruption leaves the
|
||||
// previous mode working; the next attempt replaces its own unfinished copy
|
||||
// (one with the tag), and refuses anything else of that name.
|
||||
#pragma once
|
||||
#include "result.h"
|
||||
|
||||
#include <user/portable.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace install
|
||||
{
|
||||
struct Manifest;
|
||||
const Manifest& GameManifest();
|
||||
|
||||
// Copies the installed game in `from` into `to` (which doesn't exist),
|
||||
// verified. The command line's: OpenDiscSource + Install, with progress.
|
||||
using GameCopier = std::function<Result(const std::filesystem::path& from, const std::filesystem::path& to)>;
|
||||
|
||||
// The tag file in an unfinished copy (see above).
|
||||
inline constexpr const char* kPortablePartialTag = ".speedbreaker-portable-partial";
|
||||
|
||||
// --portable on. `root`: SpeedBreaker's folder (absolute; portable.txt
|
||||
// not in it yet); `home`: this computer's data folder (HomeUserPath()).
|
||||
// `said` gets what was done, a line each, for the terminal.
|
||||
Result MakePortable(const std::filesystem::path& root, const std::filesystem::path& home, const GameCopier& copyGame,
|
||||
std::vector<std::string>& said, const Manifest& manifest = GameManifest());
|
||||
|
||||
// --portable off. `layout`: portable mode's (active); `home` as above.
|
||||
Result LeavePortable(const portable::Layout& layout, const std::filesystem::path& home, const GameCopier& copyGame,
|
||||
std::vector<std::string>& said, const Manifest& manifest = GameManifest());
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
#include "memory.h"
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#ifdef __APPLE__
|
||||
@@ -23,6 +25,7 @@ bool Memory::MapPhysicalMirrors()
|
||||
if (kr != KERN_SUCCESS)
|
||||
{
|
||||
fprintf(stderr, "[memory] mach_make_memory_entry_64: %s\n", mach_error_string(kr));
|
||||
failedError = kr;
|
||||
return false;
|
||||
}
|
||||
bool ok = true;
|
||||
@@ -32,11 +35,14 @@ bool Memory::MapPhysicalMirrors()
|
||||
int fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600);
|
||||
if (fd < 0)
|
||||
{
|
||||
failedError = errno;
|
||||
perror("[memory] shm_open");
|
||||
return false;
|
||||
}
|
||||
shm_unlink(name); // the mappings keep it alive
|
||||
bool ok = ftruncate(fd, PHYSICAL_SIZE) == 0;
|
||||
if (!ok)
|
||||
failedError = errno;
|
||||
#endif
|
||||
|
||||
struct View { uint32_t guest; size_t offset; size_t length; };
|
||||
@@ -67,11 +73,13 @@ bool Memory::MapPhysicalMirrors()
|
||||
if (kr != KERN_SUCCESS)
|
||||
{
|
||||
fprintf(stderr, "[memory] vm_map physical view: %s\n", mach_error_string(kr));
|
||||
failedError = kr;
|
||||
ok = false;
|
||||
}
|
||||
#else
|
||||
if (mmap(at, v.length, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, off_t(offset)) == MAP_FAILED)
|
||||
{
|
||||
failedError = errno;
|
||||
perror("[memory] mmap physical view");
|
||||
ok = false;
|
||||
}
|
||||
@@ -100,13 +108,28 @@ Memory::Memory()
|
||||
DWORD oldProtect;
|
||||
VirtualProtect(base, 4096, PAGE_NOACCESS, &oldProtect);
|
||||
#else
|
||||
base = (uint8_t*)mmap((void*)0x100000000ull, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
|
||||
// NFSMW_TEST_RESERVE_FAIL=1|2: fail the reservation or the mirrors as an
|
||||
// iOS without room would (memory_status.h), to see the startup message
|
||||
// anywhere.
|
||||
const char* testFail = std::getenv("NFSMW_TEST_RESERVE_FAIL");
|
||||
int failStep = testFail ? std::atoi(testFail) : 0;
|
||||
|
||||
if (base == (uint8_t*)MAP_FAILED)
|
||||
// SpeedBreaker: one contiguous 4 GB, since guest addresses are offsets
|
||||
// from base. iOS makes room for it with Extended Virtual Addressing or
|
||||
// Increased Memory Limit (scripts/ios/SpeedBreaker/SpeedBreaker.entitlements,
|
||||
// kernel/memory_status.h); without them only about 5 GB is free in one
|
||||
// piece, so this runs in static initialization, before anything else
|
||||
// takes address space. If it fails (ENOMEM), main.cpp says so on screen.
|
||||
base = failStep == 1 ? (uint8_t*)MAP_FAILED
|
||||
: (uint8_t*)mmap((void*)0x100000000ull, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
|
||||
|
||||
if (base == (uint8_t*)MAP_FAILED && failStep != 1)
|
||||
base = (uint8_t*)mmap(NULL, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
|
||||
|
||||
if (base == (uint8_t*)MAP_FAILED)
|
||||
{
|
||||
failedStep = 1;
|
||||
failedError = failStep == 1 ? ENOMEM : errno;
|
||||
base = nullptr;
|
||||
return;
|
||||
}
|
||||
@@ -118,8 +141,17 @@ Memory::Memory()
|
||||
// 0xE0000000 maps physical +0x1000 (Xenia's map_info). D3D writes records
|
||||
// through one window and reads them through another, so all three must be
|
||||
// the same host memory: one shared object, mapped three times.
|
||||
if (!MapPhysicalMirrors())
|
||||
if (failStep == 2 || !MapPhysicalMirrors())
|
||||
{
|
||||
failedStep = 2;
|
||||
if (failStep == 2)
|
||||
{
|
||||
#ifdef __APPLE__
|
||||
failedError = KERN_NO_SPACE;
|
||||
#else
|
||||
failedError = ENOMEM;
|
||||
#endif
|
||||
}
|
||||
munmap(base, PPC_MEMORY_SIZE);
|
||||
base = nullptr;
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,13 @@ struct Memory
|
||||
// Physical offset of the 0xE0000000 window: 0x1000 on hardware, 0 on
|
||||
// hosts whose page size can't express it (see memory.cpp).
|
||||
uint32_t eWindowShift = 0;
|
||||
// SpeedBreaker: base is null because a step failed (memory_status.h's
|
||||
// Failure: 1, the 4 GB reservation; 2, the physical memory mirrors) with
|
||||
// this error (errno; a kern_return_t for the Apple mirrors). main.cpp
|
||||
// says so on screen. NFSMW_TEST_RESERVE_FAIL=1 or 2 fails that step on
|
||||
// purpose (testing the message on any machine).
|
||||
int failedStep = 0;
|
||||
int failedError = 0;
|
||||
|
||||
bool IsInMemoryRange(const void* host) const noexcept
|
||||
{
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See memory_status.h.
|
||||
#include "memory_status.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
#include <mach/mach.h>
|
||||
#include <platform/code_signature.h>
|
||||
#if TARGET_OS_IOS
|
||||
#include <os/proc.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace memstatus
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr const char* kExtendedVa = "com.apple.developer.kernel.extended-virtual-addressing";
|
||||
constexpr const char* kIncreasedMemory = "com.apple.developer.kernel.increased-memory-limit";
|
||||
// The mirrors' errors are kern_return_t values on Apple (Mach memory
|
||||
// entries, kernel/memory.cpp), errno elsewhere.
|
||||
#ifdef __APPLE__
|
||||
constexpr bool kMachErrors = true;
|
||||
#else
|
||||
constexpr bool kMachErrors = false;
|
||||
#endif
|
||||
|
||||
std::string ErrorText(const Status& s)
|
||||
{
|
||||
if (s.failure == Failure::AddressSpace)
|
||||
return std::format("reserving 4 GB failed: {} (errno {})", s.errorText.empty() ? "unknown error" : s.errorText, s.error);
|
||||
if (s.failure == Failure::PhysicalMemory)
|
||||
return std::format("mapping the 512 MB of physical memory failed: {} ({})",
|
||||
s.errorText.empty() ? "unknown error" : s.errorText, s.error);
|
||||
return "";
|
||||
}
|
||||
|
||||
// "entitlements in the signature: Extended Virtual Addressing yes,
|
||||
// Increased Memory Limit no", or why they aren't known.
|
||||
std::string EntitlementsText(const Status& s)
|
||||
{
|
||||
if (!s.signatureRead)
|
||||
return "entitlements: not read";
|
||||
return std::format("entitlements in the signature: Extended Virtual Addressing {}, Increased Memory Limit {}",
|
||||
FlagName(s.extendedVirtualAddressing), FlagName(s.increasedMemoryLimit));
|
||||
}
|
||||
|
||||
std::string Facts(const Status& s)
|
||||
{
|
||||
std::string text = s.failure == Failure::None ? std::format("guest 4 GB at {:#x}", s.base) : ErrorText(s);
|
||||
if (s.ios)
|
||||
{
|
||||
text += "; " + EntitlementsText(s);
|
||||
if (s.addressSpaceTop)
|
||||
text += std::format("; address space up to {:#x} ({} GB)", s.addressSpaceTop, s.addressSpaceTop >> 30);
|
||||
text += s.memoryLimitMB > 0 ? std::format("; memory limit {} MB", s.memoryLimitMB) : "; memory limit unknown";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
bool ReadVmInfo(task_vm_info_data_t& info, mach_msg_type_number_t& count)
|
||||
{
|
||||
count = TASK_VM_INFO_COUNT;
|
||||
return task_info(mach_task_self(), TASK_VM_INFO, task_info_t(&info), &count) == KERN_SUCCESS;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
const char* FlagName(Flag flag)
|
||||
{
|
||||
return flag == Flag::Yes ? "yes" : flag == Flag::No ? "no" : "unknown";
|
||||
}
|
||||
|
||||
std::string LogLine(const Status& status)
|
||||
{
|
||||
return "[memory] " + Facts(status);
|
||||
}
|
||||
|
||||
std::string SummaryItem(const Status& status)
|
||||
{
|
||||
return Facts(status);
|
||||
}
|
||||
|
||||
Message StartupFailure(const Status& s)
|
||||
{
|
||||
Message m;
|
||||
if (s.failure == Failure::None)
|
||||
return m;
|
||||
m.details = "Details: " + Facts(s) + ".";
|
||||
if (!s.ios)
|
||||
{
|
||||
m.title = "SpeedBreaker can't start";
|
||||
m.paragraphs.push_back(s.failure == Failure::AddressSpace
|
||||
? "The system refused the 4 GB of address space the game needs to lay out the Xbox 360's memory."
|
||||
: "The system refused to map the Xbox 360's 512 MB of memory into the game's address space.");
|
||||
m.paragraphs.push_back("Close other programs and try again. On Linux, check that no address-space limit applies "
|
||||
"(ulimit -v must say unlimited).");
|
||||
return m;
|
||||
}
|
||||
m.title = "This copy of SpeedBreaker can't start";
|
||||
if (s.failure == Failure::PhysicalMemory)
|
||||
{
|
||||
m.paragraphs.push_back("iOS refused to map the Xbox 360's 512 MB of memory into the game's address space.");
|
||||
m.paragraphs.push_back("Restart the device and try again. If it happens again, please report it with a "
|
||||
"screenshot of this page.");
|
||||
return m;
|
||||
}
|
||||
if (s.extendedVirtualAddressing == Flag::Yes)
|
||||
{
|
||||
m.paragraphs.push_back("iOS refused the 4 GB of address space the game needs to lay out the Xbox 360's memory, "
|
||||
"even though this copy has the Extended Virtual Addressing entitlement.");
|
||||
m.paragraphs.push_back("Restart the device and try again. If it happens again, please report it with a "
|
||||
"screenshot of this page.");
|
||||
return m;
|
||||
}
|
||||
// iOS gives an app room for 4 GB with Extended Virtual Addressing,
|
||||
// or with Increased Memory Limit on devices that support it (both
|
||||
// lift its address space past 63 GB); without either it has about
|
||||
// 5 GB in one piece above the system's shared region, and the 4 GB
|
||||
// fits there only if nothing took that space first. A free Apple Account can
|
||||
// never give Extended Virtual Addressing; some sideloading tools
|
||||
// keep Increased Memory Limit for it (scripts/ios/README.md).
|
||||
std::string why = "iOS refused the 4 GB of address space the game needs for the Xbox 360's memory layout. ";
|
||||
if (s.extendedVirtualAddressing == Flag::No && s.increasedMemoryLimit == Flag::No)
|
||||
why += "iOS gives an app that much room with the Extended Virtual Addressing or the Increased Memory Limit "
|
||||
"entitlement, and this copy was signed with neither.";
|
||||
else if (s.extendedVirtualAddressing == Flag::No)
|
||||
why += "This copy was signed with Increased Memory Limit, which gives that room only on devices that support "
|
||||
"it, but not with Extended Virtual Addressing.";
|
||||
else
|
||||
why += "It takes the Extended Virtual Addressing or the Increased Memory Limit entitlement, and iOS didn't "
|
||||
"give this copy the room.";
|
||||
m.paragraphs.push_back(why);
|
||||
m.paragraphs.push_back("This build needs the Extended Virtual Addressing entitlement, which a paid Apple Developer "
|
||||
"Program account can give, or Increased Memory Limit, which AltStore 2.2 or later keeps even "
|
||||
"with a free Apple Account (SideStore 0.7 and some other tools drop it). Sideload "
|
||||
"SpeedBreaker again that way, or build it yourself with Xcode: see scripts/ios/README.md in "
|
||||
"the source.");
|
||||
m.paragraphs.push_back("You can close SpeedBreaker now: swipe it away in the app switcher.");
|
||||
return m;
|
||||
}
|
||||
|
||||
std::string LowMemoryNotice(const Status& s)
|
||||
{
|
||||
if (!s.ios || s.failure != Failure::None)
|
||||
return "";
|
||||
bool smallSpace = s.addressSpaceTop != 0 && s.addressSpaceTop < kLargeAddressSpace;
|
||||
bool lowLimit = s.memoryLimitMB > 0 && s.memoryLimitMB < kPeakFootprintMB;
|
||||
if (smallSpace && lowLimit)
|
||||
return std::format("iOS gives SpeedBreaker little room ({} GB of address space, {} MB of memory: no memory "
|
||||
"entitlements): it may close the game in busy scenes", s.addressSpaceTop >> 30, s.memoryLimitMB);
|
||||
if (smallSpace)
|
||||
return std::format("iOS gives SpeedBreaker only {} GB of address space (no memory entitlements): it may close "
|
||||
"the game in busy scenes", s.addressSpaceTop >> 30);
|
||||
if (lowLimit && s.increasedMemoryLimit != Flag::Yes)
|
||||
return std::format("iOS lets SpeedBreaker use only {} MB (no Increased Memory Limit entitlement): it may close "
|
||||
"the game in busy scenes", s.memoryLimitMB);
|
||||
if (lowLimit)
|
||||
return std::format("iOS lets SpeedBreaker use only {} MB on this device: it may close the game in busy scenes",
|
||||
s.memoryLimitMB);
|
||||
return "";
|
||||
}
|
||||
|
||||
void Gather(Status& status)
|
||||
{
|
||||
if (status.failure == Failure::AddressSpace || (status.failure == Failure::PhysicalMemory && !kMachErrors))
|
||||
status.errorText = std::strerror(status.error);
|
||||
#ifdef __APPLE__
|
||||
else if (status.failure == Failure::PhysicalMemory)
|
||||
status.errorText = mach_error_string(status.error);
|
||||
#endif
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
status.ios = true;
|
||||
#else
|
||||
// NFSMW_TEST_MEMORY_IOS=1: the iOS wording on another system (with
|
||||
// NFSMW_TEST_RESERVE_FAIL, the iOS page), the entitlements read from
|
||||
// this executable's own signature (a Mac build has none: "no").
|
||||
const char* test = std::getenv("NFSMW_TEST_MEMORY_IOS");
|
||||
status.ios = test && test[0] == '1';
|
||||
#endif
|
||||
#ifdef __APPLE__
|
||||
if (!status.ios)
|
||||
return;
|
||||
if (std::optional<platform::codesign::Entitlements> e = platform::codesign::ReadOwn())
|
||||
{
|
||||
using platform::codesign::Lookup;
|
||||
using platform::codesign::Value;
|
||||
status.signatureRead = true;
|
||||
status.extendedVirtualAddressing = Lookup(*e, kExtendedVa) == Value::True ? Flag::Yes : Flag::No;
|
||||
status.increasedMemoryLimit = Lookup(*e, kIncreasedMemory) == Value::True ? Flag::Yes : Flag::No;
|
||||
}
|
||||
#else
|
||||
(void)kExtendedVa;
|
||||
(void)kIncreasedMemory;
|
||||
#endif
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
// The limit iOS kills the app at: what it has (its footprint) plus
|
||||
// what it may still take. The address space's top shows whether
|
||||
// Extended Virtual Addressing took effect.
|
||||
task_vm_info_data_t info{};
|
||||
mach_msg_type_number_t count = 0;
|
||||
size_t available = os_proc_available_memory();
|
||||
if (ReadVmInfo(info, count))
|
||||
{
|
||||
if (available > 0)
|
||||
status.memoryLimitMB = int64_t((uint64_t(info.phys_footprint) + available) >> 20);
|
||||
if (count >= TASK_VM_INFO_REV2_COUNT)
|
||||
status.addressSpaceTop = info.max_address;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void Poll()
|
||||
{
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
using clock = std::chrono::steady_clock;
|
||||
static clock::time_point s_next;
|
||||
clock::time_point now = clock::now();
|
||||
if (now < s_next)
|
||||
return;
|
||||
s_next = now + std::chrono::seconds(60);
|
||||
task_vm_info_data_t info{};
|
||||
mach_msg_type_number_t count = 0;
|
||||
if (!ReadVmInfo(info, count))
|
||||
return;
|
||||
int64_t peak = count >= TASK_VM_INFO_REV3_COUNT ? info.ledger_phys_footprint_peak : 0;
|
||||
fprintf(stderr, "[memory] footprint %llu MB, peak %lld MB, %zu MB left before iOS's limit\n",
|
||||
(unsigned long long)(info.phys_footprint >> 20), (long long)(peak >> 20), os_proc_available_memory() >> 20);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Whether the game got the memory it needs, and what to tell the player when
|
||||
// it didn't. The recompiled code addresses guest memory as base + a 32-bit
|
||||
// guest address, so the game needs one contiguous 4 GB of host address space
|
||||
// (kernel/memory.cpp), with the 360's 512 MB of physical memory mapped three
|
||||
// times inside it (0xA0000000, 0xC0000000, 0xE0000000). There is no smaller
|
||||
// layout: the game's heaps reach 0x7EFFFFFF, its image sits at 0x82000000 and
|
||||
// the three physical windows end at 0xFFFFFFFF.
|
||||
//
|
||||
// iOS limits an app's address space to 17.4 GB on a device with more than
|
||||
// 3 GB of memory, about 5 GB of it free in one piece above the system's
|
||||
// shared region, unless it has Extended Virtual Addressing (then the 4 GB
|
||||
// lands at 0x7000000000) or Increased Memory Limit on a device that supports
|
||||
// it (63 GB since iOS 16: xnu's kern_exec.c). Increased Memory Limit also
|
||||
// raises the memory limit (about 3.3 GB -> 6 GB on an 8 GB iPhone), which the
|
||||
// game's peak footprint (about 2.5-3 GB, scripts/ios/README.md, "Memory")
|
||||
// needs on smaller devices. scripts/ios/SpeedBreaker/SpeedBreaker.entitlements
|
||||
// asks for both. A sideloaded copy (the IPA, scripts/ios/make_ipa.sh) is
|
||||
// re-signed with the player's own Apple Account: a free one never gets
|
||||
// Extended Virtual Addressing, and keeps Increased Memory Limit only with
|
||||
// some tools. When the 4 GB can't be had the game can't start: main.cpp shows
|
||||
// StartupFailure() on screen instead of quitting. With a low memory limit it
|
||||
// starts, and LowMemoryNotice() says iOS may close it.
|
||||
//
|
||||
// Everything here is pure (tests/memory_status_test.cpp) except Gather(),
|
||||
// which reads the device.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace memstatus
|
||||
{
|
||||
// Which step of Memory::Memory() failed (kernel/memory.h: failedStep).
|
||||
enum class Failure : uint8_t { None, AddressSpace, PhysicalMemory };
|
||||
|
||||
// From the app's own code signature (platform/code_signature.h):
|
||||
// Unknown when it couldn't be read (or isn't iOS's concern).
|
||||
enum class Flag : uint8_t { Unknown, No, Yes };
|
||||
|
||||
struct Status
|
||||
{
|
||||
Failure failure = Failure::None;
|
||||
int error = 0; // errno for the reservation, a kern_return_t for the mirrors
|
||||
std::string errorText; // strerror / mach_error_string
|
||||
uint64_t base = 0; // host address of the guest's 4 GB (0: none)
|
||||
Flag extendedVirtualAddressing = Flag::Unknown;
|
||||
Flag increasedMemoryLimit = Flag::Unknown;
|
||||
bool signatureRead = false; // the entitlements above come from the signature
|
||||
int64_t memoryLimitMB = -1; // iOS: footprint + os_proc_available_memory() at startup; -1 unknown
|
||||
// iOS: the top of the app's address space (task_vm_info's
|
||||
// max_address), which shows whether the entitlements took effect:
|
||||
// 0xFC0000000 (63 GB) or more with them, 0x458000000 (17.4 GB)
|
||||
// without on a device with more than 3 GB; 0 unknown.
|
||||
uint64_t addressSpaceTop = 0;
|
||||
bool ios = false;
|
||||
};
|
||||
|
||||
// The game's peak memory footprint with room to spare (measured 2.2 GB
|
||||
// in a race at 1x on a Mac, about 2.5 GB expected in an iPhone's free
|
||||
// roam: scripts/ios/README.md, "Memory"): below this limit iOS may
|
||||
// close the game in a busy scene.
|
||||
constexpr int64_t kPeakFootprintMB = 3072;
|
||||
// An address space that ends below this (MACH_VM_JUMBO_ADDRESS, 63 GB)
|
||||
// is iOS's default one: neither entitlement took effect, and the game's
|
||||
// 4 GB plus the 3 GB or so it needs besides (the graphics driver's,
|
||||
// its heaps) must fit in the 7 GB or so iOS leaves an app.
|
||||
constexpr uint64_t kLargeAddressSpace = 0xFC0000000;
|
||||
|
||||
// The log's line: "[memory] guest 4 GB at 0x7000000000; ...".
|
||||
std::string LogLine(const Status& status);
|
||||
|
||||
// The system summary's Memory item (the same facts, without the prefix).
|
||||
std::string SummaryItem(const Status& status);
|
||||
|
||||
struct Message
|
||||
{
|
||||
std::string title;
|
||||
std::vector<std::string> paragraphs; // the first says what happened, the rest what to do
|
||||
std::string details; // small print: the error and the entitlements
|
||||
};
|
||||
// What the player sees when the game can't start (failure != None).
|
||||
Message StartupFailure(const Status& status);
|
||||
|
||||
// A one-line notice (a toast) when the game started but iOS may close
|
||||
// it: iOS's default address space (below kLargeAddressSpace), or a
|
||||
// memory limit below kPeakFootprintMB. "" otherwise (and off iOS).
|
||||
std::string LowMemoryNotice(const Status& status);
|
||||
|
||||
// The device's side (iOS): the entitlements from the app's signature,
|
||||
// the memory limit and the address space's top; elsewhere only the
|
||||
// error's text (NFSMW_TEST_MEMORY_IOS=1: the iOS wording); `base`, the
|
||||
// failure and its error come from g_memory. Main thread, once, early.
|
||||
void Gather(Status& status);
|
||||
|
||||
// iOS, main thread, every frame (video::RunFrame): once a minute a log
|
||||
// line with the app's memory footprint, its peak and what is left before
|
||||
// iOS's limit ("[memory] footprint 2410 MB, peak 2511 MB, 3626 MB left"),
|
||||
// so a log cut short by iOS closing the game shows how close it was.
|
||||
// Nothing elsewhere.
|
||||
void Poll();
|
||||
|
||||
const char* FlagName(Flag flag); // "yes", "no", "unknown"
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include "stdafx.h"
|
||||
#include "kernel/arena.h"
|
||||
#include "kernel/memory_status.h"
|
||||
#include "kernel/module.h"
|
||||
#include "kernel/write_watch.h"
|
||||
#include "kernel/dispatcher.h"
|
||||
@@ -23,6 +24,7 @@
|
||||
#include <install/locate.h>
|
||||
#include <install/placement.h>
|
||||
#include <install/sha256.h>
|
||||
#include <platform/quit_signals.h>
|
||||
#include <report/report.h>
|
||||
#include <ui/installer_screen.h>
|
||||
#include <ui/ui.h>
|
||||
@@ -171,6 +173,7 @@ namespace
|
||||
uint32_t entry;
|
||||
uint32_t stackSize;
|
||||
};
|
||||
std::atomic<bool> s_guestReturned{ false }; // the game's entry point returned
|
||||
|
||||
void* GuestMain(void* arg)
|
||||
{
|
||||
@@ -186,6 +189,7 @@ namespace
|
||||
fprintf(stderr, "[runtime] calling entry %08X\n", params->entry);
|
||||
uint32_t result = GuestThread::Start({ params->entry, 0, 0 });
|
||||
fprintf(stderr, "[runtime] entry point returned %08X\n", result);
|
||||
s_guestReturned = true;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -277,12 +281,23 @@ int main(int argc, char** argv)
|
||||
if (const char* home = std::getenv("HOME"))
|
||||
chdir((std::string(home) + "/Documents").c_str());
|
||||
#endif
|
||||
// Before anything uses the per-user folders: nfsmw-recomp's become
|
||||
// SpeedBreaker's (user/paths.h).
|
||||
std::string migrated = MigrateUserFolders();
|
||||
// Which folder the game runs from, before anything changes directory:
|
||||
// the installer never puts the game inside it (install/placement.h).
|
||||
// the installer never puts the game inside it (install/placement.h), and
|
||||
// a portable.txt there keeps all of SpeedBreaker's data in it
|
||||
// (user/portable.h).
|
||||
install::SetProgramPath(argc > 0 ? argv[0] : nullptr);
|
||||
// Before anything uses the per-user folders: portable mode or not, and
|
||||
// then (not in portable mode) nfsmw-recomp's folders become
|
||||
// SpeedBreaker's (user/paths.h). A portable folder that can't be written
|
||||
// (a read-only drive, a locked card) is still used: the game plays, and
|
||||
// the player is told settings and saves won't be kept; never a quiet
|
||||
// switch to the home folder, which would split saves between two places.
|
||||
const portable::Layout& portableLayout = portable::Detect(install::BundleFolder());
|
||||
const int portableError = portable::CheckWritable(portableLayout);
|
||||
std::string migrated = MigrateUserFolders() + portable::Describe(portableLayout);
|
||||
if (portableError)
|
||||
migrated += std::format("[paths] portable mode: {} can't be written ({}): settings and saves won't be kept\n",
|
||||
portableLayout.data.string(), strerror(portableError));
|
||||
// The name volume mixers and the desktop show (before SDL starts).
|
||||
SDL_SetAppMetadata("SpeedBreaker", report::BuildString(), nullptr);
|
||||
if (std::optional<int> code = install::RunInstallCommand(argc, argv))
|
||||
@@ -299,9 +314,41 @@ int main(int argc, char** argv)
|
||||
report::StartLogWriter();
|
||||
report::RegisterThread("main (window)");
|
||||
|
||||
// The guest's 4 GB (kernel/memory.cpp, reserved before main): where it
|
||||
// went, or why it couldn't, and on iOS what the app's signature allows
|
||||
// (kernel/memory_status.h). For the log, the system summary and, when
|
||||
// the game can't start, the page that says why.
|
||||
memstatus::Status memory;
|
||||
memory.base = reinterpret_cast<uintptr_t>(g_memory.base);
|
||||
memory.failure = memstatus::Failure(g_memory.base ? 0 : std::max(g_memory.failedStep, 1));
|
||||
memory.error = g_memory.failedError;
|
||||
memstatus::Gather(memory);
|
||||
fprintf(stderr, "%s\n", memstatus::LogLine(memory).c_str());
|
||||
report::SetSystemItem("Memory", memstatus::SummaryItem(memory));
|
||||
if (g_memory.base == nullptr)
|
||||
{
|
||||
fprintf(stderr, "[runtime] could not reserve 4 GB of guest address space\n");
|
||||
// An iOS copy signed without Extended Virtual Addressing (a free
|
||||
// Apple Account's sideload) ends up here: say so on screen rather
|
||||
// than vanish. Before the game's code runs at all.
|
||||
memstatus::Message message = memstatus::StartupFailure(memory);
|
||||
fprintf(stderr, "[runtime] could not reserve 4 GB of guest address space: %s\n", message.title.c_str());
|
||||
for (const std::string& p : message.paragraphs)
|
||||
fprintf(stderr, "[runtime] %s\n", p.c_str());
|
||||
settings::Load();
|
||||
if (video::Initialize())
|
||||
{
|
||||
ui::RunStartupError(message.title, message.paragraphs, message.details, [] { return video::RunFrame(); });
|
||||
video::Shutdown();
|
||||
}
|
||||
else if (const char* h = std::getenv("NFSMW_HEADLESS"); !h || h[0] != '1')
|
||||
{
|
||||
// No window for the page: the system's own message box.
|
||||
std::string text;
|
||||
for (const std::string& p : message.paragraphs)
|
||||
text += p + "\n\n";
|
||||
SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, message.title.c_str(), (text + message.details).c_str(), nullptr);
|
||||
}
|
||||
report::FlushLog();
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -316,6 +363,18 @@ int main(int argc, char** argv)
|
||||
sa.sa_flags = SA_SIGINFO;
|
||||
for (int sig : { SIGSEGV, SIGBUS, SIGILL, SIGTRAP, SIGFPE, SIGABRT })
|
||||
sigaction(sig, &sa, nullptr);
|
||||
// SIGTERM, SIGINT and SIGHUP quit the game as its window's close button
|
||||
// does (platform/quit_signals.h): before SDL starts, which leaves them to
|
||||
// us.
|
||||
quitsignals::Install();
|
||||
// From here on a return from main() is a clean end too (an unknown
|
||||
// option, a cancelled install, a XEX that can't be read): the session's
|
||||
// crash marker goes (report.h). The quit paths' std::_Exit runs no
|
||||
// destructors: they clear it themselves.
|
||||
struct ClearMarkerOnReturn
|
||||
{
|
||||
~ClearMarkerOnReturn() { report::ClearCrashMarker(); }
|
||||
} clearMarkerOnReturn;
|
||||
|
||||
// Anything else starting with '-' is a mistake, not a XEX path.
|
||||
if (argc > 1 && argv[1][0] == '-')
|
||||
@@ -349,9 +408,10 @@ int main(int argc, char** argv)
|
||||
// problem began), the "What's new" note below, and this version recorded
|
||||
// as the last (user/version_check.h).
|
||||
version::Change versionChange = version::Change::Same;
|
||||
std::string lastVersion; // the last session's ("": not known)
|
||||
if (settingsLoaded)
|
||||
{
|
||||
std::string last = settings::LastVersion();
|
||||
std::string last = lastVersion = settings::LastVersion();
|
||||
versionChange = version::Classify(last, settings::FileFound(), report::Version());
|
||||
fprintf(stderr, "[version] %s\n", version::Describe(versionChange, last, report::Version()).c_str());
|
||||
if (versionChange != version::Change::Same)
|
||||
@@ -363,7 +423,24 @@ int main(int argc, char** argv)
|
||||
bool windowed = video::Initialize();
|
||||
if (!settingsLoaded)
|
||||
ui::Toast(settings::LastError(), 12.0); // a mistake in settings.toml: it was kept as .bad
|
||||
report::NoticePreviousCrash();
|
||||
if (portableError)
|
||||
ui::Toast(std::format("SpeedBreaker's folder can't be written ({}): settings and saves won't be kept this time. "
|
||||
"Move the folder to a drive it can write to.", strerror(portableError)), 12.0);
|
||||
// iOS: a copy without Increased Memory Limit (a free Apple Account's
|
||||
// sideload) starts, but iOS may close it in a busy scene: say so once.
|
||||
if (std::string notice = memstatus::LowMemoryNotice(memory); !notice.empty())
|
||||
{
|
||||
fprintf(stderr, "[memory] %s\n", notice.c_str());
|
||||
ui::Toast(notice, 12.0);
|
||||
}
|
||||
// A crash marker left by a session of this version or a later one is a
|
||||
// session that ended without quitting; v0.1.5 and older left theirs after
|
||||
// every session. iOS ends apps without a word (none quits there).
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
report::NoticePreviousCrash(false);
|
||||
#else
|
||||
report::NoticePreviousCrash(!lastVersion.empty() && version::Compare(lastVersion, report::Version()) >= 0);
|
||||
#endif
|
||||
|
||||
// NFSMW_LIFELINE_PID (scripts/steamos/speedbreaker.sh): the launcher's PID. The
|
||||
// game runs in a container, not as the launcher's child, so Steam
|
||||
@@ -377,6 +454,7 @@ int main(int argc, char** argv)
|
||||
if (settings::Dirty())
|
||||
settings::Save();
|
||||
gpu::renderer::Shutdown();
|
||||
report::ClearCrashMarker();
|
||||
report::FlushLog();
|
||||
std::_Exit(0);
|
||||
}).detach();
|
||||
@@ -487,6 +565,21 @@ int main(int argc, char** argv)
|
||||
|
||||
if (!windowed)
|
||||
{
|
||||
// No window (NFSMW_HEADLESS=1): the game runs until its entry point
|
||||
// returns, or until a requested termination, which ends it as the
|
||||
// window's quit path below does.
|
||||
while (!s_guestReturned.load() && !quitsignals::Requested())
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
if (int sig = quitsignals::Requested())
|
||||
{
|
||||
fprintf(stderr, "[runtime] %s: quitting\n", quitsignals::Name(sig));
|
||||
if (settings::Dirty())
|
||||
settings::Save();
|
||||
gpu::renderer::Shutdown();
|
||||
report::ClearCrashMarker();
|
||||
report::FlushLog();
|
||||
std::_Exit(0);
|
||||
}
|
||||
pthread_join(thread, nullptr);
|
||||
report::FlushLog();
|
||||
return 0;
|
||||
@@ -504,11 +597,16 @@ int main(int argc, char** argv)
|
||||
hid::NoteGameStarting();
|
||||
while (video::RunFrame())
|
||||
;
|
||||
if (int sig = quitsignals::Requested())
|
||||
fprintf(stderr, "[runtime] %s: quitting, as the window's close button does\n", quitsignals::Name(sig));
|
||||
fprintf(stderr, "[runtime] window closed\n");
|
||||
if (settings::Dirty())
|
||||
settings::Save();
|
||||
gpu::renderer::Shutdown();
|
||||
video::Shutdown();
|
||||
// A clean end: no crash marker left (a crash in the shutdown above still
|
||||
// reported one).
|
||||
report::ClearCrashMarker();
|
||||
// The session log's last lines are still in its pipe: let them out
|
||||
// before _Exit takes the thread that copies them.
|
||||
report::FlushLog();
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ../macos_window.h.
|
||||
//
|
||||
// macOS only (AppKit); built for iOS too, as every platform/apple/*.mm is,
|
||||
// where it is empty. Manual reference counting like the rest of the runtime.
|
||||
#include <TargetConditionals.h>
|
||||
|
||||
#if TARGET_OS_OSX
|
||||
#include <platform/macos_window.h>
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
namespace platform::macos
|
||||
{
|
||||
namespace
|
||||
{
|
||||
NSWindow* s_window = nil; // the game's (SDL's, not retained here)
|
||||
bool s_inTransition = false; // between a fullscreen transition's "will" and its "did" (or "did fail")
|
||||
NSMutableArray* s_observers = nil;
|
||||
|
||||
NSWindow* CocoaWindow(SDL_Window* window)
|
||||
{
|
||||
if (!window)
|
||||
return nil;
|
||||
return (__bridge NSWindow*)SDL_GetPointerProperty(SDL_GetWindowProperties(window), SDL_PROP_WINDOW_COCOA_WINDOW_POINTER,
|
||||
nullptr);
|
||||
}
|
||||
|
||||
// No title, a transparent title bar, the picture under it.
|
||||
void ApplyLook(NSWindow* window)
|
||||
{
|
||||
window.titleVisibility = NSWindowTitleHidden;
|
||||
window.titlebarAppearsTransparent = YES;
|
||||
window.styleMask |= NSWindowStyleMaskFullSizeContentView;
|
||||
}
|
||||
|
||||
void Observe(NSWindow* window, NSString* name, void (^block)(NSNotification*))
|
||||
{
|
||||
id token = [NSNotificationCenter.defaultCenter addObserverForName:name object:window queue:nil usingBlock:block];
|
||||
[s_observers addObject:token];
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigureWindow(SDL_Window* window, bool windowed)
|
||||
{
|
||||
NSWindow* w = CocoaWindow(window);
|
||||
if (!w)
|
||||
return;
|
||||
if (s_window != w)
|
||||
{
|
||||
ForgetWindow(nullptr);
|
||||
s_window = w;
|
||||
s_observers = [[NSMutableArray alloc] init];
|
||||
// Whether a fullscreen transition is under way (AppKit says
|
||||
// nothing else): the look is never changed in the middle of one.
|
||||
for (NSString* name in @[ NSWindowWillEnterFullScreenNotification, NSWindowWillExitFullScreenNotification ])
|
||||
Observe(w, name, ^(NSNotification*) { s_inTransition = true; });
|
||||
for (NSString* name in @[ NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification,
|
||||
@"NSWindowDidFailToEnterFullScreenNotification", @"NSWindowDidFailToExitFullScreenNotification" ])
|
||||
Observe(w, name, ^(NSNotification*) { s_inTransition = false; });
|
||||
// SDL takes the full-size content view off as a fullscreen
|
||||
// transition starts (its windowWillEnterFullScreen), but its
|
||||
// timing against AppKit's own steps varies, and the bit changing
|
||||
// after AppKit has set its fullscreen frame crashed AppKit. So it
|
||||
// goes now, as AppKit announces the transition. (In testing SDL
|
||||
// had always taken it off already; this is the safety net.)
|
||||
Observe(w, NSWindowWillEnterFullScreenNotification, ^(NSNotification* n) {
|
||||
NSWindow* nw = n.object;
|
||||
if (nw.styleMask & NSWindowStyleMaskFullSizeContentView)
|
||||
nw.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
|
||||
});
|
||||
}
|
||||
if (windowed)
|
||||
ApplyLook(w);
|
||||
}
|
||||
|
||||
bool RestoreAfterFullscreen(SDL_Window* window)
|
||||
{
|
||||
NSWindow* w = CocoaWindow(window);
|
||||
if (!w || s_inTransition || (w.styleMask & NSWindowStyleMaskFullScreen))
|
||||
return false;
|
||||
// AppKit has put back the frame the window had before fullscreen;
|
||||
// -setStyleMask: would keep the content (one title bar shorter
|
||||
// without the bit) and shrink the frame to it, so the frame is set
|
||||
// back: the content is the whole frame again, as before fullscreen.
|
||||
const NSRect frame = w.frame;
|
||||
ApplyLook(w);
|
||||
[w setFrame:frame display:YES];
|
||||
return true;
|
||||
}
|
||||
|
||||
void ShowWindowButtons(SDL_Window* window, bool shown)
|
||||
{
|
||||
NSWindow* w = CocoaWindow(window);
|
||||
if (!w)
|
||||
return;
|
||||
const NSWindowButton buttons[] = { NSWindowCloseButton, NSWindowMiniaturizeButton, NSWindowZoomButton };
|
||||
for (NSWindowButton b : buttons)
|
||||
{
|
||||
NSButton* button = [w standardWindowButton:b];
|
||||
if (button && button.hidden == shown)
|
||||
button.hidden = !shown;
|
||||
}
|
||||
}
|
||||
|
||||
float TitleStripHeight(SDL_Window* window)
|
||||
{
|
||||
NSWindow* w = CocoaWindow(window);
|
||||
if (!w || (w.styleMask & NSWindowStyleMaskFullScreen) || !(w.styleMask & NSWindowStyleMaskFullSizeContentView))
|
||||
return 0.0f;
|
||||
// The layout rect is the part of the window no title bar covers.
|
||||
const CGFloat strip = NSHeight(w.frame) - NSMaxY(w.contentLayoutRect);
|
||||
return strip > 0 ? float(strip) : 0.0f;
|
||||
}
|
||||
|
||||
void ForgetWindow(SDL_Window*)
|
||||
{
|
||||
for (id token in s_observers)
|
||||
[NSNotificationCenter.defaultCenter removeObserver:token];
|
||||
[s_observers release];
|
||||
s_observers = nil;
|
||||
s_window = nil;
|
||||
s_inTransition = false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,242 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See code_signature.h.
|
||||
//
|
||||
// Formats (Apple's <mach-o/loader.h>, <mach-o/fat.h> and the code signing
|
||||
// blobs of xnu's osfmk/kern/cs_blobs.h), read field by field so this builds
|
||||
// and tests anywhere:
|
||||
// - Fat header, big-endian: magic 0xCAFEBABE (32-bit offsets, 20-byte
|
||||
// entries) or 0xCAFEBABF (64-bit, 32-byte entries), then the entries:
|
||||
// cputype, cpusubtype, offset, size, align (, reserved).
|
||||
// - mach_header_64, the file's (little-endian on arm64): magic 0xFEEDFACF,
|
||||
// cputype, cpusubtype, filetype, ncmds, sizeofcmds, flags, reserved;
|
||||
// then the load commands, each cmd and cmdsize first.
|
||||
// LC_CODE_SIGNATURE (0x1D) is a linkedit_data_command: dataoff and
|
||||
// datasize, file offsets within the slice.
|
||||
// - The embedded signature, big-endian: a SuperBlob (magic 0xFADE0CC0,
|
||||
// length, count, then count index entries of type and offset from the
|
||||
// SuperBlob's start). Slot 5 is the XML entitlements blob (magic
|
||||
// 0xFADE7171), slot 7 the DER one (0xFADE7172): magic, length (the
|
||||
// blob's whole size, header included), then the bytes.
|
||||
#include "code_signature.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <fcntl.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace platform::codesign
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr uint32_t kFatMagic = 0xCAFEBABE, kFatMagic64 = 0xCAFEBABF;
|
||||
constexpr uint32_t kMachMagic64 = 0xFEEDFACF;
|
||||
constexpr uint32_t kCpuArm64 = 0x0100000C;
|
||||
constexpr uint32_t kLcCodeSignature = 0x1D;
|
||||
constexpr uint32_t kSuperBlobMagic = 0xFADE0CC0;
|
||||
constexpr uint32_t kEntitlementsMagic = 0xFADE7171, kDerEntitlementsMagic = 0xFADE7172;
|
||||
constexpr uint32_t kSlotEntitlements = 5, kSlotDerEntitlements = 7;
|
||||
constexpr size_t kMachHeaderSize = 32;
|
||||
|
||||
bool Has(std::span<const uint8_t> b, uint64_t at, uint64_t n)
|
||||
{
|
||||
return at <= b.size() && n <= b.size() - at;
|
||||
}
|
||||
uint32_t Be32(std::span<const uint8_t> b, size_t at)
|
||||
{
|
||||
return uint32_t(b[at]) << 24 | uint32_t(b[at + 1]) << 16 | uint32_t(b[at + 2]) << 8 | b[at + 3];
|
||||
}
|
||||
uint64_t Be64(std::span<const uint8_t> b, size_t at)
|
||||
{
|
||||
return uint64_t(Be32(b, at)) << 32 | Be32(b, at + 4);
|
||||
}
|
||||
uint32_t Le32(std::span<const uint8_t> b, size_t at)
|
||||
{
|
||||
return uint32_t(b[at]) | uint32_t(b[at + 1]) << 8 | uint32_t(b[at + 2]) << 16 | uint32_t(b[at + 3]) << 24;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Range> MachOSlice(std::span<const uint8_t> fileStart, uint64_t fileSize)
|
||||
{
|
||||
if (!Has(fileStart, 0, 8))
|
||||
return std::nullopt;
|
||||
if (Le32(fileStart, 0) == kMachMagic64)
|
||||
return Range{ 0, fileSize };
|
||||
uint32_t magic = Be32(fileStart, 0);
|
||||
if (magic != kFatMagic && magic != kFatMagic64)
|
||||
return std::nullopt;
|
||||
uint32_t count = Be32(fileStart, 4);
|
||||
size_t entry = magic == kFatMagic64 ? 32 : 20;
|
||||
if (count == 0 || count > 64 || !Has(fileStart, 8, uint64_t(count) * entry))
|
||||
return std::nullopt;
|
||||
std::optional<Range> chosen;
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
{
|
||||
size_t at = 8 + i * entry;
|
||||
Range r;
|
||||
if (magic == kFatMagic64)
|
||||
r = { Be64(fileStart, at + 8), Be64(fileStart, at + 16) };
|
||||
else
|
||||
r = { Be32(fileStart, at + 8), Be32(fileStart, at + 12) };
|
||||
if (r.offset > fileSize || r.size > fileSize - r.offset)
|
||||
continue;
|
||||
if (Be32(fileStart, at) == kCpuArm64)
|
||||
return r;
|
||||
if (count == 1)
|
||||
chosen = r;
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
std::optional<Range> SignatureRange(std::span<const uint8_t> sliceStart, uint64_t sliceOffset)
|
||||
{
|
||||
if (!Has(sliceStart, 0, kMachHeaderSize) || Le32(sliceStart, 0) != kMachMagic64)
|
||||
return std::nullopt;
|
||||
uint32_t ncmds = Le32(sliceStart, 16), sizeofcmds = Le32(sliceStart, 20);
|
||||
if (!Has(sliceStart, kMachHeaderSize, sizeofcmds))
|
||||
return std::nullopt;
|
||||
size_t at = kMachHeaderSize, end = kMachHeaderSize + sizeofcmds;
|
||||
for (uint32_t i = 0; i < ncmds; i++)
|
||||
{
|
||||
if (at + 8 > end)
|
||||
return std::nullopt;
|
||||
uint32_t cmd = Le32(sliceStart, at), cmdsize = Le32(sliceStart, at + 4);
|
||||
if (cmdsize < 8 || cmdsize > end - at)
|
||||
return std::nullopt;
|
||||
if (cmd == kLcCodeSignature)
|
||||
{
|
||||
if (cmdsize < 16)
|
||||
return std::nullopt;
|
||||
uint32_t dataoff = Le32(sliceStart, at + 8), datasize = Le32(sliceStart, at + 12);
|
||||
if (datasize == 0)
|
||||
return std::nullopt;
|
||||
return Range{ sliceOffset + dataoff, datasize };
|
||||
}
|
||||
at += cmdsize;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Entitlements> FromSuperBlob(std::span<const uint8_t> blob)
|
||||
{
|
||||
if (!Has(blob, 0, 12) || Be32(blob, 0) != kSuperBlobMagic)
|
||||
return std::nullopt;
|
||||
uint32_t length = Be32(blob, 4), count = Be32(blob, 8);
|
||||
if (length < 12 || length > blob.size())
|
||||
return std::nullopt;
|
||||
blob = blob.first(length);
|
||||
if (!Has(blob, 12, uint64_t(count) * 8))
|
||||
return std::nullopt;
|
||||
Entitlements der;
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
{
|
||||
uint32_t type = Be32(blob, 12 + i * 8), offset = Be32(blob, 16 + i * 8);
|
||||
if (type != kSlotEntitlements && type != kSlotDerEntitlements)
|
||||
continue;
|
||||
if (!Has(blob, offset, 8))
|
||||
return std::nullopt;
|
||||
uint32_t magic = Be32(blob, offset), size = Be32(blob, offset + 4);
|
||||
if (size < 8 || !Has(blob, offset, size))
|
||||
return std::nullopt;
|
||||
const char* bytes = reinterpret_cast<const char*>(blob.data() + offset + 8);
|
||||
if (type == kSlotEntitlements && magic == kEntitlementsMagic)
|
||||
return Entitlements{ true, std::string(bytes, size - 8), false }; // XML: the one to read
|
||||
if (type == kSlotDerEntitlements && magic == kDerEntitlementsMagic)
|
||||
der = Entitlements{ true, std::string(bytes, size - 8), true };
|
||||
}
|
||||
return der; // DER only, or found == false: no entitlements at all
|
||||
}
|
||||
|
||||
Value Lookup(const Entitlements& entitlements, std::string_view key)
|
||||
{
|
||||
if (!entitlements.found || key.empty())
|
||||
return Value::Absent;
|
||||
const std::string& t = entitlements.text;
|
||||
if (entitlements.der)
|
||||
{
|
||||
// UTF8String (tag 0x0C, short length) holding the key, then a
|
||||
// BOOLEAN (0x01, length 1, 0x00 false, anything else true).
|
||||
if (key.size() >= 0x80)
|
||||
return Value::Absent;
|
||||
std::string pattern;
|
||||
pattern += char(0x0C);
|
||||
pattern += char(key.size());
|
||||
pattern += key;
|
||||
for (size_t at = t.find(pattern); at != std::string::npos; at = t.find(pattern, at + 1))
|
||||
{
|
||||
size_t v = at + pattern.size();
|
||||
if (v + 3 <= t.size() && t[v] == 0x01 && t[v + 1] == 0x01)
|
||||
return t[v + 2] != 0 ? Value::True : Value::False;
|
||||
}
|
||||
return Value::Absent;
|
||||
}
|
||||
std::string pattern = "<key>" + std::string(key) + "</key>";
|
||||
size_t at = t.find(pattern);
|
||||
if (at == std::string::npos)
|
||||
return Value::Absent;
|
||||
at += pattern.size();
|
||||
while (at < t.size() && (t[at] == ' ' || t[at] == '\t' || t[at] == '\r' || t[at] == '\n'))
|
||||
at++;
|
||||
std::string_view rest = std::string_view(t).substr(at);
|
||||
if (rest.starts_with("<true/>") || rest.starts_with("<true />"))
|
||||
return Value::True;
|
||||
if (rest.starts_with("<false/>") || rest.starts_with("<false />"))
|
||||
return Value::False;
|
||||
return Value::Absent;
|
||||
}
|
||||
|
||||
std::optional<Entitlements> FromFile(std::span<const uint8_t> file)
|
||||
{
|
||||
std::optional<Range> slice = MachOSlice(file, file.size());
|
||||
if (!slice)
|
||||
return std::nullopt;
|
||||
std::optional<Range> sig = SignatureRange(file.subspan(slice->offset, slice->size), slice->offset);
|
||||
if (!sig || !Has(file, sig->offset, sig->size))
|
||||
return std::nullopt;
|
||||
return FromSuperBlob(file.subspan(sig->offset, sig->size));
|
||||
}
|
||||
|
||||
std::optional<Entitlements> ReadOwn()
|
||||
{
|
||||
#ifdef __APPLE__
|
||||
char path[4096];
|
||||
uint32_t pathSize = sizeof(path);
|
||||
if (_NSGetExecutablePath(path, &pathSize) != 0)
|
||||
return std::nullopt;
|
||||
int fd = open(path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0)
|
||||
return std::nullopt;
|
||||
off_t fileSize = lseek(fd, 0, SEEK_END);
|
||||
auto read = [fd](uint64_t offset, uint64_t length) {
|
||||
std::vector<uint8_t> bytes(static_cast<size_t>(length));
|
||||
ssize_t got = length ? pread(fd, bytes.data(), bytes.size(), off_t(offset)) : 0;
|
||||
bytes.resize(got > 0 ? size_t(got) : 0);
|
||||
return bytes;
|
||||
};
|
||||
std::optional<Entitlements> result;
|
||||
if (fileSize > 0)
|
||||
{
|
||||
std::vector<uint8_t> start = read(0, std::min<uint64_t>(uint64_t(fileSize), 4096));
|
||||
if (std::optional<Range> slice = MachOSlice(start, uint64_t(fileSize)))
|
||||
{
|
||||
std::vector<uint8_t> header = read(slice->offset, kMachHeaderSize);
|
||||
if (header.size() == kMachHeaderSize && Le32(header, 0) == kMachMagic64)
|
||||
{
|
||||
uint64_t commands = std::min<uint64_t>(Le32(header, 20), 16u << 20);
|
||||
std::vector<uint8_t> head = read(slice->offset, kMachHeaderSize + commands);
|
||||
std::optional<Range> sig = SignatureRange(head, slice->offset);
|
||||
if (sig && sig->size <= (64u << 20))
|
||||
result = FromSuperBlob(read(sig->offset, sig->size));
|
||||
}
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return result;
|
||||
#else
|
||||
return std::nullopt;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The entitlements in the game's own code signature, as installed. On iOS
|
||||
// what the app may do is what its signature says (and its provisioning
|
||||
// profile allows: iOS won't install or open an app signed with entitlements
|
||||
// its profile lacks), and a sideloading tool (AltStore, SideStore,
|
||||
// Sideloadly...) re-signs the app with the player's own Apple Account: a
|
||||
// tool may keep, or drop, the entitlements the app asks for
|
||||
// (scripts/ios/SpeedBreaker/SpeedBreaker.entitlements). So the signature on
|
||||
// the device answers "did this copy get Extended Virtual Addressing?" for
|
||||
// the log and the startup message (kernel/memory_status.h).
|
||||
//
|
||||
// Parsing is pure (tests/code_signature_test.cpp): a Mach-O file (thin, or
|
||||
// fat with an arm64 slice) -> its LC_CODE_SIGNATURE -> the embedded
|
||||
// signature's SuperBlob -> the entitlements blob, as XML (slot 5) or DER
|
||||
// (slot 7). ReadOwn() reads the running executable (Apple only).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace platform::codesign
|
||||
{
|
||||
// A file range: where the embedded signature is in the executable.
|
||||
struct Range
|
||||
{
|
||||
uint64_t offset = 0;
|
||||
uint64_t size = 0;
|
||||
};
|
||||
|
||||
// The arm64 slice of a fat file, or the whole file if thin: from the
|
||||
// file's first bytes (the fat header, which is small). nullopt: neither a
|
||||
// Mach-O nor a fat file with an arm64 (or the only) slice.
|
||||
std::optional<Range> MachOSlice(std::span<const uint8_t> fileStart, uint64_t fileSize);
|
||||
|
||||
// The embedded signature's range in the file, from the slice's first
|
||||
// bytes (its header and load commands, `sliceOffset` into the file).
|
||||
// nullopt: not a 64-bit Mach-O, malformed, or unsigned (no
|
||||
// LC_CODE_SIGNATURE).
|
||||
std::optional<Range> SignatureRange(std::span<const uint8_t> sliceStart, uint64_t sliceOffset);
|
||||
|
||||
struct Entitlements
|
||||
{
|
||||
bool found = false; // the signature has an entitlements blob (else: none at all)
|
||||
std::string text; // XML (slot 5); else the DER blob's bytes (slot 7)
|
||||
bool der = false;
|
||||
};
|
||||
// The entitlements from the embedded signature (a CS SuperBlob,
|
||||
// big-endian). nullopt: not a SuperBlob or malformed. found == false: a
|
||||
// signature without entitlements (an ad-hoc one, as the linker makes).
|
||||
std::optional<Entitlements> FromSuperBlob(std::span<const uint8_t> blob);
|
||||
|
||||
enum class Value : uint8_t { Absent, False, True };
|
||||
// `key`'s boolean in the entitlements: XML <key>key</key><true/>, or DER
|
||||
// (UTF8String key, BOOLEAN). Absent: not there (or not a boolean).
|
||||
Value Lookup(const Entitlements& entitlements, std::string_view key);
|
||||
|
||||
// The whole chain on a file's bytes (tests): nullopt as above.
|
||||
std::optional<Entitlements> FromFile(std::span<const uint8_t> file);
|
||||
|
||||
// Apple: the running executable's entitlements, read from its file.
|
||||
// nullopt: unreadable, unsigned or not parsed (and elsewhere).
|
||||
std::optional<Entitlements> ReadOwn();
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
#include <video/swapchain_policy.h>
|
||||
|
||||
struct SDL_Window;
|
||||
|
||||
namespace platform::ios
|
||||
@@ -41,4 +43,54 @@ namespace platform::ios
|
||||
// An iPhone (UIDevice's interface idiom), not an iPad: Frame Rate's
|
||||
// default (main.cpp). Main thread.
|
||||
bool IsPhone();
|
||||
|
||||
// Orientation (video/swapchain_policy.h says why). SDL 3.4.16 sets its
|
||||
// view's frame itself when a keyboard shows or hides (-[SDL_uikitview-
|
||||
// controller updateKeyboard] -> UIKit_ComputeViewFrame, SDL_uikitvideo.m)
|
||||
// and swaps its width and height unless it believes the interface is
|
||||
// landscape: UIApplication.statusBarOrientation is landscape (on iOS 27
|
||||
// it reads "unknown") or the window can't be portrait. A resizable
|
||||
// window can be, to SDL, unless SDL_HINT_ORIENTATIONS says otherwise
|
||||
// (UIKit_GetSupportedOrientations ignores the Info.plist then).
|
||||
|
||||
// Before SDL_CreateWindow: on a device where the app is landscape-only
|
||||
// (LandscapeOnly), SDL_HINT_ORIENTATIONS = "LandscapeLeft LandscapeRight"
|
||||
// (unless set already), so SDL never turns the view portrait. Main thread.
|
||||
void PrepareOrientation();
|
||||
|
||||
// Once the window is up: the log notes keyboards showing and hiding, with
|
||||
// the view's geometry (the trigger of any turn that gets past the hint),
|
||||
// at most 20 times.
|
||||
void WatchKeyboards(SDL_Window* window);
|
||||
|
||||
// The app shows only in landscape here: an iPhone, whose Info.plist
|
||||
// lists no portrait orientation. (An iPad's window can take any shape.)
|
||||
bool LandscapeOnly();
|
||||
|
||||
// The interface's orientation: the window's scene's, else its bounds'
|
||||
// shape. Unknown without a window.
|
||||
video::Orientation InterfaceOrientation(SDL_Window* window);
|
||||
|
||||
// SDL's view (the Metal view the surface is) fills its UIWindow: the
|
||||
// same size (an offset for a keyboard is SDL's to make).
|
||||
bool ViewFitsWindow(SDL_Window* window);
|
||||
|
||||
// Fits SDL's view to its UIWindow's bounds again, and lays it out (SDL
|
||||
// then sizes the drawables and sends its resize events). True: the view
|
||||
// had to be changed. Logs what it did.
|
||||
bool FitViewToWindow(SDL_Window* window);
|
||||
|
||||
// The scene's interface orientation by name ("landscape left", ...),
|
||||
// for the log: a string literal, so a change shows as another pointer.
|
||||
const char* SceneOrientationName(SDL_Window* window);
|
||||
|
||||
// The view, its window and the scene in one line, for the log.
|
||||
std::string DescribeGeometry(SDL_Window* window);
|
||||
// The battery, for the log: its level and charging state once known
|
||||
// ("[power] battery 87%, charging, at 0.4 s"), then each change ("[power]
|
||||
// battery 87% -> 86%, on battery, at 312.0 s"), so a session's drain per
|
||||
// minute reads off its log beside the [thermal] lines. Main thread, every
|
||||
// frame (video::RunFrame): reads UIDevice every 5 seconds at most, with
|
||||
// battery monitoring left on from the first call.
|
||||
void PollBattery();
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
// press still comes through once.
|
||||
#include "ios_ui.h"
|
||||
|
||||
#include <SDL3/SDL_hints.h>
|
||||
#include <SDL3/SDL_keyboard.h>
|
||||
#include <SDL3/SDL_properties.h>
|
||||
#include <SDL3/SDL_video.h>
|
||||
@@ -33,9 +34,13 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
#include <report/report.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -69,6 +74,40 @@ namespace
|
||||
bool s_noViewLogged = false, s_logged = false;
|
||||
int s_retakes = 0;
|
||||
constexpr int kRetakesLogged = 10;
|
||||
|
||||
UIWindow* UiWindow(SDL_Window* window)
|
||||
{
|
||||
return window ? static_cast<UIWindow*>(SDL_GetPointerProperty(SDL_GetWindowProperties(window),
|
||||
SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER, nullptr)) : nil;
|
||||
}
|
||||
|
||||
const char* InterfaceName(UIInterfaceOrientation o)
|
||||
{
|
||||
switch (o)
|
||||
{
|
||||
case UIInterfaceOrientationPortrait:
|
||||
return "portrait";
|
||||
case UIInterfaceOrientationPortraitUpsideDown:
|
||||
return "portrait upside down";
|
||||
case UIInterfaceOrientationLandscapeLeft:
|
||||
return "landscape left";
|
||||
case UIInterfaceOrientationLandscapeRight:
|
||||
return "landscape right";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
bool SameSize(CGSize a, CGSize b)
|
||||
{
|
||||
return std::fabs(a.width - b.width) < 0.5 && std::fabs(a.height - b.height) < 0.5;
|
||||
}
|
||||
|
||||
// Keyboard notifications reach SDL's view controller, which sets the
|
||||
// view's frame on each (see ios_ui.h): the log says when, and whose.
|
||||
int s_keyboardNotes = 0;
|
||||
constexpr int kKeyboardNotesLogged = 20;
|
||||
SDL_Window* s_window = nullptr; // the game's, once it exists (FitViewToWindow)
|
||||
}
|
||||
|
||||
namespace platform::ios
|
||||
@@ -192,4 +231,203 @@ namespace platform::ios
|
||||
{
|
||||
return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone;
|
||||
}
|
||||
|
||||
bool LandscapeOnly()
|
||||
{
|
||||
static const bool only = [] {
|
||||
@autoreleasepool
|
||||
{
|
||||
if (!IsPhone())
|
||||
return false;
|
||||
// NSBundle hands over the device's own variant of the key.
|
||||
id list = [NSBundle.mainBundle objectForInfoDictionaryKey:@"UISupportedInterfaceOrientations"];
|
||||
if (![list isKindOfClass:[NSArray class]] || [(NSArray*)list count] == 0)
|
||||
return false;
|
||||
for (id o in (NSArray*)list)
|
||||
if (![o isKindOfClass:[NSString class]] || ![(NSString*)o hasPrefix:@"UIInterfaceOrientationLandscape"])
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}();
|
||||
return only;
|
||||
}
|
||||
|
||||
void PrepareOrientation()
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
if (LandscapeOnly())
|
||||
{
|
||||
if (const char* set = SDL_GetHint(SDL_HINT_ORIENTATIONS))
|
||||
fprintf(stderr, "[video] orientation: SDL_HINT_ORIENTATIONS is \"%s\" already: left as it is\n", set);
|
||||
else
|
||||
{
|
||||
SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight");
|
||||
fprintf(stderr, "[video] orientation: landscape only (iPhone): SDL_HINT_ORIENTATIONS = \"LandscapeLeft LandscapeRight\", "
|
||||
"so SDL never turns its view portrait\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WatchKeyboards(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
s_window = window;
|
||||
static bool watching = false;
|
||||
if (std::exchange(watching, true))
|
||||
return;
|
||||
for (NSString* name in @[ UIKeyboardWillShowNotification, UIKeyboardWillHideNotification ])
|
||||
[NSNotificationCenter.defaultCenter addObserverForName:name object:nil queue:nil usingBlock:^(NSNotification* note) {
|
||||
if (s_keyboardNotes >= kKeyboardNotesLogged)
|
||||
return;
|
||||
s_keyboardNotes++;
|
||||
NSNumber* local = note.userInfo[UIKeyboardIsLocalUserInfoKey];
|
||||
fprintf(stderr, "[ui] a keyboard will %s (%s)%s: %s\n", [note.name isEqualToString:UIKeyboardWillShowNotification] ? "show" : "hide",
|
||||
local == nil ? "whose not said" : local.boolValue ? "this app's" : "another app's",
|
||||
s_keyboardNotes == kKeyboardNotesLogged ? " (no more of these logged)" : "", DescribeGeometry(s_window).c_str());
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
video::Orientation InterfaceOrientation(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
if (!uiWindow)
|
||||
return video::Orientation::Unknown;
|
||||
UIInterfaceOrientation o = uiWindow.windowScene ? uiWindow.windowScene.effectiveGeometry.interfaceOrientation
|
||||
: UIInterfaceOrientationUnknown;
|
||||
if (UIInterfaceOrientationIsLandscape(o))
|
||||
return video::Orientation::Landscape;
|
||||
if (UIInterfaceOrientationIsPortrait(o))
|
||||
return video::Orientation::Portrait;
|
||||
const CGSize size = uiWindow.bounds.size;
|
||||
return size.width <= 0 || size.height <= 0 ? video::Orientation::Unknown
|
||||
: size.width >= size.height ? video::Orientation::Landscape
|
||||
: video::Orientation::Portrait;
|
||||
}
|
||||
}
|
||||
|
||||
bool ViewFitsWindow(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
UIView* view = uiWindow.rootViewController.viewIfLoaded;
|
||||
return !view || SameSize(view.bounds.size, uiWindow.bounds.size);
|
||||
}
|
||||
}
|
||||
|
||||
bool FitViewToWindow(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
UIView* view = uiWindow.rootViewController.viewIfLoaded;
|
||||
if (!view)
|
||||
return false;
|
||||
const CGRect before = view.frame;
|
||||
if (SameSize(view.bounds.size, uiWindow.bounds.size))
|
||||
{
|
||||
fprintf(stderr, "[video] the view already fills its window: %s\n", DescribeGeometry(window).c_str());
|
||||
return false;
|
||||
}
|
||||
view.frame = uiWindow.bounds;
|
||||
[view setNeedsLayout];
|
||||
[view layoutIfNeeded];
|
||||
fprintf(stderr, "[video] the view was %.0fx%.0f points in a %.0fx%.0f window: fitted back to it (%s)\n", before.size.width,
|
||||
before.size.height, uiWindow.bounds.size.width, uiWindow.bounds.size.height, DescribeGeometry(window).c_str());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const char* SceneOrientationName(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
return InterfaceName(uiWindow.windowScene ? uiWindow.windowScene.effectiveGeometry.interfaceOrientation
|
||||
: UIInterfaceOrientationUnknown);
|
||||
}
|
||||
}
|
||||
|
||||
std::string DescribeGeometry(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
UIView* view = uiWindow.rootViewController.viewIfLoaded;
|
||||
if (!view)
|
||||
return "no view";
|
||||
UIInterfaceOrientation scene = uiWindow.windowScene ? uiWindow.windowScene.effectiveGeometry.interfaceOrientation
|
||||
: UIInterfaceOrientationUnknown;
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
UIInterfaceOrientation bar = UIApplication.sharedApplication.statusBarOrientation;
|
||||
#pragma clang diagnostic pop
|
||||
const char* state = "active";
|
||||
switch (UIApplication.sharedApplication.applicationState)
|
||||
{
|
||||
case UIApplicationStateInactive:
|
||||
state = "inactive";
|
||||
break;
|
||||
case UIApplicationStateBackground:
|
||||
state = "in the background";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
CGSize drawables = [view.layer isKindOfClass:[CAMetalLayer class]] ? ((CAMetalLayer*)view.layer).drawableSize : CGSizeZero;
|
||||
char text[320];
|
||||
snprintf(text, sizeof text,
|
||||
"view %.0fx%.0f at %.0f,%.0f in a %.0fx%.0f window; drawables %.0fx%.0f at scale %.3f; scene %s, status bar %s; app %s",
|
||||
view.frame.size.width, view.frame.size.height, view.frame.origin.x, view.frame.origin.y, uiWindow.bounds.size.width,
|
||||
uiWindow.bounds.size.height, drawables.width, drawables.height, double(view.layer.contentsScale), InterfaceName(scene),
|
||||
InterfaceName(bar), state);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
void PollBattery()
|
||||
{
|
||||
static double next = 0.0;
|
||||
static int lastPercent = -1;
|
||||
static const char* lastState = nullptr; // nullptr: nothing logged yet
|
||||
const double now = report::SessionSeconds();
|
||||
if (now < next)
|
||||
return;
|
||||
next = now + 5.0;
|
||||
@autoreleasepool
|
||||
{
|
||||
UIDevice* device = UIDevice.currentDevice;
|
||||
if (!device.batteryMonitoringEnabled)
|
||||
device.batteryMonitoringEnabled = YES; // left on: SDL's power API isn't used
|
||||
const float level = device.batteryLevel; // -1 while unknown
|
||||
const int percent = level < 0.0f ? -1 : int(std::lround(double(level) * 100.0));
|
||||
const char* state = "unknown";
|
||||
switch (device.batteryState)
|
||||
{
|
||||
case UIDeviceBatteryStateUnplugged: state = "on battery"; break;
|
||||
case UIDeviceBatteryStateCharging: state = "charging"; break;
|
||||
case UIDeviceBatteryStateFull: state = "full, on a charger"; break;
|
||||
default: break;
|
||||
}
|
||||
if (percent < 0)
|
||||
return; // monitoring just turned on (or no battery): next time
|
||||
if (!lastState)
|
||||
fprintf(stderr, "[power] battery %d%%, %s, at %.1f s\n", percent, state, now);
|
||||
else if (percent != lastPercent || std::strcmp(state, lastState) != 0)
|
||||
{
|
||||
if (percent != lastPercent)
|
||||
fprintf(stderr, "[power] battery %d%% -> %d%%, %s, at %.1f s\n", lastPercent, percent, state, now);
|
||||
else
|
||||
fprintf(stderr, "[power] battery %d%%, %s -> %s, at %.1f s\n", percent, lastState, state, now);
|
||||
}
|
||||
lastPercent = percent;
|
||||
lastState = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// macOS: the game's window with its picture under a transparent title bar
|
||||
// (apple/macos_window.mm; GitHub pull request #23 by aleszczynskig). The
|
||||
// title isn't shown, the title bar is drawn over the top of the picture
|
||||
// (NSWindowStyleMaskFullSizeContentView), and the close, minimise and zoom
|
||||
// buttons sit over it only while the mouse is in use there (ui.cpp shows and
|
||||
// hides them with the pointer). Fullscreen is macOS's own, unchanged. Main
|
||||
// thread only (AppKit).
|
||||
#pragma once
|
||||
|
||||
struct SDL_Window;
|
||||
|
||||
namespace platform::macos
|
||||
{
|
||||
// Once, after SDL_CreateWindow: watches the window's fullscreen
|
||||
// transitions (for RestoreAfterFullscreen) and, when `windowed` (not
|
||||
// made fullscreen), puts the title bar over the picture now. The content
|
||||
// keeps its size (-setStyleMask: keeps the content rect), so the picture
|
||||
// is the size the window was made at, the saved size, and the frame
|
||||
// loses the title bar's height. A window made fullscreen gets the look
|
||||
// when it leaves fullscreen: not while its Space is opening.
|
||||
void ConfigureWindow(SDL_Window* window, bool windowed);
|
||||
|
||||
// The look again after the window left fullscreen: SDL resets the
|
||||
// window's style around every fullscreen Space (SDL 3.4's Cocoa
|
||||
// windowWillEnterFullScreen and windowDidExitFullScreen), which takes
|
||||
// the full-size content view off. Call it from the game's event loop on
|
||||
// SDL_EVENT_WINDOW_LEAVE_FULLSCREEN, not from an event watch: those run
|
||||
// inside SDL's windowDidExitFullScreen, where putting it back left the
|
||||
// window one title bar shorter after every round trip (AppKit has just
|
||||
// restored the frame without it, and -setStyleMask: keeps the smaller
|
||||
// content) and the next entry into fullscreen sometimes crashed in
|
||||
// AppKit (SDL's reset then landing in the middle of the transition).
|
||||
// Here it keeps the window's frame. Does nothing (false) while a
|
||||
// fullscreen transition is under way (a toggle queued behind another):
|
||||
// the next SDL_EVENT_WINDOW_LEAVE_FULLSCREEN brings it.
|
||||
bool RestoreAfterFullscreen(SDL_Window* window);
|
||||
|
||||
// Shows or hides the close, minimise and zoom buttons.
|
||||
void ShowWindowButtons(SDL_Window* window, bool shown);
|
||||
|
||||
// The height in points of the title bar's strip over the picture (0
|
||||
// when there is none: fullscreen, or before the look is applied), for
|
||||
// laying the UI out below it.
|
||||
float TitleStripHeight(SDL_Window* window);
|
||||
|
||||
// Before SDL_DestroyWindow: stops watching the window.
|
||||
void ForgetWindow(SDL_Window* window);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// A requested termination ends the game the way its window's close button
|
||||
// does: main.cpp's quit path (settings saved, the renderer, UI and window
|
||||
// shut down, the session's crash marker cleared, the log flushed, exit code 0).
|
||||
// - SIGTERM: a shutdown or logout (systemd), `kill`, and the way a launcher
|
||||
// such as Steam asks a game to end (Steam's own reaper only forks, waits
|
||||
// and reaps: the stop comes from the Steam client; SIGKILL, which can't
|
||||
// be caught, is a stop that doesn't wait);
|
||||
// - SIGINT: Ctrl+C in the terminal the game was started from;
|
||||
// - SIGHUP: that terminal closed.
|
||||
// Until v0.1.6 SDL turned SIGTERM and SIGINT into its quit event (SDL puts its
|
||||
// handler only where a signal's action is still the default) once the window
|
||||
// was up, and SIGHUP, or SIGTERM before the window, ended the game on the
|
||||
// spot: no settings saved, and the crash marker left as by a crash
|
||||
// (report/crash_marker.h).
|
||||
//
|
||||
// The handler only records the first request (async-signal-safe); the main
|
||||
// thread's frame loop (video::RunFrame, which also runs the installer and the
|
||||
// startup error page) reads it and leaves; with no window (NFSMW_HEADLESS=1)
|
||||
// main() waits for it. Another request while quitting changes nothing, as
|
||||
// SDL's didn't. A signal the game was started with set to be ignored
|
||||
// (nohup's SIGHUP) stays ignored. SIGKILL can't be caught: a session it ends
|
||||
// keeps its crash marker, and the next launch logs that it ended without
|
||||
// quitting.
|
||||
//
|
||||
// Header only (tests/clean_exit_test.cpp).
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <csignal>
|
||||
|
||||
namespace quitsignals
|
||||
{
|
||||
namespace detail
|
||||
{
|
||||
inline std::atomic<int> g_requested{ 0 };
|
||||
static_assert(std::atomic<int>::is_always_lock_free, "the handler needs a lock-free atomic");
|
||||
|
||||
inline void Handle(int sig)
|
||||
{
|
||||
int none = 0;
|
||||
g_requested.compare_exchange_strong(none, sig, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// The signals a player or the system ends the game with.
|
||||
inline constexpr int kSignals[] = { SIGTERM, SIGINT, SIGHUP };
|
||||
|
||||
// Main thread, before SDL starts (video::Initialize), so SDL leaves these
|
||||
// signals to us.
|
||||
inline void Install()
|
||||
{
|
||||
for (int sig : kSignals)
|
||||
{
|
||||
struct sigaction old{};
|
||||
sigaction(sig, nullptr, &old);
|
||||
if (!(old.sa_flags & SA_SIGINFO) && old.sa_handler == SIG_IGN)
|
||||
continue;
|
||||
struct sigaction sa{};
|
||||
sa.sa_handler = detail::Handle;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = SA_RESTART;
|
||||
sigaction(sig, &sa, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
// The signal that asked the game to quit, 0 if none has. Any thread.
|
||||
inline int Requested()
|
||||
{
|
||||
return detail::g_requested.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// For the log line.
|
||||
inline const char* Name(int sig)
|
||||
{
|
||||
switch (sig)
|
||||
{
|
||||
case SIGTERM: return "SIGTERM";
|
||||
case SIGINT: return "SIGINT";
|
||||
case SIGHUP: return "SIGHUP";
|
||||
default: return "a signal";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
// watch declined the fault) runs on the crashing thread in a signal handler,
|
||||
// so everything here that it calls is async-signal-safe: the report file is
|
||||
// opened at startup (logs/.crash-pending-<session>, empty unless the session
|
||||
// crashes) and renamed to crash-<session>.txt when written; its header, the
|
||||
// crashes: crash_marker.h, removed when the session ends cleanly) and renamed
|
||||
// to crash-<session>.txt when written; its header, the
|
||||
// system summary and the symbol table are ready beforehand; text goes out
|
||||
// with write(2). The one call outside POSIX's list is dladdr (and the
|
||||
// backtrace() it follows), which glibc and macOS implement without
|
||||
@@ -20,6 +21,7 @@
|
||||
#include <stdafx.h>
|
||||
#include "report.h"
|
||||
#include "internal.h"
|
||||
#include "crash_marker.h"
|
||||
|
||||
#include <gpu/command_processor.h>
|
||||
#include <ui/ui.h>
|
||||
@@ -158,7 +160,7 @@ namespace report
|
||||
}
|
||||
#endif
|
||||
|
||||
int s_reportFd = -1;
|
||||
std::atomic<int> s_reportFd{ -1 }; // -1 once the session has ended cleanly (ClearCrashMarker)
|
||||
char s_pendingPath[1024];
|
||||
char s_finalPath[1024];
|
||||
char s_header[2048];
|
||||
@@ -411,7 +413,7 @@ namespace report
|
||||
{
|
||||
memcpy(s_pendingPath, pending.c_str(), pending.size() + 1);
|
||||
memcpy(s_finalPath, finalPath.c_str(), finalPath.size() + 1);
|
||||
s_reportFd = open(s_pendingPath, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
||||
s_reportFd = marker::Arm(s_pendingPath);
|
||||
}
|
||||
if (s_reportFd < 0)
|
||||
fprintf(stderr, "[crash] can't prepare a crash report file in %s: %s\n", dir.c_str(), strerror(errno));
|
||||
@@ -503,18 +505,18 @@ namespace report
|
||||
frame(frames[i]);
|
||||
detail::WriteDirect(text, b.size);
|
||||
|
||||
if (s_reportFd >= 0)
|
||||
if (const int fd = s_reportFd.load(); fd >= 0)
|
||||
{
|
||||
detail::WriteAll(s_reportFd, s_header, s_headerSize);
|
||||
detail::WriteAll(s_reportFd, text + 1, b.size - 1); // (without the leading blank line)
|
||||
detail::WriteAll(fd, s_header, s_headerSize);
|
||||
detail::WriteAll(fd, text + 1, b.size - 1); // (without the leading blank line)
|
||||
static const char recent[] = "\nThe log's last lines:\n";
|
||||
detail::WriteAll(s_reportFd, recent, sizeof(recent) - 1);
|
||||
detail::WriteRecentOutput(s_reportFd, 300);
|
||||
detail::WriteAll(fd, recent, sizeof(recent) - 1);
|
||||
detail::WriteRecentOutput(fd, 300);
|
||||
static const char system[] = "\nSystem (at startup):\n";
|
||||
detail::WriteAll(s_reportFd, system, sizeof(system) - 1);
|
||||
detail::WriteAll(fd, system, sizeof(system) - 1);
|
||||
const char* summary = detail::LoggedSystemSummary();
|
||||
detail::WriteAll(s_reportFd, summary, strlen(summary));
|
||||
fsync(s_reportFd);
|
||||
detail::WriteAll(fd, summary, strlen(summary));
|
||||
fsync(fd);
|
||||
bool renamed = rename(s_pendingPath, s_finalPath) == 0;
|
||||
b.size = 0;
|
||||
b.Str("[crash] crash report: ").Str(renamed ? s_finalPath : s_pendingPath).Char('\n');
|
||||
@@ -522,23 +524,34 @@ namespace report
|
||||
}
|
||||
}
|
||||
|
||||
void NoticePreviousCrash()
|
||||
void ClearCrashMarker()
|
||||
{
|
||||
// A crash being reported is how the session ends: its report stays.
|
||||
if (s_crashing.load())
|
||||
return;
|
||||
if (const int fd = s_reportFd.exchange(-1); fd >= 0)
|
||||
marker::Clear(fd, s_pendingPath);
|
||||
}
|
||||
|
||||
void NoticePreviousCrash(bool lastClearsMarker)
|
||||
{
|
||||
std::filesystem::path dir = LogDirectory();
|
||||
std::string current = std::string(".crash-pending-") + SessionName();
|
||||
std::error_code ec;
|
||||
// Pending files of earlier sessions: empty (no crash: removed), or a
|
||||
// Markers of earlier sessions (crash_marker.h): empty (removed), or a
|
||||
// report the handler couldn't rename.
|
||||
for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
|
||||
for (const marker::Leftover& left : marker::Find(dir, SessionName()))
|
||||
{
|
||||
std::string name = it->path().filename().string();
|
||||
if (!name.starts_with(".crash-pending-") || name == current)
|
||||
std::error_code fileError;
|
||||
if (left.ended == marker::Ended::Crashed)
|
||||
{
|
||||
std::filesystem::rename(left.path, dir / ("crash-" + left.session + ".txt"), fileError);
|
||||
continue;
|
||||
std::error_code sizeError;
|
||||
if (std::filesystem::file_size(it->path(), sizeError) == 0 || sizeError)
|
||||
std::filesystem::remove(it->path(), sizeError);
|
||||
else
|
||||
std::filesystem::rename(it->path(), dir / ("crash-" + name.substr(15) + ".txt"), sizeError);
|
||||
}
|
||||
std::filesystem::remove(left.path, fileError);
|
||||
// A version before v0.1.6 left one after every session.
|
||||
if (lastClearsMarker)
|
||||
fprintf(stderr, "[crash] an earlier session (%s) ended without quitting: it was stopped from outside (SIGKILL: a forced "
|
||||
"quit, the power going) or crashed too hard to write a report; there is no report to send\n", left.session.c_str());
|
||||
}
|
||||
detail::KeepNewest(dir.string(), "crash-", ".txt", 5);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// A session's crash marker, logs/.crash-pending-<session> (crash.cpp): made
|
||||
// empty at startup and kept open for the crash handler, which writes the
|
||||
// crash report into it and renames it crash-<session>.txt. A session that
|
||||
// ends the way it is meant to (the window's close button, Quit Game, Cmd+Q,
|
||||
// a requested termination: platform/quit_signals.h) removes it, so a marker
|
||||
// left in the logs folder is a session that hasn't ended or didn't end
|
||||
// cleanly: empty, it was stopped from outside (SIGKILL, the power went, the
|
||||
// system ended it) or crashed too hard to report; written, the handler's
|
||||
// rename didn't happen. The next launch reports what it finds (crash.cpp's
|
||||
// NoticePreviousCrash). Until v0.1.6 every session left its empty marker,
|
||||
// even after Quit Game, until the next launch removed it, so a marker after
|
||||
// the game had gone (a Steam Frame session its player closed from Steam,
|
||||
// 2026-10-15) said nothing about how it ended.
|
||||
//
|
||||
// POSIX file calls only. Header only (tests/clean_exit_test.cpp).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace report::marker
|
||||
{
|
||||
inline constexpr const char kPrefix[] = ".crash-pending-";
|
||||
|
||||
inline std::filesystem::path PathFor(const std::filesystem::path& dir, const std::string& session)
|
||||
{
|
||||
return dir / (kPrefix + session);
|
||||
}
|
||||
|
||||
// Startup: the session's empty marker, open for the crash handler (-1:
|
||||
// it couldn't be made).
|
||||
inline int Arm(const char* path)
|
||||
{
|
||||
return open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
||||
}
|
||||
|
||||
// The session ended cleanly: the marker closed and removed.
|
||||
inline void Clear(int fd, const char* path)
|
||||
{
|
||||
if (fd >= 0)
|
||||
close(fd);
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
// An earlier session's marker, as a launch finds it.
|
||||
enum class Ended
|
||||
{
|
||||
WithoutQuitting, // empty: stopped from outside, or no report could be written
|
||||
Crashed, // the handler's report (it couldn't rename it)
|
||||
};
|
||||
struct Leftover
|
||||
{
|
||||
std::filesystem::path path;
|
||||
std::string session;
|
||||
Ended ended;
|
||||
};
|
||||
|
||||
// The markers in `dir` other than the `current` session's.
|
||||
inline std::vector<Leftover> Find(const std::filesystem::path& dir, const std::string& current)
|
||||
{
|
||||
std::vector<Leftover> found;
|
||||
std::error_code ec;
|
||||
for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
|
||||
{
|
||||
const std::string name = it->path().filename().string();
|
||||
if (!name.starts_with(kPrefix) || name == kPrefix + current)
|
||||
continue;
|
||||
std::error_code sizeError;
|
||||
const uintmax_t size = std::filesystem::file_size(it->path(), sizeError);
|
||||
found.push_back({ it->path(), name.substr(sizeof(kPrefix) - 1),
|
||||
(size == 0 || sizeError) ? Ended::WithoutQuitting : Ended::Crashed });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,17 @@ namespace report
|
||||
// The crash handler (a signal handler; async-signal-safe): logs the
|
||||
// crash and writes the report. The caller exits afterwards.
|
||||
void WriteCrashReport(int sig, siginfo_t* info, void* context);
|
||||
// The session is ending cleanly (the quit path, any return from main()):
|
||||
// its crash marker is removed (crash_marker.h), so one the next launch
|
||||
// finds is a session that didn't. Last, just before the exit: a crash
|
||||
// after it is only logged. Not while a crash is being reported.
|
||||
void ClearCrashMarker();
|
||||
// Main thread, once toasts can show: tells the player, once, about a
|
||||
// crash report from an earlier session.
|
||||
void NoticePreviousCrash();
|
||||
// crash report from an earlier session, and logs an earlier session that
|
||||
// ended without quitting. `lastClearsMarker`: the session before this
|
||||
// one ran a version that clears its marker at a clean end (this one or
|
||||
// later: v0.1.5 and older left it after every session).
|
||||
void NoticePreviousCrash(bool lastClearsMarker);
|
||||
|
||||
// --- Threads and hangs (watchdog.cpp) ---
|
||||
|
||||
|
||||
@@ -298,7 +298,8 @@ namespace report
|
||||
}
|
||||
text += "Session: " + SessionText() + "\n";
|
||||
text += "Environment: " + EnvironmentText() + "\n";
|
||||
text += "Data: " + GetUserPath().string() + " (logs in logs/), caches: " + GetCachePath().string() + "\n";
|
||||
text += "Data: " + GetUserPath().string() + " (logs in logs/" + (portable::Current().Active() ? "; portable mode" : "")
|
||||
+ "), caches: " + GetCachePath().string() + "\n";
|
||||
text += SettingsText();
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "report.h"
|
||||
#include "internal.h"
|
||||
|
||||
#include <apu/audio.h>
|
||||
#include <cpu/guest_thread.h>
|
||||
#include <cpu/guest_time.h>
|
||||
#include <gpu/command_processor.h>
|
||||
@@ -234,6 +235,9 @@ namespace report
|
||||
text += std::format("Suspended (the app inactive): {} times, {:.1f} s in all before the last resume{}.\n", suspensions,
|
||||
double(guesttime::PausedNs()) / 1e9, guesttime::Suspended() ? "; suspended now" : "");
|
||||
text += "\nCommand processor:\n" + gpu::DescribeState();
|
||||
// The game's sound runs on the render callback, and some of its
|
||||
// waits on its sound commands (GitHub issue #20).
|
||||
text += "\nAudio:\n" + apu::DescribeState();
|
||||
text += "\nThreads, where each one is (host stacks, innermost first; recompiled guest functions are "
|
||||
"sub_<guest address>):\n";
|
||||
for (const ThreadSample& s : samples)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
# AMD FidelityFX Super Resolution 1 (vendored)
|
||||
|
||||
`ffx_a.h` and `ffx_fsr1.h` from https://github.com/GPUOpen-Effects/FidelityFX-FSR at commit
|
||||
a21ffb8f6c13233ba336352bdff293894c706575 (2021-11-15; its `ffx-fsr/` folder), unmodified, MIT
|
||||
license (`LICENSE.txt`, the repository's `license.txt` as it is; also
|
||||
`LICENSES/FidelityFX-FSR-MIT.txt`).
|
||||
|
||||
SHA-256:
|
||||
|
||||
f60e2722fcd13989523b9164d776ab382b3692791767f3bf8bb19967f763f3fb ffx_a.h
|
||||
93c3922362ea7fc99cbcc698ca30c98de4f8c246d1fbb0b09e015ddef38ce3a5 ffx_fsr1.h
|
||||
db089274ce766da70f5b7d791029c3486f9f9e27c8c79c652689603d3192e802 LICENSE.txt
|
||||
|
||||
Used by Settings > Graphics > Upscaling's FSR 1 (`runtime/video/fsr1.cpp`): their 32-bit GLSL
|
||||
EASU and RCAS functions are compiled into the presenter's shaders (the build embeds both files'
|
||||
text, `generated/fsr1_headers.inc`), and their CPU code computes the shaders' constants.
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -143,7 +143,10 @@ namespace ui
|
||||
// refuses for a file chooser, so no picker would open at all.
|
||||
SDL_PropertiesID props = SDL_CreateProperties();
|
||||
SDL_SetPointerProperty(props, SDL_PROP_FILE_DIALOG_WINDOW_POINTER, GetWindow());
|
||||
if (const char* home = std::getenv("HOME"))
|
||||
// Portable mode: it opens in SpeedBreaker's folder, where the game moves with it.
|
||||
if (const portable::Layout& layout = portable::Current(); layout.Active())
|
||||
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_LOCATION_STRING, layout.root.string().c_str());
|
||||
else if (const char* home = std::getenv("HOME"))
|
||||
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_LOCATION_STRING, home);
|
||||
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_TITLE_STRING, "Choose where to install the game");
|
||||
#ifdef __APPLE__
|
||||
|
||||
@@ -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,17 +7,26 @@
|
||||
// pages), and touch as the mouse (on iOS a drag scrolls the rows: ui.cpp).
|
||||
// Advanced also has Save Bug Report (report/bug_report.cpp), Check for
|
||||
// Updates (update_dialog.cpp), the build's identity (version and commit) and
|
||||
// the legal notice.
|
||||
// the legal notice. The footer's last button is Quit Game, which asks first
|
||||
// (quit_game.h; not on iPhone or iPad).
|
||||
#include <stdafx.h>
|
||||
#include "ui.h"
|
||||
#include "quit_game.h"
|
||||
|
||||
#include <report/report.h>
|
||||
#include <user/settings.h>
|
||||
#include <hid/key_map.h> // the Controls page: one row per control, two keys each
|
||||
#include <user/key_names.h> // a key's name in the file, when SDL has none
|
||||
#include <video/frame_rate.h>
|
||||
#include <video/presenter.h>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <imgui_internal.h> // ClosePopupToLevel: a dialog left open when the menu closed
|
||||
#include <SDL3/SDL_keyboard.h> // a key row's label on this layout
|
||||
|
||||
#include <SDL3/SDL_error.h>
|
||||
#include <SDL3/SDL_events.h>
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
namespace ui
|
||||
{
|
||||
@@ -33,9 +42,30 @@ namespace ui
|
||||
bool s_describedBugReport = false; // ...or Save Bug Report's
|
||||
bool s_describedUpdate = false; // ...or Check for Updates'
|
||||
bool s_opened = true; // just opened: take focus
|
||||
// The Controls page (KeyRow). A row's two keys are cells; the chosen
|
||||
// cell is the focused row's, and a wait is for one option of one
|
||||
// row (SettingsMenuTakesKey takes the press before ImGui sees it).
|
||||
Id s_waiting = Id::Count; // the Key option a row waits for; Count: no wait
|
||||
SDL_Scancode s_refused = SDL_SCANCODE_UNKNOWN; // a press the wait wouldn't take; the help line says so
|
||||
SDL_Scancode s_takenKey = SDL_SCANCODE_UNKNOWN; // the key a wait took or refused, until its release
|
||||
Id s_slotRow = Id::Count; // the row whose chosen cell s_slot is
|
||||
int s_slot = 0; // 0: the row's first key, 1: its second
|
||||
constexpr const char* kPressAKey = "Press a key";
|
||||
constexpr const char* kNoKey = "none";
|
||||
|
||||
void EndWait()
|
||||
{
|
||||
s_waiting = Id::Count;
|
||||
s_refused = SDL_SCANCODE_UNKNOWN;
|
||||
}
|
||||
|
||||
constexpr int kCategories = int(Category::Count);
|
||||
|
||||
#if SB_QUIT_GAME
|
||||
constexpr const char* kQuitPopup = "quit";
|
||||
quit::Prompt s_quit;
|
||||
#endif
|
||||
|
||||
bool Pressed(ImGuiKey key)
|
||||
{
|
||||
return ImGui::IsKeyPressed(key, true);
|
||||
@@ -51,6 +81,80 @@ namespace ui
|
||||
return delta;
|
||||
}
|
||||
|
||||
// iPhone and iPad: the Controls page only while a keyboard is
|
||||
// connected (nothing to bind without one).
|
||||
bool PageShown(Category c)
|
||||
{
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
if (c == Category::Controls)
|
||||
return SDL_HasKeyboard();
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
// A key's name for a cell: on this keyboard's layout ("Z" on a
|
||||
// French Mac for the position the file calls "w"), else SDL's name
|
||||
// of the position (the platform's: "Left Option" on a Mac), else the
|
||||
// file's. "none" for no key. Main thread.
|
||||
std::string KeyLabel(double stored)
|
||||
{
|
||||
if (stored == double(settings::keynames::kNone))
|
||||
return kNoKey;
|
||||
if (!(stored >= 1 && stored < double(SDL_SCANCODE_COUNT)))
|
||||
return "?";
|
||||
SDL_Scancode sc = SDL_Scancode(int(stored));
|
||||
const char* name = SDL_GetKeyName(SDL_GetKeyFromScancode(sc, SDL_KMOD_NONE, false));
|
||||
if (!name || !name[0])
|
||||
name = SDL_GetScancodeName(sc);
|
||||
if (!name || !name[0])
|
||||
name = settings::keynames::Token(uint16_t(stored));
|
||||
return name && name[0] ? name : "?";
|
||||
}
|
||||
|
||||
// A key the wait refused, for the help line.
|
||||
std::string RefusedName(SDL_Scancode sc)
|
||||
{
|
||||
std::string name = KeyLabel(double(sc));
|
||||
return name == "?" || name == kNoKey ? "That key" : name;
|
||||
}
|
||||
|
||||
// The other controls that have any of this row's keys ("D-pad
|
||||
// Left"), in page order, each once; "" when none.
|
||||
std::string SharedWith(const hid::keymap::Entry& e)
|
||||
{
|
||||
const double mine[2] = { settings::GetStoredValue(e.id), settings::GetStoredValue(e.id2) };
|
||||
std::string text;
|
||||
for (const auto& other : hid::keymap::kEntries)
|
||||
{
|
||||
if (other.control == e.control)
|
||||
continue;
|
||||
const double theirs[2] = { settings::GetStoredValue(other.id), settings::GetStoredValue(other.id2) };
|
||||
bool shared = false;
|
||||
for (double m : mine)
|
||||
for (double th : theirs)
|
||||
shared |= m != double(settings::keynames::kNone) && m == th;
|
||||
if (shared)
|
||||
text += (text.empty() ? "" : ", ") + std::string(settings::Info(other.id).label);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// A key cell is as wide as the widest name among the 48 keys, "Press
|
||||
// a key" or "none"; a row has two and a gap.
|
||||
float KeyCellWidth()
|
||||
{
|
||||
float width = std::max(ImGui::CalcTextSize(kPressAKey).x, ImGui::CalcTextSize(kNoKey).x);
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.type == settings::Type::Key)
|
||||
width = std::max(width, ImGui::CalcTextSize(KeyLabel(settings::GetStoredValue(o.id)).c_str()).x);
|
||||
return width;
|
||||
}
|
||||
|
||||
float KeyCellGap()
|
||||
{
|
||||
return kSpaceM * Scale();
|
||||
}
|
||||
|
||||
// Widest value this option can show, for the value column.
|
||||
float ValueWidth(const settings::Option& o)
|
||||
{
|
||||
@@ -66,6 +170,9 @@ namespace ui
|
||||
for (const auto& e : o.entries)
|
||||
measure(e.value);
|
||||
break;
|
||||
case settings::Type::Key:
|
||||
width = 2 * KeyCellWidth() + KeyCellGap();
|
||||
break;
|
||||
default:
|
||||
measure(o.min);
|
||||
measure(o.max);
|
||||
@@ -176,6 +283,10 @@ namespace ui
|
||||
marker("ENV", ImGui::GetColorU32(ImGuiCol_TextDisabled));
|
||||
if (settings::RestartPending(o.id))
|
||||
marker("RESTART", ImGui::GetColorU32(ImGuiCol_PlotLines));
|
||||
// The row is the saved choice; the system (the green button)
|
||||
// changed the window this session: say what it is now.
|
||||
if (o.id == Id::Fullscreen && !locked && video::FullscreenWindowDiffers())
|
||||
marker(video::WindowFullscreen() ? "NOW FULLSCREEN" : "NOW WINDOWED", ImGui::GetColorU32(ImGuiCol_TextDisabled));
|
||||
}
|
||||
|
||||
int delta = 0;
|
||||
@@ -235,6 +346,110 @@ namespace ui
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
// A Controls row: the two keys that drive the control, as two cells.
|
||||
// Left and Right choose a cell (the focused row's choice; another
|
||||
// row starts on its first); A or Enter waits for a key for it, which
|
||||
// SettingsMenuTakesKey takes from the event loop before ImGui sees
|
||||
// it; Y or Backspace empties it; X or Delete puts both keys back to
|
||||
// their defaults. A click or tap only selects the row, and the cell
|
||||
// under the pointer, as on the other pages. The wait follows the
|
||||
// focus (the D-pad moving on ends it) and B ends it (below, in
|
||||
// DrawSettings, where B would otherwise close the menu).
|
||||
void KeyRow(const hid::keymap::Entry& e, float valueWidth, bool focus)
|
||||
{
|
||||
const float s = Scale();
|
||||
const float rowHeight = RowHeight();
|
||||
const settings::Option& o = settings::Info(e.id);
|
||||
const Id ids[2] = { e.id, e.id2 };
|
||||
ImGui::PushID(int(e.id));
|
||||
ImGui::TableNextRow(ImGuiTableRowFlags_None, rowHeight);
|
||||
ImGui::TableNextColumn();
|
||||
if (focus)
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + LabelInset());
|
||||
ImVec2 labelPos = ImGui::GetCursorScreenPos();
|
||||
bool activated = ImGui::Selectable("##row", false,
|
||||
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap, ImVec2(0, rowHeight));
|
||||
bool focused = ImGui::IsItemFocused();
|
||||
if (focused || (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenOverlappedByItem) && s_described == Id::Count))
|
||||
s_described = e.id;
|
||||
const float mid = RowLabel(labelPos, o.label);
|
||||
|
||||
if (focused && s_slotRow != e.id)
|
||||
{
|
||||
s_slotRow = e.id;
|
||||
s_slot = 0;
|
||||
}
|
||||
const bool waiting = s_waiting == e.id || s_waiting == e.id2;
|
||||
if (waiting && !focused)
|
||||
EndWait();
|
||||
|
||||
// The cells, where other pages put their value (the arrows'
|
||||
// room kept so the columns line up from page to page).
|
||||
ImGui::TableNextColumn();
|
||||
const float arrow = ImGui::GetFrameHeight();
|
||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (rowHeight - arrow) * 0.5f);
|
||||
const ImVec2 min = ImGui::GetCursorScreenPos();
|
||||
ImGui::Dummy(ImVec2(valueWidth + arrow * 2, arrow));
|
||||
const float gap = KeyCellGap(), cell = (valueWidth - gap) * 0.5f;
|
||||
const float cellX[2] = { min.x + arrow, min.x + arrow + cell + gap };
|
||||
|
||||
const bool byPointer = ImGui::IsMouseReleased(ImGuiMouseButton_Left);
|
||||
if (activated && byPointer)
|
||||
{
|
||||
const float x = ImGui::GetMousePos().x;
|
||||
for (int i = 0; i < 2; i++)
|
||||
if (x >= cellX[i] && x < cellX[i] + cell)
|
||||
s_slot = i;
|
||||
}
|
||||
else if (activated)
|
||||
{
|
||||
s_waiting = ids[s_slot];
|
||||
s_refused = SDL_SCANCODE_UNKNOWN;
|
||||
}
|
||||
else if (focused && s_waiting == Id::Count)
|
||||
{
|
||||
if (Pressed(ImGuiKey_GamepadDpadLeft) || Pressed(ImGuiKey_GamepadLStickLeft) || Pressed(ImGuiKey_LeftArrow))
|
||||
s_slot = 0;
|
||||
if (Pressed(ImGuiKey_GamepadDpadRight) || Pressed(ImGuiKey_GamepadLStickRight) || Pressed(ImGuiKey_RightArrow))
|
||||
s_slot = 1;
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceUp, false) || ImGui::IsKeyPressed(ImGuiKey_Backspace, false))
|
||||
{
|
||||
const settings::Option& slot = settings::Info(ids[s_slot]);
|
||||
LogChange(slot, settings::GetStoredValue(slot.id), settings::Set(slot.id, settings::keynames::kNone));
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceLeft, false) || ImGui::IsKeyPressed(ImGuiKey_Delete, false))
|
||||
for (Id id : ids)
|
||||
LogChange(settings::Info(id), settings::GetStoredValue(id), settings::Reset(id));
|
||||
}
|
||||
|
||||
ImDrawList* draw = ImGui::GetWindowDrawList();
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
const bool waitingHere = s_waiting == ids[i];
|
||||
const bool chosen = focused && s_slot == i;
|
||||
const double stored = settings::GetStoredValue(ids[i]);
|
||||
const std::string text = waitingHere ? kPressAKey : KeyLabel(stored);
|
||||
const ImVec2 size = ImGui::CalcTextSize(text.c_str());
|
||||
ImU32 color;
|
||||
if (waitingHere)
|
||||
{
|
||||
PushAccentText();
|
||||
color = ImGui::GetColorU32(ImGuiCol_Text);
|
||||
PopAccentText();
|
||||
}
|
||||
else if (chosen)
|
||||
color = ImGui::GetColorU32(ImGuiCol_PlotLines);
|
||||
else
|
||||
color = ImGui::GetColorU32(stored == double(settings::keynames::kNone) ? ImGuiCol_TextDisabled : ImGuiCol_Text);
|
||||
const ImVec2 at(cellX[i] + (cell - size.x) * 0.5f, CentredTextTop(mid));
|
||||
draw->AddText(at, color, text.c_str());
|
||||
if (chosen) // underlined too, so an empty cell shows which it is
|
||||
draw->AddLine(ImVec2(at.x, at.y + size.y + 2.0f * s), ImVec2(at.x + size.x, at.y + size.y + 2.0f * s), color, 1.0f * s);
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
const char* BugReportValue()
|
||||
{
|
||||
switch (report::GetBugReportState())
|
||||
@@ -288,6 +503,84 @@ namespace ui
|
||||
OpenUpdateDialog();
|
||||
}
|
||||
|
||||
bool QuitPromptOpen()
|
||||
{
|
||||
#if SB_QUIT_GAME
|
||||
return ImGui::IsPopupOpen(kQuitPopup);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if SB_QUIT_GAME
|
||||
// Quit Game's prompt (quit_game.h), at the settings window's top
|
||||
// level like the other dialogs. True while it is open: B is its, not
|
||||
// the menu's.
|
||||
bool DrawQuitPrompt()
|
||||
{
|
||||
if (!ImGui::IsPopupOpen(kQuitPopup))
|
||||
{
|
||||
if (s_quit.Dismiss() == quit::Outcome::Cancelled)
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (the prompt closed)\n");
|
||||
return false;
|
||||
}
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
if (!ImGui::BeginPopupModal(kQuitPopup, nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar))
|
||||
{
|
||||
if (s_quit.Dismiss() == quit::Outcome::Cancelled)
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (the prompt closed)\n");
|
||||
return false;
|
||||
}
|
||||
s_quit.Shown();
|
||||
ImGui::TextUnformatted("Quit SpeedBreaker? Progress since your last save will be lost.");
|
||||
ImGui::Spacing();
|
||||
const bool quitPressed = ImGui::Button("Quit");
|
||||
ImGui::SameLine();
|
||||
if (s_quit.TakeFocus())
|
||||
ImGui::SetKeyboardFocusHere(); // Cancel (quit::Prompt::kDefaultFocus)
|
||||
const bool cancelPressed = ImGui::Button("Cancel");
|
||||
ImGui::SetItemDefaultFocus();
|
||||
quit::Outcome outcome = quit::Outcome::None;
|
||||
const char* how = "";
|
||||
if (cancelPressed)
|
||||
{
|
||||
outcome = s_quit.Press(quit::Button::Cancel);
|
||||
how = "Cancel";
|
||||
}
|
||||
else if (quitPressed)
|
||||
outcome = s_quit.Press(quit::Button::Quit);
|
||||
else if (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false))
|
||||
{
|
||||
outcome = s_quit.Back();
|
||||
how = "B or Escape";
|
||||
}
|
||||
if (outcome == quit::Outcome::Cancelled)
|
||||
{
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (%s)\n", how);
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
else if (outcome == quit::Outcome::Quit)
|
||||
{
|
||||
// The way out a window close takes: RunFrame returns false on
|
||||
// its next poll and main() shuts the game down.
|
||||
fprintf(stderr, "[ui] Quit Game: quitting (as closing the window does)\n");
|
||||
ImGui::CloseCurrentPopup();
|
||||
SDL_Event e{};
|
||||
e.type = SDL_EVENT_QUIT;
|
||||
e.quit.timestamp = SDL_GetTicksNS();
|
||||
if (!SDL_PushEvent(&e))
|
||||
{
|
||||
fprintf(stderr, "[ui] Quit Game: couldn't queue the quit: %s\n", SDL_GetError());
|
||||
Toast("Couldn't quit. Close the game's window instead.");
|
||||
s_quit.QuitFailed();
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
void HelpLine()
|
||||
{
|
||||
if (s_describedUpdate)
|
||||
@@ -334,12 +627,43 @@ namespace ui
|
||||
return;
|
||||
}
|
||||
const settings::Option& o = settings::Info(s_described);
|
||||
ImGui::TextWrapped("%s", o.description);
|
||||
if (o.type == settings::Type::Key)
|
||||
{
|
||||
const hid::keymap::Entry& e = hid::keymap::EntryOf(o.id);
|
||||
if (s_waiting == e.id || s_waiting == e.id2)
|
||||
{
|
||||
PushAccentText();
|
||||
if (s_refused != SDL_SCANCODE_UNKNOWN)
|
||||
ImGui::Text("%s can't be used here. Press another key; Esc or B cancels.", RefusedName(s_refused).c_str());
|
||||
else
|
||||
ImGui::Text("Press the key for %s. Esc or B cancels.", o.label);
|
||||
PopAccentText();
|
||||
ImGui::TextDisabled(" ");
|
||||
return;
|
||||
}
|
||||
ImGui::TextWrapped("%s", o.description);
|
||||
// The keyboard's keys for what the legend names in
|
||||
// controller terms (Y, X); and a key on several controls is
|
||||
// fine (the defaults share the arrows), but say which, in
|
||||
// case it wasn't meant.
|
||||
std::string also = SharedWith(e);
|
||||
ImGui::TextDisabled("Enter sets the key, Backspace clears it, Delete restores the row.%s%s%s",
|
||||
also.empty() ? "" : " Also: ", also.c_str(), also.empty() ? "" : ".");
|
||||
return;
|
||||
}
|
||||
if (o.id == Id::Fullscreen && video::FullscreenLocked())
|
||||
{
|
||||
ImGui::TextDisabled("Always on in Game Mode: Steam sets the resolution (Properties > Game Resolution).");
|
||||
return;
|
||||
}
|
||||
// The window's own fullscreen button (or the system) changed it for
|
||||
// this session; the setting still decides how the game starts.
|
||||
if (o.id == Id::Fullscreen && video::FullscreenWindowDiffers() && !settings::EnvOverride(o.id))
|
||||
{
|
||||
ImGui::TextDisabled("The window is %s for now. The game starts %s, as set here.",
|
||||
video::WindowFullscreen() ? "fullscreen" : "windowed", settings::GetBool(Id::Fullscreen) ? "fullscreen" : "windowed");
|
||||
return;
|
||||
}
|
||||
// The experiment's finer scale wins over Screen Resolution's.
|
||||
if (float scale = video::PresentScaleOverride(); o.id == Id::PresentScale && scale > 0.0f)
|
||||
ImGui::TextDisabled("NFSMW_PRESENT_SCALE sets %.2f for this run.", double(scale));
|
||||
@@ -396,21 +720,46 @@ namespace ui
|
||||
#endif
|
||||
|
||||
// Pages: LB/RB (Q/E) or the tabs; not under a dialog, which keeps them.
|
||||
const bool dialog = ImGui::IsPopupOpen("reset") || UpdateDialogOpen();
|
||||
const bool dialog = ImGui::IsPopupOpen("reset") || UpdateDialogOpen() || QuitPromptOpen();
|
||||
if (int delta = dialog ? 0 : PageDelta())
|
||||
{
|
||||
s_category = Category((int(s_category) + delta + kCategories) % kCategories);
|
||||
Category next = s_category;
|
||||
do
|
||||
next = Category((int(next) + delta + kCategories) % kCategories);
|
||||
while (!PageShown(next));
|
||||
s_category = next;
|
||||
s_tabPending = true;
|
||||
s_focusFirstRow = true;
|
||||
}
|
||||
if (!PageShown(s_category)) // the keyboard went away while on Controls
|
||||
{
|
||||
s_category = Category::Input;
|
||||
s_tabPending = true;
|
||||
s_focusFirstRow = true;
|
||||
}
|
||||
// X on the Controls page is the rows' (KeyRow: the row's default
|
||||
// keys). To ImGui it is also the window-switching key: a press
|
||||
// starts the toggle and the release moves the cursor to the root
|
||||
// window. Owning the key while the page shows keeps that off; the
|
||||
// rows' IsKeyPressed, which accepts any owner, still sees it.
|
||||
if (s_category == Category::Controls)
|
||||
ImGui::SetKeyOwner(ImGuiKey_GamepadFaceLeft, ImHashStr("controls-x"));
|
||||
s_described = Id::Count;
|
||||
s_describedBugReport = false;
|
||||
s_describedUpdate = false;
|
||||
if (s_waiting != Id::Count && settings::Info(s_waiting).category != s_category)
|
||||
EndWait();
|
||||
// For the B check at the end: B's nav cancel (in ImGui's NewFrame,
|
||||
// before this) takes the waiting row's focus, and KeyRow then ends
|
||||
// the wait before that check could see it.
|
||||
const bool wasWaiting = s_waiting != Id::Count;
|
||||
if (ImGui::BeginTabBar("pages", ImGuiTabBarFlags_NoTooltip))
|
||||
{
|
||||
for (int c = 0; c < kCategories; c++)
|
||||
{
|
||||
Category category = Category(c);
|
||||
if (!PageShown(category))
|
||||
continue;
|
||||
ImGuiTabItemFlags tabFlags = (s_tabPending && category == s_category) ? ImGuiTabItemFlags_SetSelected : 0;
|
||||
ImGui::PushItemFlag(ImGuiItemFlags_NoNav, true);
|
||||
bool open = ImGui::BeginTabItem(settings::CategoryName(category), nullptr, tabFlags);
|
||||
@@ -446,9 +795,12 @@ namespace ui
|
||||
if (rows)
|
||||
{
|
||||
float valueWidth = 0.0f;
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.category == category && !o.hidden)
|
||||
valueWidth = std::max(valueWidth, ValueWidth(o));
|
||||
if (category == Category::Controls)
|
||||
valueWidth = 2 * KeyCellWidth() + KeyCellGap();
|
||||
else
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.category == category && !o.hidden)
|
||||
valueWidth = std::max(valueWidth, ValueWidth(o));
|
||||
if (category == Category::Advanced)
|
||||
for (const char* text : { "Saving...", "Checking...", UpdateRowValue() })
|
||||
valueWidth = std::max(valueWidth, ImGui::CalcTextSize(text).x);
|
||||
@@ -459,12 +811,19 @@ namespace ui
|
||||
{
|
||||
ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthStretch);
|
||||
ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthFixed, valueWidth + arrow * 2 + ValueInset());
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.category == category && !o.hidden)
|
||||
if (category == Category::Controls)
|
||||
for (const auto& e : hid::keymap::kEntries)
|
||||
{
|
||||
OptionRow(o, valueWidth, focusFirst);
|
||||
KeyRow(e, valueWidth, focusFirst);
|
||||
focusFirst = false;
|
||||
}
|
||||
else
|
||||
for (const auto& o : settings::Options())
|
||||
if (o.category == category && !o.hidden)
|
||||
{
|
||||
OptionRow(o, valueWidth, focusFirst);
|
||||
focusFirst = false;
|
||||
}
|
||||
if (category == Category::Advanced)
|
||||
{
|
||||
BugReportRow(valueWidth);
|
||||
@@ -514,9 +873,22 @@ namespace ui
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close"))
|
||||
CloseSettings();
|
||||
#if SB_QUIT_GAME
|
||||
// Last, after Close: Down from the rows lands on the footer's first
|
||||
// button, so Quit Game takes a Right more, and then asks.
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Quit Game") && s_quit.Choose() == quit::Outcome::Asking)
|
||||
{
|
||||
fprintf(stderr, "[ui] Quit Game: asking\n");
|
||||
ImGui::OpenPopup(kQuitPopup);
|
||||
}
|
||||
#endif
|
||||
ImGui::SameLine();
|
||||
{
|
||||
const char* legend = "D-pad: choose, change LB RB: page B: close";
|
||||
// (The Controls page leaves out "D-pad: choose": with Quit Game in the
|
||||
// footer its legend was wider than the window and lost "B: close".)
|
||||
const char* legend = s_category == Category::Controls ? "A: set key Y: clear X: default LB RB: page B: close"
|
||||
: "D-pad: choose, change LB RB: page B: close";
|
||||
float w = ImGui::CalcTextSize(legend).x;
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - w));
|
||||
ImGui::TextDisabled("%s", legend);
|
||||
@@ -546,19 +918,87 @@ namespace ui
|
||||
}
|
||||
if (DrawUpdateDialog())
|
||||
popup = true;
|
||||
#if SB_QUIT_GAME
|
||||
if (DrawQuitPrompt())
|
||||
popup = true;
|
||||
#endif
|
||||
// B (or Esc) closes the menu; while a Controls row was waiting for
|
||||
// a key at the top of this frame it ends the wait instead (the
|
||||
// cursor goes with it, as after the Reset dialog: the next D-pad
|
||||
// press brings it back). The Esc key itself never gets here then:
|
||||
// SettingsMenuTakesKey took its press.
|
||||
if (!popup && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && !ImGui::IsAnyItemActive() &&
|
||||
(ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)))
|
||||
CloseSettings();
|
||||
{
|
||||
if (wasWaiting)
|
||||
EndWait();
|
||||
else
|
||||
CloseSettings();
|
||||
}
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
void ResetSettingsMenu()
|
||||
{
|
||||
s_opened = true;
|
||||
EndWait(); // a wait the menu was closed under is over
|
||||
s_takenKey = SDL_SCANCODE_UNKNOWN;
|
||||
s_slotRow = Id::Count;
|
||||
s_slot = 0;
|
||||
// A dialog the menu was closed under (Start or Back alone closes it
|
||||
// from anywhere) doesn't come back with it.
|
||||
if (ImGuiContext* g = ImGui::GetCurrentContext(); g && g->OpenPopupStack.Size > 0)
|
||||
ImGui::ClosePopupToLevel(0, false);
|
||||
ForgetUpdateDialog();
|
||||
}
|
||||
|
||||
bool SettingsMenuTakesKey(const SDL_KeyboardEvent& key)
|
||||
{
|
||||
// The key a wait took or refused stays down a moment longer. ImGui never saw
|
||||
// it go down, so its OS repeats would read as fresh presses (Enter
|
||||
// would start a new wait, an arrow would move the cursor): they and
|
||||
// its release stay out of ImGui too.
|
||||
if (s_takenKey != SDL_SCANCODE_UNKNOWN && key.scancode == s_takenKey)
|
||||
{
|
||||
if (!key.down)
|
||||
s_takenKey = SDL_SCANCODE_UNKNOWN;
|
||||
if (!key.down || key.repeat)
|
||||
return true;
|
||||
s_takenKey = SDL_SCANCODE_UNKNOWN; // pressed anew: its release went by unseen
|
||||
}
|
||||
if (s_waiting == Id::Count || !key.down || key.repeat)
|
||||
return false;
|
||||
const settings::Option& o = settings::Info(s_waiting);
|
||||
s_takenKey = key.scancode;
|
||||
if (key.scancode == SDL_SCANCODE_ESCAPE)
|
||||
{
|
||||
fprintf(stderr, "[settings] menu: %s.%s: no new key (Esc)\n", o.table, o.key);
|
||||
EndWait();
|
||||
return true;
|
||||
}
|
||||
// Not a key of the table (Cmd / Windows, F1, the media keys), or a
|
||||
// key pressed with Cmd / Windows held: the wait goes on and the help
|
||||
// line says so. (Cmd+, still closes the menu on a Mac: ProcessEvent's
|
||||
// toggle sees the comma before this does; the Cmd press before it
|
||||
// lands here and is refused.) Its repeats and release stay out of
|
||||
// ImGui like a taken key's: s_takenKey is already set.
|
||||
if (key.scancode == SDL_SCANCODE_UNKNOWN || !settings::keynames::Known(uint16_t(key.scancode)) || (key.mod & SDL_KMOD_GUI))
|
||||
{
|
||||
s_refused = key.scancode;
|
||||
fprintf(stderr, "[settings] menu: %s.%s: %s can't be used\n", o.table, o.key, RefusedName(key.scancode).c_str());
|
||||
return true;
|
||||
}
|
||||
double from = settings::GetStoredValue(o.id);
|
||||
LogChange(o, from, settings::Set(o.id, double(key.scancode)));
|
||||
EndWait();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ForgetQuitPrompt()
|
||||
{
|
||||
#if SB_QUIT_GAME
|
||||
if (s_quit.Dismiss() == quit::Outcome::Cancelled)
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (the menu closed)\n");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ui.h.
|
||||
//
|
||||
// The page shown instead of the game when it can't start: today, when the
|
||||
// guest's 4 GB of address space couldn't be reserved (kernel/memory_status.h:
|
||||
// on iOS, an app signed without Extended Virtual Addressing). Before the
|
||||
// game, like the installer: nothing of the game runs behind it. On iOS it
|
||||
// stays up (an app doesn't quit itself; the player swipes it away); elsewhere
|
||||
// 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>
|
||||
|
||||
namespace ui
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr bool kCanQuit = quit::kOffered; // not on iPhone or iPad
|
||||
|
||||
struct ErrorPage
|
||||
{
|
||||
std::string title;
|
||||
std::vector<std::string> paragraphs;
|
||||
std::string details;
|
||||
bool firstFrame = true;
|
||||
bool quit = false;
|
||||
};
|
||||
|
||||
void Draw(ErrorPage& page)
|
||||
{
|
||||
float s = Scale();
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
float width = std::min(viewport->WorkSize.x - 2 * kSpaceL * s, 760.0f * s);
|
||||
float maxHeight = viewport->WorkSize.y - 2 * kSpaceL * s;
|
||||
ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
ImGui::SetNextWindowSizeConstraints(ImVec2(width, 0), ImVec2(width, maxHeight));
|
||||
constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize;
|
||||
if (page.firstFrame || !ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow))
|
||||
ImGui::SetNextWindowFocus();
|
||||
if (!ImGui::Begin("Can't start", nullptr, flags))
|
||||
{
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
TitleText(page.title.c_str());
|
||||
ImGui::Spacing();
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
for (size_t i = 0; i < page.paragraphs.size(); i++)
|
||||
{
|
||||
if (i > 0)
|
||||
ImGui::Dummy(ImVec2(0, kSpaceS * s));
|
||||
ImGui::TextWrapped("%s", page.paragraphs[i].c_str());
|
||||
}
|
||||
if (!page.details.empty())
|
||||
{
|
||||
ImGui::Dummy(ImVec2(0, kSpaceM * s));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
|
||||
ImGui::TextWrapped("%s", page.details.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
ImGui::PopTextWrapPos();
|
||||
if (kCanQuit)
|
||||
{
|
||||
ImGui::Dummy(ImVec2(0, kSpaceS * s));
|
||||
if (page.firstFrame)
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
bool quit = ImGui::Button("Quit");
|
||||
if (quit || ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)
|
||||
|| ImGui::IsKeyPressed(ImGuiKey_GamepadStart, false))
|
||||
page.quit = true;
|
||||
}
|
||||
page.firstFrame = false;
|
||||
ImGui::End();
|
||||
}
|
||||
}
|
||||
|
||||
void RunStartupError(const std::string& title, const std::vector<std::string>& paragraphs, const std::string& details,
|
||||
const std::function<bool()>& runFrame)
|
||||
{
|
||||
if (!Ready())
|
||||
return;
|
||||
ErrorPage page{ title, paragraphs, details };
|
||||
SetModal([&page] { Draw(page); });
|
||||
fprintf(stderr, "[ui] showing \"%s\" (%s)\n", title.c_str(),
|
||||
kCanQuit ? "until it is closed" : "until the app is closed");
|
||||
while (!page.quit && runFrame())
|
||||
;
|
||||
SetModal(nullptr);
|
||||
fprintf(stderr, "[ui] \"%s\" closed\n", title.c_str());
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
// 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>
|
||||
#include <user/settings.h>
|
||||
#include <video/swapchain_policy.h>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <imgui_impl_sdl3.h>
|
||||
@@ -13,6 +15,9 @@
|
||||
#include <imgui_internal.h> // DragToScroll: the window under a touch, its scroll and the active item
|
||||
#include <platform/ios_ui.h>
|
||||
#endif
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
#include <platform/macos_window.h>
|
||||
#endif
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
@@ -106,6 +111,7 @@ namespace ui
|
||||
|
||||
float s_appliedScale = 0.0f;
|
||||
uint32_t s_framebufferW = 0, s_framebufferH = 0; // SetFramebufferSize
|
||||
bool s_displayFromSwapchain = false; // the layout follows the swapchain, not the window (FitUiDisplay)
|
||||
ImGuiStyle s_baseStyle;
|
||||
|
||||
// Back + Start on one controller toggles the settings menu; both must
|
||||
@@ -467,6 +473,93 @@ namespace ui
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
// The mouse cursor over the game (cursor_idle.h). Main thread.
|
||||
CursorIdle s_cursorIdle;
|
||||
bool s_cursorShown = true; // as SDL starts
|
||||
int s_cursorChanges = 0; // logged, the first few
|
||||
|
||||
// A real mouse's movement, click or scroll: not the mouse SDL makes
|
||||
// of a finger or a pen, nor a motion event that moved nothing.
|
||||
bool MouseActivity(const SDL_Event& e)
|
||||
{
|
||||
auto touchOrPen = [](SDL_MouseID which) { return which == SDL_TOUCH_MOUSEID || which == SDL_PEN_MOUSEID; };
|
||||
switch (e.type)
|
||||
{
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
return CursorIdle::Activity(true, touchOrPen(e.motion.which), e.motion.xrel, e.motion.yrel);
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
return CursorIdle::Activity(false, touchOrPen(e.button.which), 0.0f, 0.0f);
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
return CursorIdle::Activity(false, touchOrPen(e.wheel.which), 0.0f, 0.0f);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
bool s_buttonsShown = true; // as AppKit makes the window
|
||||
int s_buttonChanges = 0; // logged, the first few
|
||||
|
||||
// The window's close, minimise and zoom buttons, over the picture in
|
||||
// a window (platform/macos_window.h), come and go with the pointer:
|
||||
// shown while it shows over the window, hidden when it hides (the
|
||||
// mouse still for 2 s), leaves the window, or the app isn't in front
|
||||
// (SDL then gives no window the mouse). In fullscreen they stay:
|
||||
// macOS shows them there only with the menu bar.
|
||||
void UpdateWindowButtons(bool pointerShown)
|
||||
{
|
||||
if (!s_window)
|
||||
return;
|
||||
const bool fullscreen = SDL_GetWindowFlags(s_window) & SDL_WINDOW_FULLSCREEN;
|
||||
const bool shown = fullscreen || (pointerShown && SDL_GetMouseFocus() == s_window);
|
||||
if (shown == s_buttonsShown)
|
||||
return;
|
||||
s_buttonsShown = shown;
|
||||
platform::macos::ShowWindowButtons(s_window, shown);
|
||||
if (++s_buttonChanges <= 6)
|
||||
fprintf(stderr, "[ui] window buttons %s\n",
|
||||
shown ? (fullscreen ? "shown (fullscreen)" : "shown (the pointer is over the window)")
|
||||
: "hidden (the pointer hid or left the window)");
|
||||
}
|
||||
#endif
|
||||
|
||||
// Shows or hides the cursor for now (each frame, and as the menu
|
||||
// opens or closes). While a menu or modal page has the mouse, ImGui's
|
||||
// backend shows it and picks its shape; otherwise it must keep its
|
||||
// hands off, or it shows it again every frame the performance
|
||||
// overlay or a toast is drawn.
|
||||
void UpdateCursor()
|
||||
{
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
const bool menu = s_ready && s_capturing.load(std::memory_order_relaxed);
|
||||
if (s_ready)
|
||||
{
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (menu)
|
||||
io.ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange;
|
||||
else
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
|
||||
}
|
||||
const bool show = s_cursorIdle.Shows(SDL_GetTicksNS(), menu);
|
||||
#if defined(__APPLE__)
|
||||
UpdateWindowButtons(show); // every time: the pointer can leave without hiding
|
||||
#endif
|
||||
if (show == s_cursorShown)
|
||||
return;
|
||||
s_cursorShown = show;
|
||||
show ? SDL_ShowCursor() : SDL_HideCursor();
|
||||
if (++s_cursorChanges <= 6)
|
||||
fprintf(stderr, "[ui] mouse cursor %s\n", show ? (menu ? "shown (a menu has the mouse)" : "shown (the mouse moved)")
|
||||
: "hidden (still for 2 s over the game)");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
bool Initialize(SDL_Window* window, const VulkanTarget& target)
|
||||
{
|
||||
if (s_ready)
|
||||
@@ -553,6 +646,14 @@ namespace ui
|
||||
|
||||
void ProcessEvent(const SDL_Event& e)
|
||||
{
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
if (MouseActivity(e))
|
||||
{
|
||||
// Shown at once, not at the next frame's check.
|
||||
s_cursorIdle.Moved(e.common.timestamp);
|
||||
UpdateCursor();
|
||||
}
|
||||
#endif
|
||||
if (!s_ready)
|
||||
return;
|
||||
bool forward = s_capturing.load(std::memory_order_relaxed);
|
||||
@@ -570,8 +671,14 @@ namespace ui
|
||||
s_settingsOpen ? CloseSettings() : OpenSettings();
|
||||
return;
|
||||
}
|
||||
if (s_settingsOpen && SettingsMenuTakesKey(e.key))
|
||||
return; // a Controls row took it: ImGui doesn't act on it too
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_KEY_UP:
|
||||
if (s_settingsOpen && SettingsMenuTakesKey(e.key))
|
||||
return; // the release of a key a Controls row took
|
||||
break;
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_UP:
|
||||
{
|
||||
@@ -645,6 +752,7 @@ namespace ui
|
||||
void Tick()
|
||||
{
|
||||
s_pollAt = SDL_GetTicksNS();
|
||||
UpdateCursor();
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
// Every frame: SDL's text input or the Files picker can have the
|
||||
// first responder for a while (ios_ui.h). Cheap when nothing changed.
|
||||
@@ -654,15 +762,24 @@ namespace ui
|
||||
|
||||
// NFSMW_UI_KEYS="3:F1,4:Down,4.5:Right": key presses (SDL key names)
|
||||
// at seconds since launch, for testing the menus without a person.
|
||||
// A key is pressed and released at once; "Hold Return" only presses
|
||||
// it, "Repeat Return" is the OS's auto-repeat of a held key (a
|
||||
// key-down marked repeat) and "Release Return" lets it go, for a key
|
||||
// held across other keys.
|
||||
// FocusLost and FocusGained send the window those events instead (as
|
||||
// switching to another window would: Mute in Background). Shot saves
|
||||
// the next presented frame as build/ui_shot_<n>.ppm: on the keys'
|
||||
// own clock, so a capture lands between two keys (the
|
||||
// NFSMW_CHECK_PRESENT_SEC clock starts at the first frame, a second
|
||||
// or more later).
|
||||
// or more later). MouseMove moves the pointer to the window's middle
|
||||
// and 40 points either side in turn, for the cursor's hiding:
|
||||
// SDL_WarpMouseInWindow moves the real pointer (SDL reports a warp as
|
||||
// a motion of no distance, which isn't the mouse moving), then the
|
||||
// motion a mouse moving there makes is posted.
|
||||
constexpr SDL_Keycode kFocusLost = SDLK_EXTENDED_MASK | 0x7F01, kFocusGained = SDLK_EXTENDED_MASK | 0x7F02,
|
||||
kShot = SDLK_EXTENDED_MASK | 0x7F03;
|
||||
struct Key { double time; SDL_Keycode key; };
|
||||
kShot = SDLK_EXTENDED_MASK | 0x7F03, kMouseMove = SDLK_EXTENDED_MASK | 0x7F04;
|
||||
enum class Press : uint8_t { Tap, Hold, Repeat, Release };
|
||||
struct Key { double time; SDL_Keycode key; Press press = Press::Tap; };
|
||||
static const auto start = std::chrono::steady_clock::now();
|
||||
static std::vector<Key> keys = [] {
|
||||
std::vector<Key> parsed;
|
||||
@@ -676,11 +793,20 @@ namespace ui
|
||||
if (colon == std::string::npos)
|
||||
continue;
|
||||
std::string name = item.substr(colon + 1);
|
||||
Press press = Press::Tap;
|
||||
for (auto [prefix, kind] : { std::pair{ "Hold ", Press::Hold }, std::pair{ "Repeat ", Press::Repeat },
|
||||
std::pair{ "Release ", Press::Release } })
|
||||
if (name.starts_with(prefix))
|
||||
{
|
||||
press = kind;
|
||||
name = name.substr(strlen(prefix));
|
||||
}
|
||||
SDL_Keycode key = name == "FocusLost" ? kFocusLost : name == "FocusGained" ? kFocusGained
|
||||
: name == "Shot" ? kShot
|
||||
: name == "MouseMove" ? kMouseMove
|
||||
: SDL_GetKeyFromName(name.c_str());
|
||||
if (key != SDLK_UNKNOWN)
|
||||
parsed.push_back({ std::atof(item.c_str()), key });
|
||||
parsed.push_back({ std::atof(item.c_str()), key, press });
|
||||
else
|
||||
fprintf(stderr, "[ui] NFSMW_UI_KEYS: unknown key %s\n", item.c_str() + colon + 1);
|
||||
}
|
||||
@@ -715,6 +841,28 @@ namespace ui
|
||||
shotDue = keys[next++].time;
|
||||
return;
|
||||
}
|
||||
if (keys[next].key == kMouseMove)
|
||||
{
|
||||
static int moves = 0;
|
||||
int w = 0, h = 0;
|
||||
SDL_GetWindowSize(s_window, &w, &h);
|
||||
const float x = float(w) * 0.5f + float((moves++ % 3) - 1) * 40.0f, y = float(h) * 0.5f;
|
||||
float fromX = x, fromY = y;
|
||||
SDL_GetMouseState(&fromX, &fromY);
|
||||
SDL_WarpMouseInWindow(s_window, x, y);
|
||||
SDL_Event e{};
|
||||
e.type = SDL_EVENT_MOUSE_MOTION;
|
||||
e.motion.timestamp = SDL_GetTicksNS();
|
||||
e.motion.windowID = SDL_GetWindowID(s_window);
|
||||
e.motion.which = 1; // a mouse (not SDL_TOUCH_MOUSEID or SDL_PEN_MOUSEID)
|
||||
e.motion.x = x;
|
||||
e.motion.y = y;
|
||||
e.motion.xrel = x - fromX;
|
||||
e.motion.yrel = y - fromY;
|
||||
SDL_PushEvent(&e);
|
||||
fprintf(stderr, "[ui] NFSMW_UI_KEYS: mouse to %.0f,%.0f\n", x, y);
|
||||
continue;
|
||||
}
|
||||
if (keys[next].key == kFocusLost || keys[next].key == kFocusGained)
|
||||
{
|
||||
SDL_Event e{};
|
||||
@@ -725,8 +873,11 @@ namespace ui
|
||||
fprintf(stderr, "[ui] NFSMW_UI_KEYS: window focus %s\n", keys[next].key == kFocusLost ? "lost" : "gained");
|
||||
continue;
|
||||
}
|
||||
const Press press = keys[next].press;
|
||||
for (bool down : { true, false })
|
||||
{
|
||||
if ((down && press == Press::Release) || (!down && (press == Press::Hold || press == Press::Repeat)))
|
||||
continue;
|
||||
SDL_Event e{};
|
||||
e.type = down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP;
|
||||
e.key.timestamp = SDL_GetTicksNS();
|
||||
@@ -734,6 +885,7 @@ namespace ui
|
||||
e.key.key = keys[next].key;
|
||||
e.key.scancode = SDL_GetScancodeFromKey(keys[next].key, nullptr);
|
||||
e.key.down = down;
|
||||
e.key.repeat = press == Press::Repeat;
|
||||
SDL_PushEvent(&e);
|
||||
}
|
||||
}
|
||||
@@ -804,6 +956,7 @@ namespace ui
|
||||
s_frameBuilt = false;
|
||||
if (!s_ready)
|
||||
return false;
|
||||
UpdateCursor(); // a menu opened or closed by this frame's events
|
||||
// A touch press held back for a three-finger tap goes to ImGui once
|
||||
// a second finger can no longer land in time (a finger resting on a
|
||||
// row, say), without waiting for another touch event. Judged here,
|
||||
@@ -826,21 +979,51 @@ namespace ui
|
||||
return false;
|
||||
ImGui_ImplVulkan_NewFrame();
|
||||
ImGui_ImplSDL3_NewFrame();
|
||||
// SDL gave the window's pixels at the screen's scale; a smaller
|
||||
// swapchain (iOS's present scale) needs its own for the viewport,
|
||||
// clip rectangles and font density.
|
||||
if (ImGuiIO& io = ImGui::GetIO(); s_framebufferW && io.DisplaySize.x > 0 && io.DisplaySize.y > 0)
|
||||
io.DisplayFramebufferScale = ImVec2(float(s_framebufferW) / io.DisplaySize.x, float(s_framebufferH) / io.DisplaySize.y);
|
||||
// SDL gave the window's size and pixels; the UI is drawn into the
|
||||
// swapchain, which can be smaller (iOS's present scale) and, for a
|
||||
// moment, another shape (a view SDL turned portrait while it is
|
||||
// fitted back): its own scale for the viewport, clip rectangles and
|
||||
// font density, and its shape for the layout (and so the UI scale,
|
||||
// which follows the height).
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (s_framebufferW && io.DisplaySize.x > 0 && io.DisplaySize.y > 0)
|
||||
{
|
||||
const video::UiDisplay fit = video::FitUiDisplay(io.DisplaySize.x, io.DisplaySize.y, { s_framebufferW, s_framebufferH });
|
||||
if (fit.fromSwapchain != s_displayFromSwapchain)
|
||||
{
|
||||
s_displayFromSwapchain = fit.fromSwapchain;
|
||||
if (fit.fromSwapchain)
|
||||
fprintf(stderr, "[ui] laying out at the swapchain's %.0fx%.0f points (%ux%u pixels), not the window's %.0fx%.0f\n",
|
||||
fit.width, fit.height, s_framebufferW, s_framebufferH, io.DisplaySize.x, io.DisplaySize.y);
|
||||
else
|
||||
fprintf(stderr, "[ui] laying out at the window's %.0fx%.0f points again\n", io.DisplaySize.x, io.DisplaySize.y);
|
||||
}
|
||||
io.DisplaySize = ImVec2(fit.width, fit.height);
|
||||
io.DisplayFramebufferScale = ImVec2(fit.scaleX, fit.scaleY);
|
||||
}
|
||||
UpdateScale();
|
||||
ImGui::NewFrame();
|
||||
// Panels and toasts lay out in the work area: the window's safe area,
|
||||
// clear of an iPhone's Dynamic Island and home indicator (elsewhere
|
||||
// the whole window, as before).
|
||||
// clear of an iPhone's Dynamic Island and home indicator, and below a
|
||||
// Mac window's title bar where that lies over the picture (the
|
||||
// window's buttons are there: platform/macos_window.h); elsewhere the
|
||||
// whole window, as before.
|
||||
// (The window's, in its points: kept inside the display, which is
|
||||
// the swapchain's shape while FitUiDisplay overrules the window.)
|
||||
if (SDL_Rect safe; s_window && SDL_GetWindowSafeArea(s_window, &safe) && safe.w > 0 && safe.h > 0)
|
||||
{
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
viewport->WorkPos = ImVec2(viewport->Pos.x + float(safe.x), viewport->Pos.y + float(safe.y));
|
||||
viewport->WorkSize = ImVec2(float(safe.w), float(safe.h));
|
||||
float top = float(safe.y);
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
top = std::max(top, platform::macos::TitleStripHeight(s_window));
|
||||
#endif
|
||||
const float x0 = std::clamp(float(safe.x), 0.0f, io.DisplaySize.x), y0 = std::clamp(top, 0.0f, io.DisplaySize.y);
|
||||
const float x1 = std::clamp(float(safe.x + safe.w), x0, io.DisplaySize.x), y1 = std::clamp(float(safe.y + safe.h), y0, io.DisplaySize.y);
|
||||
if (x1 - x0 >= 1.0f && y1 - y0 >= 1.0f)
|
||||
{
|
||||
viewport->WorkPos = ImVec2(viewport->Pos.x + x0, viewport->Pos.y + y0);
|
||||
viewport->WorkSize = ImVec2(x1 - x0, y1 - y0);
|
||||
}
|
||||
}
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
DragToScroll();
|
||||
@@ -922,6 +1105,7 @@ namespace ui
|
||||
s_settingsOpen = false;
|
||||
UpdateCapture();
|
||||
ForgetUpdateDialog();
|
||||
ForgetQuitPrompt();
|
||||
report::NoteMenuClosed();
|
||||
XamNotifySystemUi(false);
|
||||
if (settings::Dirty() && !settings::Save())
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <SDL3/SDL_events.h>
|
||||
#include <SDL3/SDL_video.h>
|
||||
@@ -47,9 +48,11 @@ namespace ui
|
||||
bool Initialize(SDL_Window* window, const VulkanTarget& target);
|
||||
// The swapchain was recreated in another format.
|
||||
void SetFormat(VkFormat format);
|
||||
// The swapchain's size when it isn't the window's pixels (iOS's present
|
||||
// scale: Screen Resolution or NFSMW_PRESENT_SCALE); 0, 0: the window's
|
||||
// (the default).
|
||||
// The swapchain's size (each time it is built). The UI's framebuffer
|
||||
// scale follows it (iOS's present scale: Screen Resolution or
|
||||
// NFSMW_PRESENT_SCALE makes it smaller than the window's pixels), and
|
||||
// its layout too should the window's size disagree with it in shape
|
||||
// (video/swapchain_policy.h: FitUiDisplay). 0, 0: the window's.
|
||||
void SetFramebufferSize(uint32_t width, uint32_t height);
|
||||
// Main thread, with the device idle.
|
||||
void Shutdown();
|
||||
@@ -113,6 +116,13 @@ namespace ui
|
||||
// closes it (A, B, Start, Esc or OK). Nothing without an entry.
|
||||
void ShowWhatsNew(std::string_view version);
|
||||
|
||||
// Main thread, before the game starts (main.cpp): a page saying why the
|
||||
// game can't start (memory_status.h's StartupFailure), drawn until it is
|
||||
// closed (Quit, B, Start, Esc; on iOS only by closing the app) or the
|
||||
// window closes. `runFrame` is video::RunFrame. Nothing without a UI.
|
||||
void RunStartupError(const std::string& title, const std::vector<std::string>& paragraphs, const std::string& details,
|
||||
const std::function<bool()>& runFrame);
|
||||
|
||||
void OpenSettings();
|
||||
void CloseSettings(); // saves any change
|
||||
bool SettingsOpen();
|
||||
@@ -120,6 +130,12 @@ namespace ui
|
||||
// Screens (settings_menu.cpp, perf_overlay.cpp, installer_screen.cpp).
|
||||
void DrawSettings();
|
||||
void ResetSettingsMenu(); // on opening: focus the first row, no dialog left open
|
||||
// Main thread, from ProcessEvent on a key-down or key-up while the menu
|
||||
// is open, after the F1 toggle: true when a Controls row was waiting
|
||||
// for a key and took this one, refused it (the wait goes on), or Esc
|
||||
// cancelled it, or this is a repeat or the release of a key it took or
|
||||
// refused, so ImGui must not see it.
|
||||
bool SettingsMenuTakesKey(const SDL_KeyboardEvent& key);
|
||||
void DrawPerfOverlay();
|
||||
|
||||
// Settings > Advanced > Check for Updates (update_dialog.cpp, the check
|
||||
@@ -137,6 +153,9 @@ namespace ui
|
||||
// The menu closed or reopened: the dialog is gone (a check still running
|
||||
// then ends in a toast).
|
||||
void ForgetUpdateDialog();
|
||||
// The menu closed: Quit Game's prompt (settings_menu.cpp, quit_game.h),
|
||||
// if it was asking, is cancelled.
|
||||
void ForgetQuitPrompt();
|
||||
|
||||
// Shared look (ui.cpp).
|
||||
SDL_Window* GetWindow();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The keys a Controls row can hold (Settings > Controls), named for
|
||||
// settings.toml: "w", "left_shift", "keypad_1", or "none" for no key. The
|
||||
// code is the key's USB HID usage, which is what SDL's scancodes are (A is
|
||||
// 4), so a binding is the physical key whatever the layout; SDL itself
|
||||
// stays out of this header and of settings.cpp, and tests/key_names_test.cpp
|
||||
// checks every row against SDL's name for the code (hid/input.cpp pins a
|
||||
// few at compile time). SDL's own names won't do for the file: some change
|
||||
// once video starts (on a Mac Left Alt becomes "Left Option"), while the
|
||||
// settings load before that, and some keys share one. The table is also
|
||||
// the list of what can be bound: F1 (it opens the menu), Cmd / Windows and
|
||||
// the media keys are left out on purpose. Header only.
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <iterator>
|
||||
#include <string_view>
|
||||
|
||||
namespace settings::keynames
|
||||
{
|
||||
constexpr uint16_t kNone = 0; // no key; "none" in the file
|
||||
constexpr uint16_t kUnknown = 0xFFFF; // Code(): not a name in the table
|
||||
|
||||
struct Key
|
||||
{
|
||||
uint16_t code;
|
||||
const char* token;
|
||||
};
|
||||
|
||||
// Ascending by code. The tokens are SDL's scancode constants in lower
|
||||
// case with underscores: SDL_SCANCODE_LEFTBRACKET is "left_bracket",
|
||||
// SDL_SCANCODE_KP_1 "keypad_1", SDL_SCANCODE_NUMLOCKCLEAR "num_lock".
|
||||
constexpr Key kKeys[] = {
|
||||
{ 4, "a" }, { 5, "b" }, { 6, "c" }, { 7, "d" }, { 8, "e" }, { 9, "f" }, { 10, "g" }, { 11, "h" }, { 12, "i" },
|
||||
{ 13, "j" }, { 14, "k" }, { 15, "l" }, { 16, "m" }, { 17, "n" }, { 18, "o" }, { 19, "p" }, { 20, "q" }, { 21, "r" },
|
||||
{ 22, "s" }, { 23, "t" }, { 24, "u" }, { 25, "v" }, { 26, "w" }, { 27, "x" }, { 28, "y" }, { 29, "z" },
|
||||
{ 30, "1" }, { 31, "2" }, { 32, "3" }, { 33, "4" }, { 34, "5" }, { 35, "6" }, { 36, "7" }, { 37, "8" }, { 38, "9" }, { 39, "0" },
|
||||
{ 40, "return" }, { 41, "escape" }, { 42, "backspace" }, { 43, "tab" }, { 44, "space" },
|
||||
{ 45, "minus" }, { 46, "equals" }, { 47, "left_bracket" }, { 48, "right_bracket" }, { 49, "backslash" },
|
||||
{ 50, "non_us_hash" }, { 51, "semicolon" }, { 52, "apostrophe" }, { 53, "grave" }, { 54, "comma" },
|
||||
{ 55, "period" }, { 56, "slash" }, { 57, "caps_lock" },
|
||||
{ 59, "f2" }, { 60, "f3" }, { 61, "f4" }, { 62, "f5" }, { 63, "f6" }, { 64, "f7" }, { 65, "f8" }, { 66, "f9" },
|
||||
{ 67, "f10" }, { 68, "f11" }, { 69, "f12" },
|
||||
{ 70, "print_screen" }, { 71, "scroll_lock" }, { 72, "pause" }, { 73, "insert" }, { 74, "home" },
|
||||
{ 75, "page_up" }, { 76, "delete" }, { 77, "end" }, { 78, "page_down" },
|
||||
{ 79, "right" }, { 80, "left" }, { 81, "down" }, { 82, "up" },
|
||||
{ 83, "num_lock" }, { 84, "keypad_divide" }, { 85, "keypad_multiply" }, { 86, "keypad_minus" },
|
||||
{ 87, "keypad_plus" }, { 88, "keypad_enter" },
|
||||
{ 89, "keypad_1" }, { 90, "keypad_2" }, { 91, "keypad_3" }, { 92, "keypad_4" }, { 93, "keypad_5" },
|
||||
{ 94, "keypad_6" }, { 95, "keypad_7" }, { 96, "keypad_8" }, { 97, "keypad_9" }, { 98, "keypad_0" },
|
||||
{ 99, "keypad_period" }, { 100, "non_us_backslash" }, { 101, "application" }, { 103, "keypad_equals" },
|
||||
{ 104, "f13" }, { 105, "f14" }, { 106, "f15" }, { 107, "f16" }, { 108, "f17" }, { 109, "f18" },
|
||||
{ 110, "f19" }, { 111, "f20" }, { 112, "f21" }, { 113, "f22" }, { 114, "f23" }, { 115, "f24" },
|
||||
{ 133, "keypad_comma" },
|
||||
{ 135, "international_1" }, { 136, "international_2" }, { 137, "international_3" },
|
||||
{ 138, "international_4" }, { 139, "international_5" }, { 140, "international_6" },
|
||||
{ 224, "left_ctrl" }, { 225, "left_shift" }, { 226, "left_alt" },
|
||||
{ 228, "right_ctrl" }, { 229, "right_shift" }, { 230, "right_alt" },
|
||||
};
|
||||
constexpr size_t kKeyCount = std::size(kKeys);
|
||||
|
||||
namespace detail
|
||||
{
|
||||
constexpr char Lower(char c)
|
||||
{
|
||||
return c >= 'A' && c <= 'Z' ? char(c - 'A' + 'a') : c;
|
||||
}
|
||||
|
||||
// `text` in any case against a lower-case token.
|
||||
constexpr bool Same(std::string_view text, const char* token)
|
||||
{
|
||||
size_t i = 0;
|
||||
for (; i < text.size() && token[i]; i++)
|
||||
if (Lower(text[i]) != token[i])
|
||||
return false;
|
||||
return i == text.size() && token[i] == '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// The file's name for a code: "none" for kNone; nullptr for a code that
|
||||
// isn't a key here.
|
||||
constexpr const char* Token(uint16_t code)
|
||||
{
|
||||
if (code == kNone)
|
||||
return "none";
|
||||
for (const Key& k : kKeys)
|
||||
if (k.code == code)
|
||||
return k.token;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The code for a name in the file, in any case; kUnknown when it isn't one.
|
||||
constexpr uint16_t Code(std::string_view token)
|
||||
{
|
||||
if (detail::Same(token, "none"))
|
||||
return kNone;
|
||||
for (const Key& k : kKeys)
|
||||
if (detail::Same(token, k.token))
|
||||
return k.code;
|
||||
return kUnknown;
|
||||
}
|
||||
|
||||
// A value a Key option can hold: no key, or a key of the table.
|
||||
constexpr bool Known(uint16_t code)
|
||||
{
|
||||
return code == kNone || Token(code) != nullptr;
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,7 @@ namespace
|
||||
|
||||
std::string MigrateUserFolders()
|
||||
{
|
||||
if (portable::Current().Active())
|
||||
return {}; // the home folder's data isn't SpeedBreaker's in portable mode: leave it as it is
|
||||
return MigrateFolder(UserDataBase(), DataFolderName()) + MigrateFolder(CacheBase(), CacheFolderName());
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
|
||||
#include "portable.h"
|
||||
|
||||
// Extracted disc contents (default.xex, NFS/, Movies/): where startup found
|
||||
// or installed the game (install::FindGameInstall, SetGamePath); before
|
||||
// that, NFSMW_GAME_DIR or the repo layout.
|
||||
@@ -54,7 +56,7 @@ inline const char*& CacheFolderName()
|
||||
return name;
|
||||
}
|
||||
|
||||
// Where the per-user folders go.
|
||||
// Where the per-user folders go (outside portable mode: user/portable.h).
|
||||
// macOS: ~/Library/Application Support, ~/Library/Caches
|
||||
// iOS: the same in the app's container; $XDG_DATA_HOME and
|
||||
// $XDG_CACHE_HOME when set (test runs: iOS sets HOME itself)
|
||||
@@ -93,21 +95,41 @@ inline std::filesystem::path CacheBase()
|
||||
#endif
|
||||
}
|
||||
|
||||
// Per-user data: saves, settings, logs and (by default) the installed game.
|
||||
inline std::filesystem::path GetUserPath()
|
||||
// This computer's own data and cache folders, portable mode or not
|
||||
// (install/portable_switch.h copies between them and SpeedBreaker's folder).
|
||||
inline std::filesystem::path HomeUserPath()
|
||||
{
|
||||
return UserDataBase() / DataFolderName();
|
||||
}
|
||||
|
||||
// Caches that can be deleted any time (compiled shaders, pipelines).
|
||||
inline std::filesystem::path GetCachePath()
|
||||
inline std::filesystem::path HomeCachePath()
|
||||
{
|
||||
return CacheBase() / CacheFolderName();
|
||||
}
|
||||
|
||||
// Per-user data: saves, settings, logs and (by default) the installed game.
|
||||
// In portable mode, the data folder in SpeedBreaker's own folder.
|
||||
inline std::filesystem::path GetUserPath()
|
||||
{
|
||||
if (const portable::Layout& layout = portable::Current(); layout.Active())
|
||||
return layout.data;
|
||||
return HomeUserPath();
|
||||
}
|
||||
|
||||
// Caches that can be deleted any time (compiled shaders, pipelines). In
|
||||
// portable mode, the cache folder in SpeedBreaker's own folder.
|
||||
inline std::filesystem::path GetCachePath()
|
||||
{
|
||||
if (const portable::Layout& layout = portable::Current(); layout.Active())
|
||||
return layout.cache;
|
||||
return HomeCachePath();
|
||||
}
|
||||
|
||||
// Renames nfsmw-recomp folders to the current name, leaving the old name as
|
||||
// a link to the new folder (for older builds and scripts). Call first thing
|
||||
// in main; returns what it did, for the log once that is open.
|
||||
// in main (after portable::Detect: in portable mode it does nothing, since
|
||||
// the home folder isn't SpeedBreaker's then); returns what it did, for the
|
||||
// log once that is open.
|
||||
std::string MigrateUserFolders();
|
||||
|
||||
inline std::filesystem::path GetSavePath(bool)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See portable.h.
|
||||
#include "portable.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <format>
|
||||
#include <system_error>
|
||||
|
||||
namespace portable
|
||||
{
|
||||
namespace
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// Normalized, no trailing separator.
|
||||
fs::path Normal(const fs::path& path)
|
||||
{
|
||||
fs::path p = path.lexically_normal();
|
||||
while (p.has_relative_path() && p.filename().empty())
|
||||
p = p.parent_path();
|
||||
return p;
|
||||
}
|
||||
|
||||
// `relative` stays inside the folder it's relative to (not "", ".",
|
||||
// or "../...").
|
||||
bool Inside(const fs::path& relative)
|
||||
{
|
||||
return !relative.empty() && relative != "." && *relative.begin() != "..";
|
||||
}
|
||||
}
|
||||
|
||||
Layout Resolve(const std::filesystem::path& root, bool marker, bool supported)
|
||||
{
|
||||
Layout layout;
|
||||
if (!supported || !marker || root.empty() || !root.is_absolute())
|
||||
return layout;
|
||||
layout.root = Normal(root);
|
||||
layout.data = layout.root / kDataFolder;
|
||||
layout.cache = layout.root / kCacheFolder;
|
||||
return layout;
|
||||
}
|
||||
|
||||
bool MarkerIn(const std::filesystem::path& root)
|
||||
{
|
||||
std::error_code ec;
|
||||
return !root.empty() && fs::is_regular_file(root / kMarker, ec);
|
||||
}
|
||||
|
||||
const Layout& Detect(const std::filesystem::path& root)
|
||||
{
|
||||
CurrentLayout() = Resolve(root, kSupported && MarkerIn(root));
|
||||
return Current();
|
||||
}
|
||||
|
||||
std::string RecordText(const std::filesystem::path& folder, const Layout& layout)
|
||||
{
|
||||
fs::path path = Normal(folder);
|
||||
if (layout.Active())
|
||||
if (fs::path relative = path.lexically_relative(layout.root); Inside(relative))
|
||||
return relative.generic_string();
|
||||
return path.string();
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> ParseRecord(std::string_view text, const Layout& layout)
|
||||
{
|
||||
while (!text.empty() && (text.back() == '\n' || text.back() == '\r'))
|
||||
text.remove_suffix(1);
|
||||
if (text.empty())
|
||||
return std::nullopt;
|
||||
fs::path path{ std::string(text) };
|
||||
// (Records before v0.1.1 kept `--dest dir/`'s trailing separator.)
|
||||
if (path.is_absolute())
|
||||
return Normal(path);
|
||||
if (!layout.Active())
|
||||
return std::nullopt;
|
||||
fs::path relative = path.lexically_normal();
|
||||
if (!Inside(relative) || relative.has_root_path())
|
||||
return std::nullopt;
|
||||
return Normal(layout.root / relative);
|
||||
}
|
||||
|
||||
int CheckWritable(const Layout& layout)
|
||||
{
|
||||
if (!layout.Active())
|
||||
return 0;
|
||||
std::error_code ec;
|
||||
fs::create_directories(layout.data, ec);
|
||||
if (ec)
|
||||
{
|
||||
std::error_condition condition = ec.default_error_condition();
|
||||
return condition.category() == std::generic_category() ? condition.value() : EIO;
|
||||
}
|
||||
// A real write: a read-only mount, a locked card or permissions all
|
||||
// show here, whatever the file system.
|
||||
fs::path probe = layout.data / ".write-check";
|
||||
errno = 0;
|
||||
FILE* f = fopen(probe.string().c_str(), "wb");
|
||||
if (!f)
|
||||
return errno ? errno : EACCES;
|
||||
bool ok = fputc('1', f) != EOF;
|
||||
int error = errno;
|
||||
ok = fclose(f) == 0 && ok;
|
||||
if (!ok && !error)
|
||||
error = errno;
|
||||
fs::remove(probe, ec);
|
||||
return ok ? 0 : (error ? error : EIO);
|
||||
}
|
||||
|
||||
std::string Describe(const Layout& layout)
|
||||
{
|
||||
if (!layout.Active())
|
||||
return {};
|
||||
return std::format("[paths] portable mode ({} in {}): data in {}, caches in {}\n", kMarker, layout.root.string(),
|
||||
layout.data.string(), layout.cache.string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Portable mode (GitHub issue #1): everything SpeedBreaker keeps (settings,
|
||||
// saves, logs, caches and, by default, the installed game) in SpeedBreaker's
|
||||
// own folder, so the whole folder can be moved to another machine and run
|
||||
// there. Opt-in: a file named portable.txt in that folder (the one with
|
||||
// speedbreaker.sh; a development build's program folder; on Windows, the
|
||||
// one with SpeedBreaker.exe: install::BundleFolder()). Its contents are
|
||||
// ignored.
|
||||
//
|
||||
// Why a file: it travels with the folder (an environment variable lives in
|
||||
// a Steam shortcut or a shell profile, per machine, so the next machine
|
||||
// would quietly use its own data folder), nothing makes it by accident (the
|
||||
// download never holds one, and unpacking an update over the folder leaves
|
||||
// it), and players know it from Xenia, Dolphin and DuckStation. Not a data/
|
||||
// folder as the switch: deleting or renaming a folder would flip the mode
|
||||
// without a word, and a leftover can't be told from an opt-in.
|
||||
//
|
||||
// The layout, in SpeedBreaker's folder (the "root"):
|
||||
// data/ what GetUserPath() is otherwise (~/.local/share/speedbreaker),
|
||||
// the same layout: settings.toml, profile_settings.bin, save/,
|
||||
// logs/ (with crash and hang reports), game-location, and game/,
|
||||
// the installer's default
|
||||
// cache/ what GetCachePath() is otherwise (~/.cache/speedbreaker):
|
||||
// SPIR-V (GPU-independent, keyed by shader source) and the Vulkan
|
||||
// pipeline cache (its header names the GPU and driver: another
|
||||
// GPU's driver ignores it and it is rebuilt). Beside data/, so it
|
||||
// can be deleted without touching saves.
|
||||
// Nothing outside the folder is read or written for SpeedBreaker's data:
|
||||
// MigrateUserFolders does nothing, XDG_DATA_HOME and XDG_CACHE_HOME are
|
||||
// ignored, and no symlink is made. The game's location is recorded relative
|
||||
// to the folder when it is inside it (RecordText), so it moves with it.
|
||||
//
|
||||
// Not on Apple platforms: a signed Mac app can't hold data (macOS stops
|
||||
// opening it), a folder beside it is App-Translocated or privacy-prompted in
|
||||
// the common cases, and iOS apps write only their container. There the
|
||||
// layout is never active.
|
||||
//
|
||||
// Switching an existing setup in or out with a copy: install/portable_switch.h
|
||||
// (speedbreaker.sh --portable on|off). By hand, making or deleting the file
|
||||
// switches with nothing copied.
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace portable
|
||||
{
|
||||
inline constexpr const char* kMarker = "portable.txt";
|
||||
inline constexpr const char* kDataFolder = "data";
|
||||
inline constexpr const char* kCacheFolder = "cache";
|
||||
|
||||
#if defined(__APPLE__)
|
||||
inline constexpr bool kSupported = false;
|
||||
#else
|
||||
inline constexpr bool kSupported = true;
|
||||
#endif
|
||||
|
||||
// Where portable mode keeps things; all empty when it's off.
|
||||
struct Layout
|
||||
{
|
||||
std::filesystem::path root; // SpeedBreaker's folder: absolute, normalized, no trailing separator
|
||||
std::filesystem::path data; // root/data
|
||||
std::filesystem::path cache; // root/cache
|
||||
|
||||
bool Active() const { return !root.empty(); }
|
||||
};
|
||||
|
||||
// Pure: the layout for SpeedBreaker's folder `root` (absolute; empty if
|
||||
// unknown), with the marker in it or not. Off unless supported, marked
|
||||
// and absolute.
|
||||
Layout Resolve(const std::filesystem::path& root, bool marker, bool supported = kSupported);
|
||||
|
||||
// The layout in effect: set by Detect at startup, before any other thread
|
||||
// starts and before anything uses a data folder (paths.h reads it).
|
||||
inline Layout& CurrentLayout()
|
||||
{
|
||||
static Layout layout;
|
||||
return layout;
|
||||
}
|
||||
|
||||
inline const Layout& Current()
|
||||
{
|
||||
return CurrentLayout();
|
||||
}
|
||||
|
||||
// Is there a portable.txt in `root`: a file, or a link to one (one stat)?
|
||||
bool MarkerIn(const std::filesystem::path& root);
|
||||
|
||||
// Startup: Resolve(root, MarkerIn(root)) made current, and returned. No
|
||||
// look at the disk on Apple platforms.
|
||||
const Layout& Detect(const std::filesystem::path& root);
|
||||
|
||||
// The text recorded for the game folder `folder` (absolute, links
|
||||
// resolved): relative to the root, with '/' separators, when portable
|
||||
// mode is on and the folder is inside the root; otherwise the absolute
|
||||
// path.
|
||||
std::string RecordText(const std::filesystem::path& folder, const Layout& layout);
|
||||
|
||||
// The folder a record names, or nullopt: an absolute path as it is (no
|
||||
// trailing separator); a relative one only in portable mode, inside the
|
||||
// root (resolved against where the root is now; "../" out of it is
|
||||
// ignored). Trailing newlines are ignored.
|
||||
std::optional<std::filesystem::path> ParseRecord(std::string_view text, const Layout& layout);
|
||||
|
||||
// Startup, portable mode: makes the data folder if needed and writes a
|
||||
// small file there (then deletes it). 0 if that worked, else the errno
|
||||
// (EROFS, EACCES...): settings and saves won't be kept. 0 when off.
|
||||
int CheckWritable(const Layout& layout);
|
||||
|
||||
// One log line: where the data and caches are ("" when off).
|
||||
std::string Describe(const Layout& layout);
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
#include <TargetConditionals.h> // TARGET_OS_IOS: __APPLE__ alone means macOS or iOS
|
||||
#endif
|
||||
|
||||
#include "key_names.h" // the Key options: the keys and their names in the file
|
||||
|
||||
// toml++ (header-only), as vendored by XenonRecomp. The fallback path keeps
|
||||
// this file building before runtime/CMakeLists.txt adds the include
|
||||
// directory (the runtime globs every .cpp under runtime/).
|
||||
@@ -45,9 +47,23 @@ namespace settings
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// FSR 1 isn't offered on iPhone and iPad: its EASU pass costs an
|
||||
// Apple GPU 2-3x the bicubic pass, and a phone's 1x picture is
|
||||
// enlarged about 2x every frame. Without the entry, "fsr1" in
|
||||
// settings.toml or NFSMW_SCALING reads as unknown (bicubic), so the
|
||||
// presenter never makes an FSR 1 shader or pipeline there (it makes
|
||||
// them only for a frame whose pass is FSR 1: video/upscale.h).
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
constexpr bool kFsr1Offered = false;
|
||||
#else
|
||||
constexpr bool kFsr1Offered = true;
|
||||
#endif
|
||||
constexpr EnumEntry kScalingEntries[] = {
|
||||
{ int32_t(ScalingMode::Bicubic), "bicubic", "Bicubic" },
|
||||
{ int32_t(ScalingMode::Linear), "linear", "Bilinear" },
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
{ int32_t(ScalingMode::Fsr1), "fsr1", "FSR 1" },
|
||||
#endif
|
||||
};
|
||||
|
||||
// Frame Rate: the game's frames per second (60, or 30 on a GPU that
|
||||
@@ -83,11 +99,15 @@ namespace settings
|
||||
{ int32_t(AspectMode::Widescreen), "16:9", "16:9" },
|
||||
};
|
||||
|
||||
// The scale in half steps (settings.h); the tokens are what
|
||||
// settings.toml and NFSMW_RESOLUTION hold, so older files read as
|
||||
// before.
|
||||
constexpr EnumEntry kResolutionEntries[] = {
|
||||
{ kResolutionAuto, "auto", "Auto" },
|
||||
{ 1, "1x", "1x (720p)" },
|
||||
{ 2, "2x", "2x (1440p)" },
|
||||
{ 3, "3x", "3x (2160p)" },
|
||||
{ 2, "1x", "1x (720p)" },
|
||||
{ 3, "1.5x", "1.5x (1080p)" },
|
||||
{ 4, "2x", "2x (1440p)" },
|
||||
{ 6, "3x", "3x (2160p)" },
|
||||
};
|
||||
|
||||
constexpr EnumEntry kAnisotropyEntries[] = {
|
||||
@@ -100,6 +120,31 @@ namespace settings
|
||||
{ kAnisotropyGame, "game", "Original" },
|
||||
};
|
||||
|
||||
// A Controls row's key: the key (user/key_names.h, by its name
|
||||
// there) that drives one Xbox 360 control. Two per control, the
|
||||
// second named <key>_2 with the same label. No environment
|
||||
// variable: 48 of them would clutter the file's comments for nobody.
|
||||
constexpr Option KeyOption(Id id, const char* key, const char* label, const char* description, const char* token)
|
||||
{
|
||||
return { .id = id, .category = Category::Controls, .type = Type::Key, .apply = Apply::Live,
|
||||
.table = "keys", .key = key, .label = label, .description = description, .env = nullptr,
|
||||
.defaultValue = double(keynames::Code(token)) };
|
||||
}
|
||||
|
||||
constexpr Option KeyOption2(Id id, const char* key, const char* label, const char* token)
|
||||
{
|
||||
return KeyOption(id, key, label, "A second key for the same control, or none.", token);
|
||||
}
|
||||
|
||||
// A stored or offered value as a key code: kUnknown unless it's a
|
||||
// whole number the key table knows (none included).
|
||||
constexpr uint16_t KeyCode(double v)
|
||||
{
|
||||
if (!(v >= 0 && v < double(keynames::kUnknown)) || v != uint16_t(v))
|
||||
return keynames::kUnknown;
|
||||
return keynames::Known(uint16_t(v)) ? uint16_t(v) : keynames::kUnknown;
|
||||
}
|
||||
|
||||
// Defaults are what the runtime did before settings existed, so an
|
||||
// empty settings.toml changes nothing.
|
||||
constexpr Option kOptions[] = {
|
||||
@@ -126,15 +171,28 @@ namespace settings
|
||||
.env = "NFSMW_AA", .defaultValue = 1 },
|
||||
{ .id = Id::Scaling, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
|
||||
.table = "video", .key = "scaling", .label = "Upscaling",
|
||||
.description = "How the 720p image is scaled to the window. Bicubic is sharper, bilinear softer.",
|
||||
.description = kFsr1Offered
|
||||
? "How the game's picture is enlarged to the window. Bicubic is sharp, bilinear softer. FSR 1 (AMD FidelityFX Super Resolution 1) keeps curves, wires and diagonals smooth when the window is much larger than the Internal Resolution (1x on a 1440p or 4K screen), for a little more GPU time."
|
||||
: "How the game's picture is enlarged to the screen. Bicubic is sharp, bilinear softer.",
|
||||
.env = "NFSMW_SCALING", .defaultValue = double(ScalingMode::Bicubic), .entries = kScalingEntries },
|
||||
{ .id = Id::AspectRatio, .category = Category::Display, .type = Type::Enum, .apply = Apply::Live,
|
||||
.table = "video", .key = "aspect_ratio", .label = "Aspect Ratio",
|
||||
.description = "Fill other screen shapes: a wider view on an ultrawide, a taller one on a Steam Deck or a 4:3 iPad, the HUD at 16:9. Or 16:9 with bars.",
|
||||
.env = "NFSMW_ASPECT", .defaultValue = double(AspectMode::Auto), .entries = kAspectEntries },
|
||||
// Brightness: a power curve on the game's picture, -5..+5 (video/display_tone.h).
|
||||
{ .id = Id::Brightness, .category = Category::Display, .type = Type::Int, .apply = Apply::Live,
|
||||
.table = "video", .key = "brightness", .label = "Brightness",
|
||||
.description = "Lighter or darker shadows and mid-tones, a gentle step at a time, as a TV's brightness. Black and white stay put. 0 is the original picture.",
|
||||
.env = "NFSMW_BRIGHTNESS", .defaultValue = 0, .min = -5, .max = 5, .step = 1 },
|
||||
{ .id = Id::DisplayGamma, .category = Category::Display, .type = Type::Bool, .apply = Apply::Live,
|
||||
.table = "video", .key = "xbox_display_gamma", .label = "Xbox 360 Display Gamma",
|
||||
.description = "The gamma ramp the game itself sets for a real Xbox 360's TV output: a little deeper shadows and brighter mid-tones, as on the console. Off: the flatter picture of SpeedBreaker 0.1.3 and earlier.",
|
||||
.env = "NFSMW_DISPLAY_GAMMA", .defaultValue = 1 },
|
||||
{ .id = Id::Sharpening, .category = Category::Graphics, .type = Type::Float, .apply = Apply::Live,
|
||||
.table = "video", .key = "sharpening", .label = "Sharpening",
|
||||
.description = "Extra sharpness for bicubic upscaling. 0 turns it off.",
|
||||
.description = kFsr1Offered
|
||||
? "Extra sharpness for bicubic and FSR 1 upscaling (for FSR 1, the share of its sharpening pass's full strength). 0 turns it off."
|
||||
: "Extra sharpness for bicubic upscaling. 0 turns it off.",
|
||||
.env = "NFSMW_SHARPEN", .defaultValue = 0.3, .min = 0, .max = 1, .step = 0.05 },
|
||||
{ .id = Id::Anisotropy, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
|
||||
.table = "video", .key = "anisotropic_filtering", .label = "Anisotropic Filtering",
|
||||
@@ -146,7 +204,7 @@ namespace settings
|
||||
.env = "NFSMW_TEXTURE_MIPS", .defaultValue = 1 },
|
||||
{ .id = Id::InternalResolution, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
|
||||
.table = "video", .key = "internal_resolution", .label = "Internal Resolution",
|
||||
.description = "Render the 3D scene above the 360's 720p. Auto: from the screen (1440p: 2x, or 3x2 filling an ultrawide).",
|
||||
.description = "Render the 3D scene above the 360's 720p. 1.5x is 1080p, a little over half the pixels of 2x. Auto: from the screen (1440p: 2x, or 3x2 filling an ultrawide).",
|
||||
.env = "NFSMW_RESOLUTION", .defaultValue = kResolutionAuto, .entries = kResolutionEntries },
|
||||
{ .id = Id::WindowWidth, .category = Category::Display, .type = Type::Int, .apply = Apply::Restart, .hidden = true,
|
||||
.table = "video", .key = "window_width", .label = "Window Width",
|
||||
@@ -182,6 +240,57 @@ namespace settings
|
||||
.table = "input", .key = "vibration", .label = "Vibration",
|
||||
.description = "Controller rumble.",
|
||||
.env = "NFSMW_VIBRATION", .defaultValue = 1 },
|
||||
// Controls (Settings > Controls; hid/key_map.h reads them). The
|
||||
// defaults are v0.1.4's keys: the arrows steer as well as A
|
||||
// and D, and are the D-pad; O/L/K/; are the right stick.
|
||||
KeyOption(Id::KeyRightTrigger, "right_trigger", "Right Trigger", "Gas in the game's default layout.", "w"),
|
||||
KeyOption2(Id::KeyRightTrigger2, "right_trigger_2", "Right Trigger", "none"),
|
||||
KeyOption(Id::KeyLeftTrigger, "left_trigger", "Left Trigger", "Brake and reverse in the game's default layout.", "s"),
|
||||
KeyOption2(Id::KeyLeftTrigger2, "left_trigger_2", "Left Trigger", "none"),
|
||||
KeyOption(Id::KeyLeftStickLeft, "left_stick_left", "Left Stick Left", "Steer left.", "a"),
|
||||
KeyOption2(Id::KeyLeftStickLeft2, "left_stick_left_2", "Left Stick Left", "left"),
|
||||
KeyOption(Id::KeyLeftStickRight, "left_stick_right", "Left Stick Right", "Steer right.", "d"),
|
||||
KeyOption2(Id::KeyLeftStickRight2, "left_stick_right_2", "Left Stick Right", "right"),
|
||||
KeyOption(Id::KeyLeftStickUp, "left_stick_up", "Left Stick Up", "The left stick pushed up (menus).", "up"),
|
||||
KeyOption2(Id::KeyLeftStickUp2, "left_stick_up_2", "Left Stick Up", "none"),
|
||||
KeyOption(Id::KeyLeftStickDown, "left_stick_down", "Left Stick Down", "The left stick pulled down.", "down"),
|
||||
KeyOption2(Id::KeyLeftStickDown2, "left_stick_down_2", "Left Stick Down", "none"),
|
||||
KeyOption(Id::KeyA, "a", "A", "The A button.", "space"),
|
||||
KeyOption2(Id::KeyA2, "a_2", "A", "none"),
|
||||
KeyOption(Id::KeyB, "b", "B", "The B button.", "backspace"),
|
||||
KeyOption2(Id::KeyB2, "b_2", "B", "none"),
|
||||
KeyOption(Id::KeyX, "x", "X", "The X button.", "left_shift"),
|
||||
KeyOption2(Id::KeyX2, "x_2", "X", "none"),
|
||||
KeyOption(Id::KeyY, "y", "Y", "The Y button.", "left_ctrl"),
|
||||
KeyOption2(Id::KeyY2, "y_2", "Y", "none"),
|
||||
KeyOption(Id::KeyLB, "lb", "LB", "The left shoulder button.", "q"),
|
||||
KeyOption2(Id::KeyLB2, "lb_2", "LB", "none"),
|
||||
KeyOption(Id::KeyRB, "rb", "RB", "The right shoulder button.", "e"),
|
||||
KeyOption2(Id::KeyRB2, "rb_2", "RB", "none"),
|
||||
KeyOption(Id::KeyDpadUp, "dpad_up", "D-pad Up", "The D-pad, up.", "up"),
|
||||
KeyOption2(Id::KeyDpadUp2, "dpad_up_2", "D-pad Up", "none"),
|
||||
KeyOption(Id::KeyDpadDown, "dpad_down", "D-pad Down", "The D-pad, down.", "down"),
|
||||
KeyOption2(Id::KeyDpadDown2, "dpad_down_2", "D-pad Down", "none"),
|
||||
KeyOption(Id::KeyDpadLeft, "dpad_left", "D-pad Left", "The D-pad, left.", "left"),
|
||||
KeyOption2(Id::KeyDpadLeft2, "dpad_left_2", "D-pad Left", "none"),
|
||||
KeyOption(Id::KeyDpadRight, "dpad_right", "D-pad Right", "The D-pad, right.", "right"),
|
||||
KeyOption2(Id::KeyDpadRight2, "dpad_right_2", "D-pad Right", "none"),
|
||||
KeyOption(Id::KeyStart, "start", "Start", "Start: pause.", "return"),
|
||||
KeyOption2(Id::KeyStart2, "start_2", "Start", "none"),
|
||||
KeyOption(Id::KeyBack, "back", "Back", "Back.", "escape"),
|
||||
KeyOption2(Id::KeyBack2, "back_2", "Back", "none"),
|
||||
KeyOption(Id::KeyRightStickUp, "right_stick_up", "Right Stick Up", "The right stick pushed up.", "o"),
|
||||
KeyOption2(Id::KeyRightStickUp2, "right_stick_up_2", "Right Stick Up", "none"),
|
||||
KeyOption(Id::KeyRightStickDown, "right_stick_down", "Right Stick Down", "The right stick pulled down.", "l"),
|
||||
KeyOption2(Id::KeyRightStickDown2, "right_stick_down_2", "Right Stick Down", "none"),
|
||||
KeyOption(Id::KeyRightStickLeft, "right_stick_left", "Right Stick Left", "The right stick pushed left.", "k"),
|
||||
KeyOption2(Id::KeyRightStickLeft2, "right_stick_left_2", "Right Stick Left", "none"),
|
||||
KeyOption(Id::KeyRightStickRight, "right_stick_right", "Right Stick Right", "The right stick pushed right.", "semicolon"),
|
||||
KeyOption2(Id::KeyRightStickRight2, "right_stick_right_2", "Right Stick Right", "none"),
|
||||
KeyOption(Id::KeyLeftStickClick, "left_stick_click", "Left Stick Click", "Pressing the left stick in.", "z"),
|
||||
KeyOption2(Id::KeyLeftStickClick2, "left_stick_click_2", "Left Stick Click", "none"),
|
||||
KeyOption(Id::KeyRightStickClick, "right_stick_click", "Right Stick Click", "Pressing the right stick in.", "c"),
|
||||
KeyOption2(Id::KeyRightStickClick2, "right_stick_click_2", "Right Stick Click", "none"),
|
||||
// Advanced
|
||||
{ .id = Id::AsyncShaders, .category = Category::Advanced, .type = Type::Bool, .apply = Apply::Live,
|
||||
.table = "advanced", .key = "async_shaders", .label = "Asynchronous Shaders",
|
||||
@@ -219,7 +328,10 @@ namespace settings
|
||||
if (!found)
|
||||
return false;
|
||||
}
|
||||
if (o.env == nullptr || o.label == nullptr || o.description == nullptr)
|
||||
if (o.type == Type::Key && !(KeyCode(o.defaultValue) != keynames::kUnknown && o.min == 0 && o.max == 0 &&
|
||||
o.step == 0 && o.entries.empty() && !o.hidden))
|
||||
return false;
|
||||
if ((o.env == nullptr) != (o.type == Type::Key) || o.label == nullptr || o.description == nullptr)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -416,6 +528,11 @@ namespace settings
|
||||
if (const EnumEntry* e = FindEntry(o, v))
|
||||
return double(e->value);
|
||||
return {};
|
||||
case Type::Key:
|
||||
// No key, or a key of the table: the file holds its name.
|
||||
if (KeyCode(v) == keynames::kUnknown)
|
||||
return {};
|
||||
return v;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -440,6 +557,8 @@ namespace settings
|
||||
text += std::format(", or a number from {} to {}", o.min, o.max);
|
||||
return text;
|
||||
}
|
||||
case Type::Key:
|
||||
return "a key name such as \"w\", \"left_shift\" or \"keypad_1\", or \"none\" (user/key_names.h lists them)";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -463,6 +582,10 @@ namespace settings
|
||||
if (auto n = ParseNumber(text))
|
||||
return EnumFromNumber(o, *n);
|
||||
return {};
|
||||
case Type::Key:
|
||||
if (uint16_t code = keynames::Code(text); code != keynames::kUnknown)
|
||||
return double(code);
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -491,6 +614,8 @@ namespace settings
|
||||
return n;
|
||||
case Type::Enum:
|
||||
return EnumFromNumber(o, *n);
|
||||
case Type::Key:
|
||||
return {}; // names only
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -556,6 +681,8 @@ namespace settings
|
||||
{
|
||||
const Option& o = kOptions[i];
|
||||
s_envSet[i].store(false, std::memory_order_relaxed);
|
||||
if (!o.env)
|
||||
return; // a Key option: the file alone
|
||||
const char* v = std::getenv(o.env);
|
||||
if (!v)
|
||||
return;
|
||||
@@ -705,6 +832,13 @@ namespace settings
|
||||
if (const EnumEntry* e = FindEntry(o, Default(size_t(o.id))))
|
||||
return std::format("\"{}\"", e->token);
|
||||
return std::format("\"{}\"", FindEntry(o, o.defaultValue)->token);
|
||||
case Type::Key:
|
||||
{
|
||||
uint16_t code = KeyCode(v);
|
||||
if (code == keynames::kUnknown)
|
||||
code = KeyCode(o.defaultValue);
|
||||
return std::format("\"{}\"", keynames::Token(code)); // [a-z0-9_]+: nothing to escape
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -714,8 +848,8 @@ namespace settings
|
||||
std::string out =
|
||||
"# SpeedBreaker settings, written by the game's settings menu. Hand edits\n"
|
||||
"# are fine while the game is closed; unknown keys are dropped on the next\n"
|
||||
"# save. The environment variable after each option overrides it for one\n"
|
||||
"# run without changing this file.\n";
|
||||
"# save. The environment variable after an option overrides it for one run\n"
|
||||
"# without changing this file.\n";
|
||||
const char* table = "";
|
||||
for (size_t i = 0; i < kCount; i++)
|
||||
{
|
||||
@@ -729,8 +863,8 @@ namespace settings
|
||||
out += "\n";
|
||||
std::string range = RangeText(o);
|
||||
range[0] = char(std::toupper(uint8_t(range[0])));
|
||||
out += std::format("# {}: {}\n# {}. ({}{})\n", o.label, o.description, range, o.env,
|
||||
o.apply == Apply::Restart ? "; applies at the next launch" : "");
|
||||
std::string override = o.env ? std::format(" ({}{})", o.env, o.apply == Apply::Restart ? "; applies at the next launch" : "") : "";
|
||||
out += std::format("# {}: {}\n# {}.{}\n", o.label, o.description, range, override);
|
||||
out += std::format("{} = {}\n", o.key, TomlValue(o, values[i]));
|
||||
}
|
||||
std::string lastVersion;
|
||||
@@ -919,6 +1053,7 @@ namespace settings
|
||||
case Category::Graphics: return "Graphics";
|
||||
case Category::Audio: return "Audio";
|
||||
case Category::Input: return "Input";
|
||||
case Category::Controls: return "Controls";
|
||||
case Category::Advanced: return "Advanced";
|
||||
default: return "";
|
||||
}
|
||||
@@ -1056,6 +1191,8 @@ namespace settings
|
||||
double index = delta > 0 ? std::floor(position) + delta : delta < 0 ? std::ceil(position) + delta : position;
|
||||
return *Normalize(o, o.min + index * o.step); // not NaN: v and step are finite
|
||||
}
|
||||
case Type::Key:
|
||||
return value; // a key has no neighbour: the menu takes a key press instead
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1072,6 +1209,11 @@ namespace settings
|
||||
if (const EnumEntry* e = FindEntry(o, value))
|
||||
return e->label;
|
||||
return "?";
|
||||
case Type::Key:
|
||||
{
|
||||
uint16_t code = KeyCode(value);
|
||||
return code == keynames::kUnknown ? "?" : keynames::Token(code);
|
||||
}
|
||||
case Type::Int:
|
||||
case Type::Float:
|
||||
break;
|
||||
@@ -1086,6 +1228,9 @@ namespace settings
|
||||
int64_t n = std::llround(value);
|
||||
if (unit == "%")
|
||||
return std::format("{}%", n);
|
||||
// A range around zero (Brightness) shows its sign: -2, 0, +2.
|
||||
if (o.min < 0 && n > 0)
|
||||
return unit.empty() ? std::format("+{}", n) : std::format("+{} {}", n, unit);
|
||||
return unit.empty() ? std::format("{}", n) : std::format("{} {}", n, unit);
|
||||
}
|
||||
if (unit == "%")
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
// that runs before main() calls Load(), so it would miss the file (it
|
||||
// gets the environment and defaults, with a warning in the log).
|
||||
// - An environment variable (NFSMW_AA=0, ...) wins over the file for that
|
||||
// run and is never written back; EnvOverride() tells the menu.
|
||||
// run and is never written back; EnvOverride() tells the menu. Key
|
||||
// options have none.
|
||||
// - A Restart option keeps its launch value for the whole run. Its
|
||||
// consumers read it once (the window's size), so a change mid-run would
|
||||
// reach only part of them. The menu edits the stored value and shows
|
||||
@@ -46,6 +47,8 @@ namespace settings
|
||||
AntiAliasing,
|
||||
Scaling,
|
||||
AspectRatio,
|
||||
Brightness, // the game's picture (video/display_tone.h)
|
||||
DisplayGamma, // the 360's display gamma ramp (video/display_gamma.h)
|
||||
Sharpening,
|
||||
Anisotropy,
|
||||
Mipmaps,
|
||||
@@ -60,6 +63,34 @@ namespace settings
|
||||
StickDeadzone,
|
||||
TriggerDeadzone,
|
||||
Vibration,
|
||||
// Controls (the [keys] table): the two keyboard keys that drive
|
||||
// each Xbox 360 control, as key codes (user/key_names.h); the
|
||||
// second of a pair is always the first's id + 1 (hid/key_map.h
|
||||
// pairs them). Page order.
|
||||
KeyRightTrigger, KeyRightTrigger2,
|
||||
KeyLeftTrigger, KeyLeftTrigger2,
|
||||
KeyLeftStickLeft, KeyLeftStickLeft2,
|
||||
KeyLeftStickRight, KeyLeftStickRight2,
|
||||
KeyLeftStickUp, KeyLeftStickUp2,
|
||||
KeyLeftStickDown, KeyLeftStickDown2,
|
||||
KeyA, KeyA2,
|
||||
KeyB, KeyB2,
|
||||
KeyX, KeyX2,
|
||||
KeyY, KeyY2,
|
||||
KeyLB, KeyLB2,
|
||||
KeyRB, KeyRB2,
|
||||
KeyDpadUp, KeyDpadUp2,
|
||||
KeyDpadDown, KeyDpadDown2,
|
||||
KeyDpadLeft, KeyDpadLeft2,
|
||||
KeyDpadRight, KeyDpadRight2,
|
||||
KeyStart, KeyStart2,
|
||||
KeyBack, KeyBack2,
|
||||
KeyRightStickUp, KeyRightStickUp2,
|
||||
KeyRightStickDown, KeyRightStickDown2,
|
||||
KeyRightStickLeft, KeyRightStickLeft2,
|
||||
KeyRightStickRight, KeyRightStickRight2,
|
||||
KeyLeftStickClick, KeyLeftStickClick2,
|
||||
KeyRightStickClick, KeyRightStickClick2,
|
||||
// Advanced
|
||||
AsyncShaders,
|
||||
PerformanceOverlay,
|
||||
@@ -67,17 +98,24 @@ namespace settings
|
||||
};
|
||||
constexpr size_t kCount = size_t(Id::Count);
|
||||
|
||||
enum class Type : uint8_t { Bool, Int, Float, Enum };
|
||||
enum class Category : uint8_t { Display, Graphics, Audio, Input, Advanced, Count };
|
||||
// Key: a key code of user/key_names.h (0: no key), saved by its name
|
||||
// there ("w", "left_shift", "none"); the menu takes it from a key press.
|
||||
enum class Type : uint8_t { Bool, Int, Float, Enum, Key };
|
||||
enum class Category : uint8_t { Display, Graphics, Audio, Input, Controls, Advanced, Count };
|
||||
enum class Apply : uint8_t { Live, Restart };
|
||||
|
||||
// Values of the enum options (GetInt returns them).
|
||||
enum class ScalingMode : int32_t { Bicubic = 0, Linear = 1 };
|
||||
// Upscaling: FSR 1 is used only where the picture is enlarged; frames
|
||||
// shown at their size or smaller take the bicubic pass
|
||||
// (video/upscale.h, whose Mode has the same values).
|
||||
enum class ScalingMode : int32_t { Bicubic = 0, Linear = 1, Fsr1 = 2 };
|
||||
// Frame Rate: 60, 30, or Auto (video/frame_rate.h: 60, or 30 while the
|
||||
// device is hot or can't hold 60).
|
||||
constexpr int32_t kFrameRateAuto = 0;
|
||||
// Internal resolution: 1-3 renders at that multiple of 720p; Auto picks
|
||||
// it from the screen area the picture covers (see renderer).
|
||||
// Internal resolution: the multiple of 720p it renders at, in half steps
|
||||
// (2: 1x, 3: 1.5x = 1080p, 4: 2x, 6: 3x; only the tokens "1x", "1.5x"...
|
||||
// are saved, so the numbers are free to change); Auto picks it from the
|
||||
// screen area the picture covers (see renderer).
|
||||
constexpr int32_t kResolutionAuto = 0;
|
||||
// Auto: a screen of another shape is filled (video/picture_fit), the HUD
|
||||
// kept in a centred 16:9 block: a wider one with a wider view (Hor+), a
|
||||
@@ -107,7 +145,7 @@ namespace settings
|
||||
const char* key; // key in that table, e.g. "anti_aliasing"
|
||||
const char* label; // menu label
|
||||
const char* description; // one-line menu help
|
||||
const char* env; // the environment variable that overrides it
|
||||
const char* env; // the environment variable that overrides it; nullptr: none (Key options)
|
||||
int envField = 0; // 0: the whole variable; n: its n-th 'x'-separated field (NFSMW_WINDOW=WxH)
|
||||
double defaultValue; // Bool: 0 or 1; Enum: an entry's value
|
||||
// Int and Float: the range and the menu's step. Enum: the range a
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Post-processing that stays on its side of the 16:9 band's edge
|
||||
// (video/presenter.cpp's anti-aliasing and scaling shaders do this in GLSL,
|
||||
// line for line; tests/band_filter_test.cpp checks it here).
|
||||
//
|
||||
// Under Vert+ and Hor+ the frame holds the 16:9 band (video::BandOf) and the
|
||||
// strips past it, and what the game draws often ends exactly at the band's
|
||||
// edge: the movie player letterboxes (or pillarboxes) a movie into the band
|
||||
// (game/ultrawide.cpp tells it the screen's aspect), and the edge-bar fills
|
||||
// black out the strips (gpu/edge_bars.h). Both filters read across that
|
||||
// edge: the anti-aliasing (an FXAA-style pass at the frame's resolution)
|
||||
// blended the strip's last row towards the band's first, by up to half on a
|
||||
// textured edge, and the scaling (Catmull-Rom from bilinear taps) a few per
|
||||
// cent more; the career intro's movie (storyfmv_rac01) showed as a faint row
|
||||
// just past each band edge (max 40-58 of 255 at 1280x800, the frame's own
|
||||
// rows exactly black). With this each pixel's taps are kept to its own span
|
||||
// of the three ([0, band start), the band, [band end, size)) along each
|
||||
// axis, as if that span were the whole image (clamp to edge): a strip pixel
|
||||
// reads only the strip, a band pixel only the band.
|
||||
//
|
||||
// Units: texels of the frame image, 0 at its edge, texel i covering
|
||||
// [i, i + 1) with its centre at i + 0.5.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::band
|
||||
{
|
||||
// The span along one axis holding coordinate t: [0, lo), [lo, hi) or
|
||||
// [hi, size); its first texel `a` and end `b`.
|
||||
struct Span
|
||||
{
|
||||
int32_t a, b;
|
||||
};
|
||||
|
||||
inline Span SpanOf(float t, int32_t lo, int32_t hi, int32_t size)
|
||||
{
|
||||
if (t < float(lo))
|
||||
return { 0, lo };
|
||||
if (t < float(hi))
|
||||
return { lo, hi };
|
||||
return { hi, size };
|
||||
}
|
||||
|
||||
// A sample coordinate kept between the span's first and last texel
|
||||
// centres: a bilinear fetch there weighs only that span's texels.
|
||||
inline float Clamp(float t, Span s)
|
||||
{
|
||||
return std::clamp(t, float(s.a) + 0.5f, float(s.b) - 0.5f);
|
||||
}
|
||||
|
||||
// A texel index kept in the span (a gather's 2x2: the row or column
|
||||
// past the span's edge reads its edge one instead).
|
||||
inline int32_t ClampTexel(int32_t i, Span s)
|
||||
{
|
||||
return std::clamp(i, s.a, s.b - 1);
|
||||
}
|
||||
|
||||
// FSR 1 (video/fsr1.cpp's GLSL). Its passes work on output pixels: the
|
||||
// picture's pixel i of outSize along an axis has its centre at this
|
||||
// coordinate of the srcSize-texel frame image, and its span is the one
|
||||
// holding it (SpanOf). EASU keeps each of its taps (texels floor(t -
|
||||
// 0.5) - 1 to + 2) in that span with ClampTexel.
|
||||
inline float SourceOf(int32_t i, int32_t outSize, int32_t srcSize)
|
||||
{
|
||||
return (float(i) + 0.5f) * float(srcSize) / float(outSize);
|
||||
}
|
||||
|
||||
// Which span holds t: 0 [0, lo), 1 [lo, hi), 2 [hi, size).
|
||||
inline int SpanIndex(float t, int32_t lo, int32_t hi)
|
||||
{
|
||||
return t < float(lo) ? 0 : t < float(hi) ? 1 : 2;
|
||||
}
|
||||
|
||||
// RCAS's neighbour j (i +- 1) of output pixel i: j, or i itself where
|
||||
// j's centre lies in another span (its span's edge pixel, as ClampTexel
|
||||
// keeps a tap), so the sharpening never reads across the band's edge.
|
||||
inline int32_t RcasNeighbour(int32_t i, int32_t j, int32_t lo, int32_t hi, int32_t outSize, int32_t srcSize)
|
||||
{
|
||||
j = std::clamp(j, 0, outSize - 1);
|
||||
return SpanIndex(SourceOf(j, outSize, srcSize), lo, hi) == SpanIndex(SourceOf(i, outSize, srcSize), lo, hi) ? j : i;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See display_gamma.h.
|
||||
#include "display_gamma.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace video::display_gamma
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// Command processor thread only.
|
||||
Decoder s_decoder;
|
||||
bool s_pwlLogged = false;
|
||||
|
||||
// The last complete table, for the presenter.
|
||||
std::mutex s_mutex;
|
||||
Table s_published;
|
||||
std::atomic<uint64_t> s_generation{ 0 };
|
||||
|
||||
// Since the process started (static initialisation), for the log.
|
||||
const auto s_launch = std::chrono::steady_clock::now();
|
||||
double Seconds()
|
||||
{
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - s_launch).count();
|
||||
}
|
||||
|
||||
// "8->5.7 16->12.5 ...": what a few framebuffer values become, in
|
||||
// 8-bit levels (r=g=b), or per channel when they differ.
|
||||
std::string Describe(const Table& t)
|
||||
{
|
||||
std::string out;
|
||||
bool grey = true;
|
||||
for (const auto& e : t.entry)
|
||||
grey = grey && e[0] == e[1] && e[1] == e[2];
|
||||
char buf[64];
|
||||
for (uint32_t k : { 8u, 16u, 32u, 64u, 96u, 128u, 160u, 192u, 224u, 255u })
|
||||
{
|
||||
const auto& e = t.entry[k];
|
||||
if (grey)
|
||||
snprintf(buf, sizeof(buf), " %u->%.1f", k, e[0] * 255.0 / 1023.0);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), " %u->%.1f/%.1f/%.1f", k, e[0] * 255.0 / 1023.0, e[1] * 255.0 / 1023.0,
|
||||
e[2] * 255.0 / 1023.0);
|
||||
out += buf;
|
||||
}
|
||||
return std::string(grey ? "r=g=b:" : "r/g/b:") + out + (t == Table::Identity() ? " (identity)" : "");
|
||||
}
|
||||
|
||||
void Publish(const Table& t)
|
||||
{
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
if (s_generation.load(std::memory_order_relaxed) != 0 && t == s_published)
|
||||
return; // the same ramp written again
|
||||
s_published = t;
|
||||
}
|
||||
uint64_t n = s_generation.fetch_add(1, std::memory_order_acq_rel) + 1;
|
||||
fprintf(stderr, "[video] display gamma ramp #%llu from the game at %.1f s: %s\n", (unsigned long long)n, Seconds(),
|
||||
Describe(t).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void OnRegisterWrite(uint32_t index, uint32_t value)
|
||||
{
|
||||
if (s_decoder.Write(index, value))
|
||||
{
|
||||
if (s_decoder.TableMode())
|
||||
Publish(s_decoder.table());
|
||||
}
|
||||
if (s_decoder.PwlWrites() && !s_pwlLogged)
|
||||
{
|
||||
s_pwlLogged = true;
|
||||
fprintf(stderr, "[video] display gamma: the game writes a PWL ramp (DC_LUT_PWL_DATA); only the 256-entry table is applied\n");
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t Generation()
|
||||
{
|
||||
return s_generation.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
uint64_t Latest(Table& out)
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
out = s_published;
|
||||
return s_generation.load(std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The display gamma ramp a game programs into the Xbox 360's display
|
||||
// controller: a 256-entry table (10 bits per channel) that the 360 applies to
|
||||
// every pixel at scan-out, after the GPU is done with the frame. Direct3D
|
||||
// writes it through the GPU's DC_LUT_* registers (PM4 type-0 register
|
||||
// writes), from the ramp the game passes to SetGammaRamp, adjusted for the
|
||||
// display by the kernel's VdGetCurrentDisplayGamma (gpu/imports_video.cpp).
|
||||
// Most Wanted and Carbon pass a power curve from their own brightness value
|
||||
// (Most Wanted's 360 menus never change it from the one it sets at boot).
|
||||
//
|
||||
// The command processor feeds every write of those registers to a Decoder
|
||||
// (video/display_gamma.cpp keeps the one in use); a complete table is handed
|
||||
// to the presenter, which applies it while the "Xbox 360 Display Gamma"
|
||||
// setting is on, as it is by default (video/display_tone.h).
|
||||
//
|
||||
// Register protocol after Xenia (src/xenia/gpu/register_table.inc and
|
||||
// CommandProcessor::WriteRegister, BSD license), which follows the M56
|
||||
// (ATI/AMD R5xx) display controller's documentation:
|
||||
// DC_LUT_RW_MODE (0x1921) bit 0: 0 = the 256-entry table, 1 = PWL
|
||||
// DC_LUT_RW_INDEX (0x1922) the entry the next access goes to (bits 0:7);
|
||||
// a write also restarts DC_LUT_SEQ_COLOR at red
|
||||
// DC_LUT_SEQ_COLOR (0x1923) one 10-bit component per write (bits 6:15):
|
||||
// red, green, blue, then the next entry
|
||||
// DC_LUT_PWL_DATA (0x1924) a PWL segment (base 6:15, delta 22:31); three
|
||||
// writes (R, G, B) an entry, 128 entries
|
||||
// DC_LUT_30_COLOR (0x1925) a whole entry: blue 0:9, green 10:19, red
|
||||
// 20:29; then the index moves on by one
|
||||
// DC_LUT_WRITE_EN_MASK (0x1927) which components a write changes: bit 0
|
||||
// blue, 1 green, 2 red
|
||||
// DC_LUTA_CONTROL (0x1930) 0: the table; other values select PWL
|
||||
// Direct3D writes RW_MODE 0, RW_INDEX 0, WRITE_EN_MASK 7, then for each entry
|
||||
// DC_LUT_30_COLOR and RW_INDEX = the next index (a COND_WRITE once the color
|
||||
// register reads back), ending with RW_INDEX = 0x100.
|
||||
//
|
||||
// Header only, no runtime dependencies (tests/display_tone_test.cpp).
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <bitset>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::display_gamma
|
||||
{
|
||||
constexpr uint32_t kRegRwMode = 0x1921;
|
||||
constexpr uint32_t kRegRwIndex = 0x1922;
|
||||
constexpr uint32_t kRegSeqColor = 0x1923;
|
||||
constexpr uint32_t kRegPwlData = 0x1924;
|
||||
constexpr uint32_t kRegColor30 = 0x1925;
|
||||
constexpr uint32_t kRegWriteEnMask = 0x1927;
|
||||
constexpr uint32_t kRegLutaControl = 0x1930;
|
||||
|
||||
// The registers the Decoder follows (the command processor's cheap test).
|
||||
constexpr bool IsLutRegister(uint32_t index)
|
||||
{
|
||||
return index - kRegRwMode <= kRegLutaControl - kRegRwMode;
|
||||
}
|
||||
|
||||
// 256 entries of 10-bit red, green, blue: what the display shows for a
|
||||
// framebuffer value k is entry[k] / 1023.
|
||||
struct Table
|
||||
{
|
||||
std::array<std::array<uint16_t, 3>, 256> entry{};
|
||||
|
||||
bool operator==(const Table&) const = default;
|
||||
|
||||
// The ramp that changes nothing: k * 1023 / 255, rounded.
|
||||
static constexpr Table Identity()
|
||||
{
|
||||
Table t;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
t.entry[k] = { uint16_t((k * 1023 + 127) / 255), uint16_t((k * 1023 + 127) / 255), uint16_t((k * 1023 + 127) / 255) };
|
||||
return t;
|
||||
}
|
||||
};
|
||||
|
||||
class Decoder
|
||||
{
|
||||
public:
|
||||
// One register write, in command-stream order. True when it completed
|
||||
// the table: every entry written since the last completion (Direct3D
|
||||
// rewrites all 256 each time). Writes outside the LUT registers are
|
||||
// ignored.
|
||||
bool Write(uint32_t index, uint32_t value)
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case kRegRwMode:
|
||||
mode_ = value & 1;
|
||||
return false;
|
||||
case kRegRwIndex:
|
||||
index_ = value & 0xFF;
|
||||
component_ = 0;
|
||||
return false;
|
||||
case kRegWriteEnMask:
|
||||
mask_ = value & 7;
|
||||
return false;
|
||||
case kRegLutaControl:
|
||||
lutaControl_ = value;
|
||||
return false;
|
||||
case kRegColor30:
|
||||
{
|
||||
if (mode_ != 0)
|
||||
return false; // the PWL table is written through DC_LUT_PWL_DATA
|
||||
uint32_t k = index_;
|
||||
if (mask_ & 4) Set(k, 0, (value >> 20) & 0x3FF);
|
||||
if (mask_ & 2) Set(k, 1, (value >> 10) & 0x3FF);
|
||||
if (mask_ & 1) Set(k, 2, value & 0x3FF);
|
||||
if (mask_)
|
||||
written_.set(k);
|
||||
component_ = 0;
|
||||
index_ = (index_ + 1) & 0xFF;
|
||||
return Completed();
|
||||
}
|
||||
case kRegSeqColor:
|
||||
{
|
||||
if (mode_ != 0)
|
||||
return false;
|
||||
uint32_t k = index_;
|
||||
// Components in red, green, blue order; the mask's bits are
|
||||
// blue, green, red. Bits 0:5 are hardwired to zero.
|
||||
bool enabled = (mask_ >> (2 - component_)) & 1;
|
||||
if (enabled)
|
||||
{
|
||||
Set(k, component_, (value >> 6) & 0x3FF);
|
||||
seqWritten_[component_] = true;
|
||||
}
|
||||
if (++component_ == 3)
|
||||
{
|
||||
component_ = 0;
|
||||
if (seqWritten_[0] || seqWritten_[1] || seqWritten_[2])
|
||||
written_.set(k);
|
||||
seqWritten_ = {};
|
||||
index_ = (index_ + 1) & 0xFF;
|
||||
return Completed();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case kRegPwlData:
|
||||
pwlWrites_++;
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const Table& table() const { return table_; }
|
||||
// The table is what the display uses (not the PWL ramp).
|
||||
bool TableMode() const { return (lutaControl_ & 0xF) == 0; }
|
||||
// DC_LUT_PWL_DATA writes seen (a game that uses the PWL ramp: not
|
||||
// applied, logged).
|
||||
uint32_t PwlWrites() const { return pwlWrites_; }
|
||||
// Entries written since the last completed table.
|
||||
size_t Pending() const { return written_.count(); }
|
||||
|
||||
private:
|
||||
void Set(uint32_t k, uint32_t c, uint32_t v) { table_.entry[k][c] = uint16_t(v); }
|
||||
|
||||
bool Completed()
|
||||
{
|
||||
if (!written_.all())
|
||||
return false;
|
||||
written_.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
Table table_ = Table::Identity();
|
||||
std::bitset<256> written_;
|
||||
std::array<bool, 3> seqWritten_{};
|
||||
uint32_t mode_ = 0, index_ = 0, component_ = 0, mask_ = 7, lutaControl_ = 0, pwlWrites_ = 0;
|
||||
};
|
||||
|
||||
// The runtime's ramp (video/display_gamma.cpp). Command processor thread:
|
||||
// every write of a LUT register (IsLutRegister), in stream order; a
|
||||
// completed table that differs from the last is published and logged.
|
||||
void OnRegisterWrite(uint32_t index, uint32_t value);
|
||||
// Any thread: bumped by each published table; 0 while the game hasn't
|
||||
// written one.
|
||||
uint64_t Generation();
|
||||
// The last published table and its generation (0: none, `out` is then
|
||||
// the identity).
|
||||
uint64_t Latest(Table& out);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The picture's tone curve in the presenter's last pass (video/presenter.cpp):
|
||||
// Settings > Display > Brightness, and "Xbox 360 Display Gamma" (on by
|
||||
// default: the ramp the game programs into the 360's display output,
|
||||
// video/display_gamma.h). Both only touch the game's picture, never the menus
|
||||
// drawn over it, and with Brightness 0 and the ramp off nothing is applied at
|
||||
// all: the presenter draws with the same pipeline as before (Neutral()).
|
||||
//
|
||||
// Brightness is a power curve on the picture's values, as a console's
|
||||
// brightness option (and the game's own) works: black stays black, white
|
||||
// stays white, the shadows and mid-tones move. Each step multiplies the
|
||||
// exponent by kStepRatio: mid-grey moves about 6 levels of 255 a step, and
|
||||
// the ends (-5, +5) reach about as far as the games' own brightness range
|
||||
// does from its default (their ramp's exponent runs 1.06 .. 0.57 around
|
||||
// 0.815: x1.30 .. x0.70; here x1.40 .. x0.71).
|
||||
//
|
||||
// The 360's ramp, when on, comes first (it is the console's output, before
|
||||
// the TV), then Brightness.
|
||||
//
|
||||
// The lookup table is 256 entries per channel, for framebuffer values k/255;
|
||||
// the shader samples it with linear filtering at (v * 255 + 0.5) / 256, so a
|
||||
// value between two entries (a scaled pixel) is interpolated.
|
||||
//
|
||||
// Header only, no runtime dependencies (tests/display_tone_test.cpp).
|
||||
#pragma once
|
||||
#include "display_gamma.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::tone
|
||||
{
|
||||
constexpr int32_t kBrightnessMin = -5;
|
||||
constexpr int32_t kBrightnessMax = 5;
|
||||
constexpr int32_t kBrightnessDefault = 0;
|
||||
// The exponent's factor per step (brighter: smaller exponent).
|
||||
constexpr double kStepRatio = 0.935;
|
||||
|
||||
// The power applied to the picture's values at a Brightness step
|
||||
// (clamped to the range): 1 at 0.
|
||||
inline double BrightnessExponent(int32_t step)
|
||||
{
|
||||
step = std::clamp(step, kBrightnessMin, kBrightnessMax);
|
||||
return step == 0 ? 1.0 : std::pow(kStepRatio, double(step));
|
||||
}
|
||||
|
||||
// Brightness alone on one value in 0..1.
|
||||
inline double ApplyBrightness(double v, int32_t step)
|
||||
{
|
||||
v = std::clamp(v, 0.0, 1.0);
|
||||
double e = BrightnessExponent(step);
|
||||
return e == 1.0 ? v : std::pow(v, e);
|
||||
}
|
||||
|
||||
// Nothing to apply: the presenter keeps its plain pipeline.
|
||||
inline bool Neutral(int32_t brightness, bool useRamp)
|
||||
{
|
||||
return std::clamp(brightness, kBrightnessMin, kBrightnessMax) == 0 && !useRamp;
|
||||
}
|
||||
|
||||
// out[k][c]: what framebuffer value k/255 of channel c (red, green, blue)
|
||||
// becomes, 0..1.
|
||||
struct Lut
|
||||
{
|
||||
std::array<std::array<float, 3>, 256> entry{};
|
||||
};
|
||||
|
||||
// The table for a Brightness step and, when `ramp` is given, the 360's
|
||||
// display ramp before it.
|
||||
inline Lut BuildLut(int32_t brightness, const display_gamma::Table* ramp)
|
||||
{
|
||||
Lut lut;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
{
|
||||
double v = ramp ? ramp->entry[k][c] / 1023.0 : k / 255.0;
|
||||
lut.entry[k][c] = float(ApplyBrightness(v, brightness));
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
// IEEE half (the LUT image's R16G16B16A16_SFLOAT) from a float in 0..1,
|
||||
// rounded to nearest even. Values outside 0..1 are clamped.
|
||||
inline uint16_t ToHalf(float f)
|
||||
{
|
||||
f = std::clamp(f, 0.0f, 1.0f);
|
||||
if (f == 0.0f)
|
||||
return 0;
|
||||
int e = 0;
|
||||
float m = std::frexp(f, &e); // f = m * 2^e, m in [0.5, 1)
|
||||
// Half: value = (1 + frac/1024) * 2^(exp - 15), exp 1..30 (normal),
|
||||
// or frac/1024 * 2^-14 (subnormal, exp 0).
|
||||
int exp = e - 1 + 15;
|
||||
if (exp <= 0)
|
||||
{
|
||||
// Subnormal: frac = f * 2^24.
|
||||
double frac = std::nearbyint(double(f) * 16777216.0);
|
||||
return uint16_t(frac);
|
||||
}
|
||||
double frac = (double(m) * 2.0 - 1.0) * 1024.0;
|
||||
double r = std::nearbyint(frac); // ties to even (default rounding mode)
|
||||
if (r >= 1024.0)
|
||||
{
|
||||
r = 0.0;
|
||||
exp++;
|
||||
}
|
||||
return uint16_t((exp << 10) | int(r));
|
||||
}
|
||||
|
||||
inline float FromHalf(uint16_t h)
|
||||
{
|
||||
int exp = (h >> 10) & 0x1F, frac = h & 0x3FF;
|
||||
if (exp == 0)
|
||||
return std::ldexp(float(frac), -24);
|
||||
return std::ldexp(1.0f + frac / 1024.0f, exp - 15);
|
||||
}
|
||||
|
||||
// The LUT image's texels: 256 x RGBA16F (alpha 1).
|
||||
inline std::array<uint16_t, 256 * 4> ToHalfTexels(const Lut& lut)
|
||||
{
|
||||
std::array<uint16_t, 256 * 4> texels{};
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
{
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
texels[k * 4 + c] = ToHalf(lut.entry[k][c]);
|
||||
texels[k * 4 + 3] = ToHalf(1.0f);
|
||||
}
|
||||
return texels;
|
||||
}
|
||||
|
||||
// What the shader does with the table (kToneGlsl in presenter.cpp), for
|
||||
// one channel value v in 0..1: linear between the two entries around
|
||||
// v * 255.
|
||||
inline double Sample(const Lut& lut, uint32_t channel, double v)
|
||||
{
|
||||
double x = std::clamp(v, 0.0, 1.0) * 255.0;
|
||||
uint32_t i = std::min(uint32_t(x), 254u);
|
||||
double t = x - i;
|
||||
return lut.entry[i][channel] * (1.0 - t) + lut.entry[i + 1][channel] * t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See fsr1.h.
|
||||
#include "fsr1.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
// AMD's headers (thirdparty/fsr1, MIT), as text for the shaders: k_ffx_a and
|
||||
// k_ffx_fsr1 (runtime/CMakeLists.txt).
|
||||
#include <fsr1_headers.inc>
|
||||
|
||||
// The same headers' CPU code, for the constants (it defines short type
|
||||
// names and many helpers this file doesn't use).
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wunused-function"
|
||||
#pragma GCC diagnostic ignored "-Wunused-parameter"
|
||||
#define A_CPU 1
|
||||
#include <thirdparty/fsr1/ffx_a.h>
|
||||
#include <thirdparty/fsr1/ffx_fsr1.h>
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
namespace video::fsr1
|
||||
{
|
||||
namespace
|
||||
{
|
||||
std::string Text(const unsigned char* bytes)
|
||||
{
|
||||
return std::string(reinterpret_cast<const char*>(bytes));
|
||||
}
|
||||
|
||||
// Both shaders: AMD's 32-bit GLSL path (no extensions needed).
|
||||
constexpr const char* kPrelude = R"(#version 460
|
||||
#define A_GPU 1
|
||||
#define A_GLSL 1
|
||||
)";
|
||||
|
||||
// EASU, one 8x8 group per 64 pixels of the picture. Its 12 taps come
|
||||
// from 4 gathers (FsrEasuRF..BF at the corners between texels);
|
||||
// under Hor+ and Vert+ (bandOn) each of a gather's 2x2 texels is
|
||||
// fetched instead and kept in this pixel's span of the band's three
|
||||
// along each axis (band_filter.h's SpanOf and ClampTexel), so a strip
|
||||
// pixel reads only the strip and a band pixel only the band.
|
||||
// band: the band's columns x..y and rows z..w in texels (video::BandOf).
|
||||
constexpr const char* kEasuHead = R"(
|
||||
layout(local_size_x = 8, local_size_y = 8) in;
|
||||
layout(set = 0, binding = 0) uniform sampler2D src;
|
||||
layout(set = 0, binding = 1, rgba8) uniform writeonly image2D dst;
|
||||
layout(push_constant) uniform P { uvec4 con0, con1, con2, con3; ivec4 band; ivec2 outSize; ivec2 srcSize; uint bandOn; } p;
|
||||
|
||||
// This pixel's span: its first and last texels along each axis.
|
||||
ivec2 spanLo = ivec2(0), spanHi = ivec2(0);
|
||||
|
||||
// band_filter.h's SpanOf: [0, lo), [lo, hi) or [hi, size) holding t, as its first and end texels.
|
||||
ivec2 spanOf(float t, int lo, int hi, int size)
|
||||
{
|
||||
return t < float(lo) ? ivec2(0, lo) : t < float(hi) ? ivec2(lo, hi) : ivec2(hi, size);
|
||||
}
|
||||
|
||||
vec3 texelIn(ivec2 t)
|
||||
{
|
||||
return texelFetch(src, clamp(t, spanLo, spanHi), 0).rgb;
|
||||
}
|
||||
|
||||
// What a gather at q reads, each texel kept in the span, in a gather's
|
||||
// order: (x0, y1), (x1, y1), (x1, y0), (x0, y0).
|
||||
vec4 quadIn(vec2 q, int c)
|
||||
{
|
||||
ivec2 t = ivec2(floor(q * vec2(p.srcSize) - 0.5));
|
||||
return vec4(texelIn(t + ivec2(0, 1))[c], texelIn(t + ivec2(1, 1))[c], texelIn(t + ivec2(1, 0))[c], texelIn(t)[c]);
|
||||
}
|
||||
|
||||
AF4 FsrEasuRF(AF2 q) { return p.bandOn == 0u ? textureGather(src, q, 0) : quadIn(q, 0); }
|
||||
AF4 FsrEasuGF(AF2 q) { return p.bandOn == 0u ? textureGather(src, q, 1) : quadIn(q, 1); }
|
||||
AF4 FsrEasuBF(AF2 q) { return p.bandOn == 0u ? textureGather(src, q, 2) : quadIn(q, 2); }
|
||||
#define FSR_EASU_F 1
|
||||
)";
|
||||
|
||||
constexpr const char* kEasuMain = R"(
|
||||
void main()
|
||||
{
|
||||
uvec2 ip = gl_GlobalInvocationID.xy;
|
||||
if (ip.x >= uint(p.outSize.x) || ip.y >= uint(p.outSize.y))
|
||||
return;
|
||||
if (p.bandOn != 0u)
|
||||
{
|
||||
// Where this pixel's centre falls in the frame image (texels).
|
||||
vec2 pos = (vec2(ip) + 0.5) * vec2(p.srcSize) / vec2(p.outSize);
|
||||
ivec2 sx = spanOf(pos.x, p.band.x, p.band.y, p.srcSize.x), sy = spanOf(pos.y, p.band.z, p.band.w, p.srcSize.y);
|
||||
spanLo = ivec2(sx.x, sy.x);
|
||||
spanHi = ivec2(sx.y, sy.y) - 1;
|
||||
}
|
||||
AF3 c;
|
||||
FsrEasuF(c, ip, p.con0, p.con1, p.con2, p.con3);
|
||||
imageStore(dst, ivec2(ip), vec4(c, 1.0));
|
||||
}
|
||||
)";
|
||||
|
||||
// RCAS, drawn over the picture's place in the swapchain image (the
|
||||
// viewport at origin, outSize): src is EASU's image. Under Hor+ and
|
||||
// Vert+ a neighbour on the other side of the band's edge (its centre
|
||||
// maps into another span than this pixel's, by EASU's rule) reads
|
||||
// this pixel instead: the span's edge, as ClampTexel keeps a tap.
|
||||
// con.x 0 (Sharpening 0): EASU's picture as it is (RCAS's own
|
||||
// arithmetic would still scale it by its approximate reciprocal).
|
||||
constexpr const char* kRcasHead = R"(
|
||||
layout(location = 0) in vec2 uv;
|
||||
layout(location = 0) out vec4 color;
|
||||
layout(set = 0, binding = 0) uniform sampler2D src;
|
||||
layout(push_constant) uniform P { uvec4 con; ivec4 band; ivec2 origin; ivec2 outSize; ivec2 srcSize; uint bandOn; } p;
|
||||
|
||||
ivec2 centre = ivec2(0), centreSpan = ivec2(0);
|
||||
|
||||
// Which of the band's three spans along an axis holds output pixel i's centre: 0, 1 or 2.
|
||||
int spanIndex(int i, int lo, int hi, int outSize, int srcSize)
|
||||
{
|
||||
float t = (float(i) + 0.5) * float(srcSize) / float(outSize);
|
||||
return t < float(lo) ? 0 : t < float(hi) ? 1 : 2;
|
||||
}
|
||||
|
||||
AF4 FsrRcasLoadF(ASU2 q)
|
||||
{
|
||||
ivec2 t = clamp(q, ivec2(0), p.outSize - 1);
|
||||
if (p.bandOn != 0u)
|
||||
{
|
||||
if (spanIndex(t.x, p.band.x, p.band.y, p.outSize.x, p.srcSize.x) != centreSpan.x)
|
||||
t.x = centre.x;
|
||||
if (spanIndex(t.y, p.band.z, p.band.w, p.outSize.y, p.srcSize.y) != centreSpan.y)
|
||||
t.y = centre.y;
|
||||
}
|
||||
return texelFetch(src, t, 0);
|
||||
}
|
||||
void FsrRcasInputF(inout AF1 r, inout AF1 g, inout AF1 b) {}
|
||||
#define FSR_RCAS_F 1
|
||||
)";
|
||||
|
||||
constexpr const char* kRcasMain = R"(
|
||||
void main()
|
||||
{
|
||||
centre = clamp(ivec2(gl_FragCoord.xy) - p.origin, ivec2(0), p.outSize - 1);
|
||||
if (p.bandOn != 0u)
|
||||
centreSpan = ivec2(spanIndex(centre.x, p.band.x, p.band.y, p.outSize.x, p.srcSize.x),
|
||||
spanIndex(centre.y, p.band.z, p.band.w, p.outSize.y, p.srcSize.y));
|
||||
if (p.con.x == 0u)
|
||||
{
|
||||
color = vec4(texelFetch(src, centre, 0).rgb, 1.0);
|
||||
return;
|
||||
}
|
||||
AF1 r, g, b;
|
||||
FsrRcasF(r, g, b, uvec2(centre), p.con);
|
||||
color = vec4(r, g, b, 1.0);
|
||||
}
|
||||
)";
|
||||
}
|
||||
|
||||
std::array<uint32_t, 16> EasuConstants(uint32_t srcW, uint32_t srcH, uint32_t dstW, uint32_t dstH)
|
||||
{
|
||||
std::array<uint32_t, 16> con{};
|
||||
FsrEasuCon(&con[0], &con[4], &con[8], &con[12], AF1(srcW), AF1(srcH), AF1(srcW), AF1(srcH), AF1(dstW), AF1(dstH));
|
||||
return con;
|
||||
}
|
||||
|
||||
std::array<uint32_t, 4> RcasConstants(float stops)
|
||||
{
|
||||
std::array<uint32_t, 4> con{};
|
||||
if (!std::isfinite(stops))
|
||||
return con; // off: con.x 0 (the shader then copies EASU's picture)
|
||||
FsrRcasCon(con.data(), AF1(stops));
|
||||
return con;
|
||||
}
|
||||
|
||||
std::string EasuGlsl()
|
||||
{
|
||||
return kPrelude + Text(k_ffx_a) + kEasuHead + Text(k_ffx_fsr1) + kEasuMain;
|
||||
}
|
||||
|
||||
std::string RcasGlsl()
|
||||
{
|
||||
return kPrelude + Text(k_ffx_a) + kRcasHead + Text(k_ffx_fsr1) + kRcasMain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Graphics > Upscaling's FSR 1: AMD FidelityFX Super Resolution 1
|
||||
// (runtime/thirdparty/fsr1, MIT, unmodified), as two passes after the
|
||||
// anti-aliasing (video/presenter.cpp):
|
||||
// - EASU (compute), the frame image enlarged into an image the size of
|
||||
// the picture on the screen: AMD's FsrEasuF (32-bit), whose 12 taps
|
||||
// come from 4 gathers, or under Hor+ and Vert+ from fetches kept in the
|
||||
// pixel's span of the 16:9 band's three (video/band_filter.h);
|
||||
// - RCAS (fragment, into the swapchain image, where bicubic's pass draws),
|
||||
// AMD's FsrRcasF: sharpening of that image, its 4 neighbours each kept
|
||||
// in the pixel's span too; then Brightness and the 360's display gamma
|
||||
// (the tone variant, as for the other scale shaders), then the UI.
|
||||
// Their constants come from AMD's own CPU code (FsrEasuCon, FsrRcasCon).
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace video::fsr1
|
||||
{
|
||||
// EASU's push constants: FsrEasuCon's con0..con3 for a srcW x srcH
|
||||
// image enlarged to dstW x dstH.
|
||||
std::array<uint32_t, 16> EasuConstants(uint32_t srcW, uint32_t srcH, uint32_t dstW, uint32_t dstH);
|
||||
// RCAS's: FsrRcasCon's con for a strength in stops below its maximum
|
||||
// (video::upscale::RcasStops; infinity gives 0: no sharpening).
|
||||
std::array<uint32_t, 4> RcasConstants(float stops);
|
||||
|
||||
// The shaders' GLSL: AMD's two headers between ours (the RCAS one has a
|
||||
// single main(), so the tone variant can wrap it).
|
||||
std::string EasuGlsl();
|
||||
std::string RcasGlsl();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Display > Fullscreen and the window's own fullscreen state.
|
||||
//
|
||||
// The setting is the player's choice and the only thing that decides how the
|
||||
// game starts. Changing it in the menu (or Reset page) also puts the window
|
||||
// in that state now. The system can change the window too: the green button
|
||||
// or Window > Enter Full Screen (Ctrl+Cmd+F) on a Mac, a window manager's
|
||||
// fullscreen key on a Linux desktop, or a macOS fullscreen Space that fails
|
||||
// to open at launch (SDL then reports the window leaving fullscreen). Those
|
||||
// change the window for this session only and never the setting: until
|
||||
// v0.1.3 every such change was written into the setting, so the next launch
|
||||
// was fullscreen or not depending on how the window happened to be when the
|
||||
// game last saved (a quit from a green-button fullscreen started fullscreen;
|
||||
// a failed Space at launch turned the setting off).
|
||||
//
|
||||
// The menu shows the saved choice; when the window differs from it, the row
|
||||
// says what the window is now ("NOW FULLSCREEN" / "NOW WINDOWED") and its
|
||||
// help line which way the game starts (ui/settings_menu.cpp).
|
||||
//
|
||||
// Main thread. Header only (tests/fullscreen_policy_test.cpp).
|
||||
#pragma once
|
||||
#include <optional>
|
||||
|
||||
namespace video
|
||||
{
|
||||
class FullscreenPolicy
|
||||
{
|
||||
public:
|
||||
// Who changed the window, for its log line.
|
||||
enum class Change
|
||||
{
|
||||
Requested, // the game asked for it (launch, the setting): nothing to do
|
||||
System, // the system did: the window only, the setting stays
|
||||
};
|
||||
|
||||
// At launch: whether to create the window fullscreen. `setting` is
|
||||
// the effective value (NFSMW_FULLSCREEN or settings.toml); `locked`:
|
||||
// always fullscreen (SteamOS Game Mode, iOS).
|
||||
bool Launch(bool setting, bool locked)
|
||||
{
|
||||
m_locked = locked;
|
||||
m_setting = setting;
|
||||
m_window = m_requested = locked || setting;
|
||||
return m_window;
|
||||
}
|
||||
|
||||
// The setting's effective value now (checked whenever the settings
|
||||
// change: the menu, Reset page). When it changed, the window is to be
|
||||
// put in that state: the value to ask SDL for, unless the window is
|
||||
// there already or locked.
|
||||
std::optional<bool> SettingIs(bool setting)
|
||||
{
|
||||
if (m_locked || setting == m_setting)
|
||||
return std::nullopt;
|
||||
m_setting = setting;
|
||||
if (setting == m_window)
|
||||
return std::nullopt;
|
||||
m_window = m_requested = setting;
|
||||
return setting;
|
||||
}
|
||||
|
||||
// SDL reported the window entering (true) or leaving fullscreen. The
|
||||
// setting is never touched: a change the game didn't ask for is the
|
||||
// system's, for this session only.
|
||||
Change WindowIs(bool fullscreen)
|
||||
{
|
||||
m_window = fullscreen;
|
||||
if (fullscreen == m_requested)
|
||||
return Change::Requested;
|
||||
m_requested = fullscreen;
|
||||
return Change::System;
|
||||
}
|
||||
|
||||
bool Window() const { return m_window; } // the window's state
|
||||
bool Setting() const { return m_setting; } // the choice as last applied
|
||||
// The window isn't as the setting says (a system change this session).
|
||||
bool WindowDiffers() const { return !m_locked && m_window != m_setting; }
|
||||
|
||||
private:
|
||||
bool m_locked = false;
|
||||
bool m_setting = false;
|
||||
bool m_window = false;
|
||||
bool m_requested = false; // what the game last asked for (or the system last did)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Which Vulkan device the presenter draws with (video/presenter.cpp,
|
||||
// ChooseDevice), and the log's one line about it. Up to v0.1.1 it was the
|
||||
// loader's first that could present to the window. On a laptop with two GPUs
|
||||
// (an Intel or AMD one in the processor beside an NVIDIA or AMD graphics
|
||||
// card) the loader, and Mesa's device-select layer, usually list the
|
||||
// integrated one first, so the game ran there (GitHub PR #6). Now, of the
|
||||
// devices that can present to the window:
|
||||
// 1. one with every device extension the game needs, before one without
|
||||
// (the game can't start on that);
|
||||
// 2. a discrete GPU, then an integrated one, a virtual one, any other kind,
|
||||
// then a software renderer (llvmpipe); a layered one (Mesa's Dozen,
|
||||
// Vulkan on Direct3D 12, which lists the same GPUs again) after every
|
||||
// native driver's;
|
||||
// 3. among equals, the one with the most device-local memory;
|
||||
// 4. then the loader's order.
|
||||
// A device that can't present to the window is never chosen. So where only
|
||||
// one can present (a Steam Deck, a Steam Machine, a Steam Frame, a Mac, an
|
||||
// iPhone or iPad: one GPU each), it is that one, as before.
|
||||
// NFSMW_GPU=<index or part of a name> picks one by hand: an index from the
|
||||
// log's list, or any part of a name, in any case (several matching: the best
|
||||
// of them by the rules above). A number that is no device's index is tried as
|
||||
// part of a name ("3050").
|
||||
//
|
||||
// Header only and Vulkan-free (`type` holds VkPhysicalDeviceType's values):
|
||||
// tests/gpu_choice_test.cpp. The windows branch's first version of this file
|
||||
// had rules 2 and 4 (and NFSMW_GPU); this one adds 1 and 3, and the log line.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct GpuCandidate
|
||||
{
|
||||
size_t index; // in the loader's list (vkEnumeratePhysicalDevices)
|
||||
int type; // VkPhysicalDeviceType: 1 integrated, 2 discrete, 3 virtual, 4 CPU, else other
|
||||
bool layered = false; // Mesa's Dozen: Vulkan on Direct3D 12
|
||||
std::string name; // deviceName
|
||||
uint64_t localBytes = 0; // its device-local memory heaps, added up
|
||||
bool presents = true; // a graphics queue family can present to the window
|
||||
std::string lacks; // a device extension the game needs that it doesn't have ("": none)
|
||||
};
|
||||
|
||||
inline constexpr size_t kNoGpu = size_t(-1);
|
||||
|
||||
inline const char* GpuKind(int type)
|
||||
{
|
||||
return type == 2 ? "discrete" : type == 1 ? "integrated" : type == 3 ? "virtual" : type == 4 ? "software" : "other";
|
||||
}
|
||||
|
||||
// "a discrete GPU", "an integrated GPU on Direct3D 12".
|
||||
inline std::string GpuDescription(const GpuCandidate& c)
|
||||
{
|
||||
const char* text = c.type == 2 ? "a discrete GPU" : c.type == 1 ? "an integrated GPU" : c.type == 3 ? "a virtual GPU"
|
||||
: c.type == 4 ? "a software renderer" : "a device of another kind";
|
||||
return std::format("{}{}", text, c.layered ? " on Direct3D 12" : "");
|
||||
}
|
||||
|
||||
// "7.6 GB" (binary gigabytes, as drivers size their heaps).
|
||||
inline std::string GpuMemory(uint64_t bytes)
|
||||
{
|
||||
return std::format("{:.1f} GB", double(bytes) / double(1ull << 30));
|
||||
}
|
||||
|
||||
// Rule 2's order: lower comes first.
|
||||
inline int GpuKindRank(const GpuCandidate& c)
|
||||
{
|
||||
int kind = c.type == 2 ? 0 : c.type == 1 ? 1 : c.type == 3 ? 2 : c.type == 4 ? 4 : 3;
|
||||
return kind + (c.layered ? 5 : 0);
|
||||
}
|
||||
|
||||
// Rules 1-4: whether `a` comes before `b`.
|
||||
inline bool GpuBefore(const GpuCandidate& a, const GpuCandidate& b)
|
||||
{
|
||||
if (a.lacks.empty() != b.lacks.empty())
|
||||
return a.lacks.empty();
|
||||
if (GpuKindRank(a) != GpuKindRank(b))
|
||||
return GpuKindRank(a) < GpuKindRank(b);
|
||||
if (a.localBytes != b.localBytes)
|
||||
return a.localBytes > b.localBytes;
|
||||
return a.index < b.index;
|
||||
}
|
||||
|
||||
struct GpuChoice
|
||||
{
|
||||
size_t pick = kNoGpu; // into the list; kNoGpu: none can present to the window
|
||||
std::string why; // why that one, for the log
|
||||
std::string want; // NFSMW_GPU as given ("": not set)
|
||||
std::string wantNote; // when NFSMW_GPU was set but not used: why not ("": used, or not set)
|
||||
};
|
||||
|
||||
namespace detail
|
||||
{
|
||||
inline std::string Lower(std::string text)
|
||||
{
|
||||
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return char(std::tolower(c)); });
|
||||
return text;
|
||||
}
|
||||
|
||||
inline std::string Trim(const char* text)
|
||||
{
|
||||
std::string s(text ? text : "");
|
||||
size_t b = s.find_first_not_of(" \t"), e = s.find_last_not_of(" \t");
|
||||
return b == std::string::npos ? std::string() : s.substr(b, e - b + 1);
|
||||
}
|
||||
|
||||
// The best of the presenting devices, by rules 1-4 (kNoGpu: none).
|
||||
inline size_t Best(const std::vector<GpuCandidate>& devices, size_t skip = kNoGpu)
|
||||
{
|
||||
size_t best = kNoGpu;
|
||||
for (size_t i = 0; i < devices.size(); i++)
|
||||
if (i != skip && devices[i].presents && (best == kNoGpu || GpuBefore(devices[i], devices[best])))
|
||||
best = i;
|
||||
return best;
|
||||
}
|
||||
|
||||
// Why `pick` came before `next` (the best of the rest), by the first
|
||||
// rule that tells them apart.
|
||||
inline std::string Why(const GpuCandidate& pick, const GpuCandidate& next)
|
||||
{
|
||||
if (pick.lacks.empty() != next.lacks.empty())
|
||||
return std::format("{} {} lacks {}, which the game needs", next.index, next.name, next.lacks);
|
||||
if (GpuKindRank(pick) != GpuKindRank(next))
|
||||
return std::format("{} before {}", GpuDescription(pick), GpuDescription(next));
|
||||
if (pick.localBytes != next.localBytes)
|
||||
return std::format("{}, with more device-local memory than {}: {} against {}", GpuDescription(pick), next.index,
|
||||
GpuMemory(pick.localBytes), GpuMemory(next.localBytes));
|
||||
return std::format("{}, with as much device-local memory as {}: the loader's order", GpuDescription(pick), next.index);
|
||||
}
|
||||
}
|
||||
|
||||
// `devices`: every device the loader lists, in its order. `want`:
|
||||
// NFSMW_GPU, or null.
|
||||
inline GpuChoice ChooseGpu(const std::vector<GpuCandidate>& devices, const char* want)
|
||||
{
|
||||
GpuChoice choice;
|
||||
choice.pick = detail::Best(devices);
|
||||
if (choice.pick == kNoGpu)
|
||||
return choice;
|
||||
const size_t next = detail::Best(devices, choice.pick);
|
||||
choice.why = next == kNoGpu ? std::string("the only one that can present to the window")
|
||||
: detail::Why(devices[choice.pick], devices[next]);
|
||||
|
||||
choice.want = detail::Trim(want);
|
||||
if (choice.want.empty())
|
||||
return choice;
|
||||
// An index from the log's list, else part of a name.
|
||||
size_t named = kNoGpu;
|
||||
char* end = nullptr;
|
||||
unsigned long long index = std::strtoull(choice.want.c_str(), &end, 10);
|
||||
if (end != choice.want.c_str() && *end == '\0' && std::isdigit((unsigned char)choice.want[0]))
|
||||
for (size_t i = 0; i < devices.size() && named == kNoGpu; i++)
|
||||
if (devices[i].index == index)
|
||||
named = i;
|
||||
bool anyName = false;
|
||||
if (named == kNoGpu)
|
||||
{
|
||||
const std::string part = detail::Lower(choice.want);
|
||||
for (size_t i = 0; i < devices.size(); i++)
|
||||
if (detail::Lower(devices[i].name).find(part) != std::string::npos)
|
||||
{
|
||||
anyName = true;
|
||||
if (devices[i].presents && (named == kNoGpu || GpuBefore(devices[i], devices[named])))
|
||||
named = i;
|
||||
}
|
||||
}
|
||||
if (named != kNoGpu && devices[named].presents)
|
||||
{
|
||||
choice.pick = named;
|
||||
choice.why = std::format("NFSMW_GPU={}", choice.want);
|
||||
return choice;
|
||||
}
|
||||
choice.wantNote = named != kNoGpu
|
||||
? std::format("NFSMW_GPU={} names {} {}, which can't present to the window", choice.want, devices[named].index, devices[named].name)
|
||||
: anyName ? std::format("NFSMW_GPU={} names only devices that can't present to the window", choice.want)
|
||||
: std::format("NFSMW_GPU={} names none of them (an index from this list, or part of a name)", choice.want);
|
||||
return choice;
|
||||
}
|
||||
|
||||
// The log's line (after "[video] "): every device the loader listed, the
|
||||
// one used, and why.
|
||||
// Vulkan devices: 0 Intel(R) Iris(R) Xe Graphics (integrated, 7.6 GB),
|
||||
// 1 NVIDIA GeForce RTX 3050 Laptop GPU (discrete, 4.0 GB); using 1: a
|
||||
// discrete GPU before an integrated GPU (the loader listed 0 first);
|
||||
// NFSMW_GPU=<index or part of a name> picks another
|
||||
inline std::string GpuLogLine(const std::vector<GpuCandidate>& devices, const GpuChoice& choice)
|
||||
{
|
||||
std::string line = "Vulkan devices: ";
|
||||
if (devices.empty())
|
||||
line += "none";
|
||||
size_t presenting = 0, first = kNoGpu;
|
||||
for (size_t i = 0; i < devices.size(); i++)
|
||||
{
|
||||
const GpuCandidate& c = devices[i];
|
||||
line += std::format("{}{} {} ({}{}{}{}{})", i ? ", " : "", c.index, c.name, GpuKind(c.type),
|
||||
c.layered ? " on Direct3D 12" : "", c.localBytes ? ", " + GpuMemory(c.localBytes) : std::string(),
|
||||
c.presents ? "" : ", can't present to the window", c.lacks.empty() ? std::string() : ", lacks " + c.lacks);
|
||||
if (c.presents)
|
||||
{
|
||||
presenting++;
|
||||
if (first == kNoGpu)
|
||||
first = i;
|
||||
}
|
||||
}
|
||||
if (choice.pick == kNoGpu)
|
||||
return line + "; none can present to the window";
|
||||
line += std::format("; using {}: {}", devices[choice.pick].index,
|
||||
choice.wantNote.empty() ? choice.why : choice.wantNote + ", so the usual choice: " + choice.why);
|
||||
if (presenting > 1)
|
||||
{
|
||||
line += choice.pick == first ? " (the loader listed it first)" : std::format(" (the loader listed {} first)", devices[first].index);
|
||||
if (choice.want.empty())
|
||||
line += "; NFSMW_GPU=<index or part of a name> picks another";
|
||||
}
|
||||
return line;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See picture_fit.h.
|
||||
#include "picture_fit.h"
|
||||
|
||||
#include <gpu/edge_bars.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
@@ -58,6 +60,22 @@ namespace video
|
||||
return fit;
|
||||
}
|
||||
|
||||
FrameBand BandOf(uint32_t width, uint32_t height, float scaleX, float scaleY)
|
||||
{
|
||||
FrameBand band{ 0, int32_t(width), 0, int32_t(height) };
|
||||
if (scaleX < 1.0f)
|
||||
{
|
||||
band.x0 = gpu::bars::Edge(-scaleX, float(width));
|
||||
band.x1 = gpu::bars::Edge(scaleX, float(width));
|
||||
}
|
||||
if (scaleY < 1.0f)
|
||||
{
|
||||
band.y0 = gpu::bars::Edge(-scaleY, float(height));
|
||||
band.y1 = gpu::bars::Edge(scaleY, float(height));
|
||||
}
|
||||
return band;
|
||||
}
|
||||
|
||||
void AutoRenderScale(uint32_t regionW, uint32_t regionH, uint32_t budget, uint32_t& sx, uint32_t& sy)
|
||||
{
|
||||
// Enough host pixels to cover the screen area the frame fills
|
||||
|
||||
@@ -53,6 +53,18 @@ namespace video
|
||||
// for another one (a redraw after a resize) or clamped to 4:3.
|
||||
PictureFit PlacePicture(uint32_t screenW, uint32_t screenH, float frameW, float frameH, float scaleX, float scaleY);
|
||||
|
||||
// The 16:9 band of a frame image of width x height texels (any internal
|
||||
// resolution) rendered with the view scales (scaleX, scaleY): what a
|
||||
// 16:9 screen shows, the columns [x0, x1) and rows [y0, y1) whose
|
||||
// centres lie inside clip +-scaleX and +-scaleY, by gpu::bars::Edge's
|
||||
// rule, so the presenter splits a frame where the renderer's band clip
|
||||
// and edge-bar fills do. The whole image at 1, 1.
|
||||
struct FrameBand
|
||||
{
|
||||
int32_t x0 = 0, x1 = 0, y0 = 0, y1 = 0;
|
||||
};
|
||||
FrameBand BandOf(uint32_t width, uint32_t height, float scaleX, float scaleY);
|
||||
|
||||
// Auto internal resolution: the render scale (sx x sy host pixels per
|
||||
// guest pixel, 1-3 each) for a picture covering regionW x regionH of the
|
||||
// screen (video::FrameRegion), within `budget` host pixels per guest
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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,73 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See scale_budget.h.
|
||||
#include "scale_budget.h"
|
||||
|
||||
namespace video
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// "Apple <family><generation>[ <tier>] GPU": family 'A' (iPhones, the
|
||||
// iPad mini and the base iPad) or 'M' (iPad Pro and Air), tier "Pro"
|
||||
// or empty. False for any other name.
|
||||
bool ParseAppleChip(std::string_view name, char& family, unsigned& generation, std::string_view& tier)
|
||||
{
|
||||
constexpr std::string_view kApple = "Apple ";
|
||||
if (!name.starts_with(kApple))
|
||||
return false;
|
||||
name.remove_prefix(kApple.size());
|
||||
if (name.empty() || (name[0] != 'A' && name[0] != 'M'))
|
||||
return false;
|
||||
family = name[0];
|
||||
name.remove_prefix(1);
|
||||
generation = 0;
|
||||
size_t digits = 0;
|
||||
while (digits < name.size() && name[digits] >= '0' && name[digits] <= '9' && digits < 4)
|
||||
generation = generation * 10 + unsigned(name[digits++] - '0');
|
||||
if (digits == 0 || (digits < name.size() && name[digits] != ' '))
|
||||
return false;
|
||||
name.remove_prefix(digits < name.size() ? digits + 1 : digits);
|
||||
// What's left: "GPU", or "<tier> GPU". MoltenVK names the device
|
||||
// after Metal's, which ends in " GPU"; accept a name without it too.
|
||||
constexpr std::string_view kGpu = "GPU";
|
||||
if (name.ends_with(kGpu))
|
||||
name.remove_suffix(kGpu.size());
|
||||
while (!name.empty() && name.back() == ' ')
|
||||
name.remove_suffix(1);
|
||||
tier = name;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
ScaleBudget MobileScaleBudget(std::string_view gpuName)
|
||||
{
|
||||
char family = 0;
|
||||
unsigned generation = 0;
|
||||
std::string_view tier;
|
||||
if (!ParseAppleChip(gpuName, family, generation, tier))
|
||||
return { 1, "an unrecognised GPU keeps 1x1" };
|
||||
if (family == 'A' && generation == 20 && tier == "Pro")
|
||||
{
|
||||
// iPhone 18 Pro Max (2868x1320 screen), on a charger, scripted
|
||||
// fly-in and race: 60 fps at 1x1 with GPU to spare (race 7.7 ms a
|
||||
// frame), but iOS's thermal state went nominal -> fair at 75 s and
|
||||
// -> serious at 350 s (Frame Rate's Auto then dropped to 30 fps),
|
||||
// and was still serious 25 minutes later. More pixels only heat
|
||||
// it sooner: 1x1 until 2x2 is shown to stay out of serious.
|
||||
return { 1, "the A20 Pro heats to serious within 6 minutes at 1x1 on a charger" };
|
||||
}
|
||||
if (family == 'A' && generation <= 17)
|
||||
{
|
||||
// iPhone 15 Pro Max (A17 Pro), scripted race: GPU 15 ms a frame
|
||||
// at 1x1, 36-40 ms at 2x2.
|
||||
return { 1, "A17 Pro and older hold a race at 60 fps at 1x1 only" };
|
||||
}
|
||||
if (family == 'M')
|
||||
{
|
||||
// iPad Pro M2: 2x2 = 43-50 fps in a race. Newer M chips are not
|
||||
// measured in an iPad.
|
||||
return { 1, "iPads keep 1x1: the M2 makes 43-50 fps at 2x2" };
|
||||
}
|
||||
// An A-series chip newer than the A17 Pro but not measured (the
|
||||
// A18/A19, a plain A20, an A21...).
|
||||
return { 1, "a chip not measured yet keeps 1x1" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Internal Resolution's Auto on iPhones and iPads: how many host pixels per
|
||||
// guest pixel (sx x sy, video::AutoRenderScale's budget) the GPU gets, chip
|
||||
// by chip. A pure function of the GPU's name, for tests/scale_budget_test.cpp.
|
||||
//
|
||||
// Only chips measured to hold a race at 60 fps with headroom, and to stay out
|
||||
// of serious thermal throttling over a long session, get more than 1x1. Any
|
||||
// other chip, a newer one included, keeps 1x1 until it is measured: a phone
|
||||
// that renders too much heats up and throttles to 30 fps mid-session, which
|
||||
// is worse than a softer picture. None has qualified yet: the A20 Pro (iPhone
|
||||
// 18 Pro Max) reaches the serious thermal state at 1x1 already (on a
|
||||
// charger), so it keeps 1x1 too, and Settings > Graphics > Internal
|
||||
// Resolution > 2x stays the player's choice.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct ScaleBudget
|
||||
{
|
||||
// At most this many host pixels per guest pixel (1: 1x1, 4: 2x2).
|
||||
uint32_t pixels = 1;
|
||||
// Why, for the log ("[renderer] Auto internal resolution: ...").
|
||||
const char* reason = "";
|
||||
};
|
||||
|
||||
// The budget for the GPU that MoltenVK names `gpuName` (Metal's device
|
||||
// name, "Apple A20 Pro GPU", "Apple M2 GPU").
|
||||
ScaleBudget MobileScaleBudget(std::string_view gpuName);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See swapchain_policy.h.
|
||||
#include "swapchain_policy.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace video
|
||||
{
|
||||
const char* OrientationName(Orientation orientation)
|
||||
{
|
||||
switch (orientation)
|
||||
{
|
||||
case Orientation::Landscape:
|
||||
return "landscape";
|
||||
case Orientation::Portrait:
|
||||
return "portrait";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const char* SwapActionName(SwapAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case SwapAction::Recreate:
|
||||
return "recreate";
|
||||
case SwapAction::RepairView:
|
||||
return "repair the view";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
ExtentChoice ChooseExtent(Extent surface, Orientation interface, bool landscapeOnly, bool refusePortrait)
|
||||
{
|
||||
// Unknown counts as landscape: the app shows in nothing else.
|
||||
if (!refusePortrait || !landscapeOnly || !surface.Portrait() || interface == Orientation::Portrait)
|
||||
return { surface, false };
|
||||
return { Extent{ surface.height, surface.width }, true };
|
||||
}
|
||||
|
||||
void SwapchainPolicy::Suspended()
|
||||
{
|
||||
resumeWatch_ = false;
|
||||
}
|
||||
|
||||
void SwapchainPolicy::Resumed()
|
||||
{
|
||||
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
|
||||
resumeWatch_ = rules_.settleFrames > 0;
|
||||
}
|
||||
|
||||
void SwapchainPolicy::SizeChanged()
|
||||
{
|
||||
sizePending_ = true;
|
||||
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
|
||||
}
|
||||
|
||||
void SwapchainPolicy::Reported(SurfaceResult result)
|
||||
{
|
||||
if (result == SurfaceResult::OutOfDate)
|
||||
outOfDate_ = true;
|
||||
else if (rules_.followSuboptimal)
|
||||
suboptimal_ = true;
|
||||
}
|
||||
|
||||
void SwapchainPolicy::Created(Extent)
|
||||
{
|
||||
outOfDate_ = false;
|
||||
sizePending_ = false;
|
||||
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
|
||||
}
|
||||
|
||||
bool SwapchainPolicy::WantsSurface() const
|
||||
{
|
||||
return sizePending_ || suboptimal_ || outOfDate_ || settleLeft_ > 0 || misfitFrames_ > 0 || refusedFrames_ > 0 ||
|
||||
(rules_.periodFrames > 0 && (frame_ + 1) % rules_.periodFrames == 0);
|
||||
}
|
||||
|
||||
SwapDecision SwapchainPolicy::Decide(const SurfaceState& s)
|
||||
{
|
||||
SwapDecision d;
|
||||
if (!s.active && rules_.holdWhileInactive)
|
||||
{
|
||||
// What was seen meanwhile waits for the resume: a size reported
|
||||
// now (iOS turning or snapshotting the window) is not the one the
|
||||
// app will show at.
|
||||
d.reason = "inactive";
|
||||
return d;
|
||||
}
|
||||
frame_++;
|
||||
if (settleLeft_ > 0 && --settleLeft_ == 0 && resumeWatch_)
|
||||
{
|
||||
resumeWatch_ = false;
|
||||
d.settled = true;
|
||||
}
|
||||
|
||||
if (outOfDate_)
|
||||
{
|
||||
// The old one can't be presented to: build again whatever the size.
|
||||
outOfDate_ = suboptimal_ = sizePending_ = false;
|
||||
ExtentChoice choice = ChooseExtent(s.surface, s.interface, s.landscapeOnly, rules_.refusePortrait);
|
||||
d.action = SwapAction::Recreate;
|
||||
d.reason = "VK_ERROR_OUT_OF_DATE_KHR";
|
||||
d.extent = choice.extent;
|
||||
d.refused = choice.refused;
|
||||
return d;
|
||||
}
|
||||
if (!s.surfaceRead)
|
||||
return d;
|
||||
if (s.surface.Empty())
|
||||
{
|
||||
// Minimised (or no layer): as before, a size event tears the
|
||||
// swapchain down and the presenter waits for a size again.
|
||||
refusedFrames_ = misfitFrames_ = 0;
|
||||
if (sizePending_ && !s.swapchain.Empty())
|
||||
{
|
||||
sizePending_ = suboptimal_ = false;
|
||||
d.action = SwapAction::Recreate;
|
||||
d.reason = "no surface (minimised)";
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// iOS: the surface is the view's layer. A view that doesn't fill its
|
||||
// window was sized by someone guessing (SDL's portrait guess): fit it
|
||||
// back, and look again next frame, before following it anywhere.
|
||||
if (!s.viewFitsWindow && rules_.repairView)
|
||||
{
|
||||
misfitFrames_++;
|
||||
if (misfitFrames_ <= rules_.repairPatience)
|
||||
{
|
||||
if (misfitFrames_ <= 3 || (rules_.repairEvery && misfitFrames_ % rules_.repairEvery == 0))
|
||||
{
|
||||
d.action = SwapAction::RepairView;
|
||||
d.reason = "the view doesn't fill its window";
|
||||
}
|
||||
else
|
||||
d.reason = "waiting for the view to fill its window";
|
||||
return d;
|
||||
}
|
||||
// It won't: the swapchain follows the layer after all (below).
|
||||
}
|
||||
else
|
||||
misfitFrames_ = 0;
|
||||
|
||||
const ExtentChoice choice = ChooseExtent(s.surface, s.interface, s.landscapeOnly, rules_.refusePortrait);
|
||||
if (choice.extent != s.swapchain)
|
||||
{
|
||||
d.action = SwapAction::Recreate;
|
||||
d.reason = s.swapchain.Empty() ? "no swapchain"
|
||||
: sizePending_ ? "the size changed"
|
||||
: resumeWatch_ ? "after the resume"
|
||||
: suboptimal_ ? "VK_SUBOPTIMAL_KHR"
|
||||
: "the surface's size moved";
|
||||
d.extent = choice.extent;
|
||||
d.refused = choice.refused;
|
||||
sizePending_ = suboptimal_ = false;
|
||||
refusedFrames_ = choice.refused ? refusedFrames_ + 1 : 0;
|
||||
return d;
|
||||
}
|
||||
sizePending_ = false;
|
||||
if (choice.refused)
|
||||
{
|
||||
// A portrait layer under a landscape-only interface: the swapchain
|
||||
// keeps its landscape size (its SUBOPTIMALs say only that), and the
|
||||
// view is fitted back to its window.
|
||||
suboptimal_ = false;
|
||||
refusedFrames_++;
|
||||
d.refused = true;
|
||||
if (rules_.repairView && (refusedFrames_ <= 3 || (rules_.repairEvery && refusedFrames_ % rules_.repairEvery == 0)))
|
||||
{
|
||||
d.action = SwapAction::RepairView;
|
||||
d.reason = "a portrait surface under a landscape interface";
|
||||
}
|
||||
else
|
||||
d.reason = "a portrait surface refused";
|
||||
return d;
|
||||
}
|
||||
refusedFrames_ = 0;
|
||||
if (suboptimal_)
|
||||
{
|
||||
suboptimal_ = false;
|
||||
// The same size: the drawables or the layer's scale disagree with
|
||||
// it. Build again, but not every frame if that doesn't settle it.
|
||||
if (lastSameRecreate_ == 0 || frame_ - lastSameRecreate_ >= rules_.suboptimalBackoff)
|
||||
{
|
||||
lastSameRecreate_ = frame_;
|
||||
d.action = SwapAction::Recreate;
|
||||
d.reason = "VK_SUBOPTIMAL_KHR at the same size";
|
||||
d.extent = choice.extent;
|
||||
return d;
|
||||
}
|
||||
d.reason = "VK_SUBOPTIMAL_KHR again (backing off)";
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
UiDisplay FitUiDisplay(float windowWidth, float windowHeight, Extent fb)
|
||||
{
|
||||
UiDisplay u;
|
||||
u.width = windowWidth;
|
||||
u.height = windowHeight;
|
||||
if (fb.Empty() || windowWidth <= 0 || windowHeight <= 0)
|
||||
return u;
|
||||
// Pixels per point, whichever way round the two are.
|
||||
const float ppp = float(std::max(fb.width, fb.height)) / std::max(windowWidth, windowHeight);
|
||||
const float w = float(fb.width) / ppp, h = float(fb.height) / ppp;
|
||||
if (std::fabs(w - windowWidth) > 2.0f || std::fabs(h - windowHeight) > 2.0f)
|
||||
{
|
||||
u.width = w;
|
||||
u.height = h;
|
||||
u.fromSwapchain = true;
|
||||
}
|
||||
u.scaleX = float(fb.width) / u.width;
|
||||
u.scaleY = float(fb.height) / u.height;
|
||||
return u;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// When the presenter builds its swapchain again, and at what size; and the
|
||||
// size the settings UI lays out at. Pure decisions (no Vulkan, SDL or UIKit:
|
||||
// presenter.cpp feeds them what it reads), tested with planted wrong rules in
|
||||
// tests/swapchain_policy_test.cpp. Main thread.
|
||||
//
|
||||
// Why (v0.1.2, an iPhone 18 Pro Max on iOS 27): after an iOS screenshot the
|
||||
// game's picture sat in a corner of the screen and the settings UI was blown
|
||||
// up. SDL 3.4.16 sets its view's frame itself whenever a keyboard shows or
|
||||
// hides (-[SDL_uikitviewcontroller updateKeyboard], UIKit_ComputeViewFrame),
|
||||
// swapping width and height when it believes the interface is portrait:
|
||||
// iOS 27's UIApplication.statusBarOrientation is "unknown" under the scene
|
||||
// life cycle, and SDL thinks a resizable window may be portrait whatever the
|
||||
// Info.plist says. The view became 440x956 points in a 956x440 window, its
|
||||
// layer followed, and the presenter rebuilt its swapchain at the layer's
|
||||
// 1320x2868 and never back (nothing moved the view again). So:
|
||||
// - nothing is built while the app is inactive or in the background, and a
|
||||
// size seen then is only a reason to look again once it is active;
|
||||
// - after a resume, a size event or a new swapchain the surface is read
|
||||
// every frame for a while (and once a second always), and the swapchain
|
||||
// follows it only when they differ;
|
||||
// - VK_SUBOPTIMAL_KHR and VK_ERROR_OUT_OF_DATE_KHR are reasons to build
|
||||
// again (a SUBOPTIMAL at an unchanged size at most once per backoff);
|
||||
// - on iOS a view that doesn't fill its window is fitted back first (the
|
||||
// surface is its layer: following it would follow SDL's mistake);
|
||||
// - a landscape-only app (SpeedBreaker on iPhone) never takes a portrait
|
||||
// swapchain while its interface is landscape;
|
||||
// - the UI lays out at the swapchain's size in points, not a window size
|
||||
// that disagrees with it.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct Extent
|
||||
{
|
||||
uint32_t width = 0, height = 0;
|
||||
bool Empty() const { return width == 0 || height == 0; }
|
||||
bool Portrait() const { return height > width; }
|
||||
friend bool operator==(const Extent&, const Extent&) = default;
|
||||
};
|
||||
|
||||
enum class Orientation : uint8_t
|
||||
{
|
||||
Unknown,
|
||||
Landscape,
|
||||
Portrait,
|
||||
};
|
||||
const char* OrientationName(Orientation orientation);
|
||||
|
||||
// The size to build a swapchain at for a surface of `surface`. A
|
||||
// landscape-only app refuses a portrait surface unless its interface is
|
||||
// portrait too: then it takes the same size turned to landscape (which
|
||||
// is what its view's layer measures once fitted back to its window).
|
||||
struct ExtentChoice
|
||||
{
|
||||
Extent extent;
|
||||
bool refused = false; // `surface` itself was refused
|
||||
};
|
||||
ExtentChoice ChooseExtent(Extent surface, Orientation interface, bool landscapeOnly, bool refusePortrait = true);
|
||||
|
||||
// What vkAcquireNextImageKHR or vkQueuePresentKHR said, other than success.
|
||||
enum class SurfaceResult : uint8_t
|
||||
{
|
||||
Suboptimal,
|
||||
OutOfDate,
|
||||
};
|
||||
|
||||
// What the presenter read this frame.
|
||||
struct SurfaceState
|
||||
{
|
||||
bool active = true; // the app is in front (not inactive, not in the background)
|
||||
bool surfaceRead = false; // `surface` (and the fields after it) were read this frame (WantsSurface)
|
||||
Extent surface; // the surface's extent now (Vulkan's currentExtent, else the window's pixels)
|
||||
Extent swapchain; // the current swapchain's (0x0: none)
|
||||
Orientation interface = Orientation::Unknown; // the interface's (iOS: the window scene's), Unknown elsewhere
|
||||
bool landscapeOnly = false; // the app shows only in landscape here (iPhone)
|
||||
bool viewFitsWindow = true; // iOS: SDL's view fills its UIWindow (always elsewhere)
|
||||
};
|
||||
|
||||
enum class SwapAction : uint8_t
|
||||
{
|
||||
None,
|
||||
Recreate, // build the swapchain again, at `extent`
|
||||
RepairView, // fit the view to its window (iOS), then look again next frame
|
||||
};
|
||||
|
||||
struct SwapDecision
|
||||
{
|
||||
SwapAction action = SwapAction::None;
|
||||
const char* reason = "";
|
||||
Extent extent; // Recreate: the size (ChooseExtent's)
|
||||
bool refused = false; // the surface's own size was refused
|
||||
bool settled = false; // the watch after a resume ended this frame: the presenter logs where it stands
|
||||
};
|
||||
const char* SwapActionName(SwapAction action);
|
||||
|
||||
// The rules, a field each, so the tests can plant a wrong one.
|
||||
struct SwapchainRules
|
||||
{
|
||||
bool holdWhileInactive = true; // nothing built while the app is inactive or in the background
|
||||
bool refusePortrait = true; // landscape-only: no portrait swapchain under a landscape interface
|
||||
bool repairView = true; // a view that doesn't fill its window is fitted back before anything else
|
||||
bool followSuboptimal = true; // VK_SUBOPTIMAL_KHR builds again
|
||||
uint32_t settleFrames = 120; // frames the surface is read every frame after a resume, a size event or a new swapchain
|
||||
uint32_t periodFrames = 60; // otherwise once every this many frames
|
||||
uint32_t suboptimalBackoff = 30; // a SUBOPTIMAL at an unchanged size builds again at most once per this many frames
|
||||
uint32_t repairEvery = 30; // after the first three, a repair is tried once per this many frames
|
||||
uint32_t repairPatience = 180; // a view that won't fit: after this many frames the swapchain follows its layer anyway
|
||||
};
|
||||
|
||||
class SwapchainPolicy
|
||||
{
|
||||
public:
|
||||
explicit SwapchainPolicy(SwapchainRules rules = {}) : rules_(rules) {}
|
||||
|
||||
void Suspended(); // the app went inactive (or to the background)
|
||||
void Resumed(); // it is active again: watch the surface
|
||||
void SizeChanged(); // a window, pixel-size or view-size event
|
||||
void Reported(SurfaceResult result);
|
||||
void Created(Extent extent); // a swapchain was built (on any path)
|
||||
|
||||
// Whether to read the surface (and on iOS the view) this frame.
|
||||
bool WantsSurface() const;
|
||||
// Once a frame, before acquiring an image.
|
||||
SwapDecision Decide(const SurfaceState& state);
|
||||
|
||||
const SwapchainRules& Rules() const { return rules_; }
|
||||
|
||||
private:
|
||||
SwapchainRules rules_;
|
||||
uint64_t frame_ = 0; // active frames decided
|
||||
uint32_t settleLeft_ = 0; // frames left reading the surface every frame
|
||||
bool sizePending_ = false;
|
||||
bool suboptimal_ = false;
|
||||
bool outOfDate_ = false;
|
||||
bool resumeWatch_ = false; // settleLeft_ counts down from a resume
|
||||
uint32_t misfitFrames_ = 0; // frames the view hasn't filled its window
|
||||
uint32_t refusedFrames_ = 0; // frames the surface has been refused
|
||||
uint64_t lastSameRecreate_ = 0; // frame_ of the last SUBOPTIMAL rebuild at an unchanged size (0: none)
|
||||
};
|
||||
|
||||
// The UI's display size (points) and framebuffer scale for a swapchain
|
||||
// of `framebuffer` pixels, given the window's size in points. While they
|
||||
// agree (to 2 points) the window's size stands, as before; when they
|
||||
// don't (a view SDL turned, or a swapchain kept while it is fitted back)
|
||||
// the swapchain's own shape wins, at the window's pixels per point.
|
||||
struct UiDisplay
|
||||
{
|
||||
float width = 0, height = 0; // ImGui's DisplaySize
|
||||
float scaleX = 1, scaleY = 1; // DisplayFramebufferScale
|
||||
bool fromSwapchain = false; // the window's size was overruled
|
||||
};
|
||||
UiDisplay FitUiDisplay(float windowWidth, float windowHeight, Extent framebuffer);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The swapchain the present waiter's thread may use, against the main thread
|
||||
// destroying it and creating another (video/presenter.cpp).
|
||||
//
|
||||
// The waiter (PresentWaiter) waits for each present to reach the screen
|
||||
// (vkWaitForPresentKHR) and, on a Mac showing frames at a time, reads the
|
||||
// driver's reports on the presents shown (vkGetPastPresentationTimingGOOGLE,
|
||||
// for present_schedule.h's watchdog). Both take the swapchain. The main
|
||||
// thread destroys it and makes another whenever the surface changes: a
|
||||
// fullscreen toggle, a resize, V-Sync. In v0.1.4 and v0.1.5 the wait looked
|
||||
// at the swapchain under a mutex, but the reports were read after it,
|
||||
// outside it: when the main thread destroyed the swapchain in between,
|
||||
// MoltenVK read a freed swapchain and the game aborted ("mutex lock failed"
|
||||
// in vkGetPastPresentationTimingGOOGLE: a MacBook's 120 Hz panel, the
|
||||
// default settings, fullscreen toggled again and again). The reports had
|
||||
// been read that way only for NFSMW_VBLANK_LOG until v0.1.4's watchdog read
|
||||
// them every frame.
|
||||
//
|
||||
// Every use of the swapchain off the main thread goes through Use(), which
|
||||
// runs it only while the swapchain it is for is still the one published, and
|
||||
// holds Retire() off until it returns. Retire() (before the swapchain is
|
||||
// destroyed) returns once no use is under way, and none starts after it.
|
||||
// Uses name the swapchain by its generation, not its handle: a new swapchain
|
||||
// may get a destroyed one's handle (MoltenVK's and Mesa's are pointers), and
|
||||
// a present to the old one must not be waited for on the new one.
|
||||
//
|
||||
// Header only (tests/swapchain_use_test.cpp).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
namespace video
|
||||
{
|
||||
template <typename Handle>
|
||||
class SwapchainUse
|
||||
{
|
||||
public:
|
||||
// Main thread, once `swapchain` is made: uses may take it from now
|
||||
// on. Its generation (never 0), which they name it by.
|
||||
uint64_t Publish(Handle swapchain)
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_swapchain = swapchain;
|
||||
m_generation = ++m_lastGeneration;
|
||||
return m_generation;
|
||||
}
|
||||
|
||||
// Main thread, before the published swapchain is destroyed: on
|
||||
// return no use of it is under way, and none will start.
|
||||
void Retire()
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
m_swapchain = Handle{};
|
||||
m_generation = 0;
|
||||
}
|
||||
|
||||
// Any thread: use(swapchain) if `generation` is still the published
|
||||
// one, with Retire() held off until it returns. False: that
|
||||
// swapchain is gone (or about to be), and `use` didn't run.
|
||||
template <typename F>
|
||||
bool Use(uint64_t generation, F&& use)
|
||||
{
|
||||
std::lock_guard lock(m_mutex);
|
||||
if (generation == 0 || generation != m_generation)
|
||||
return false;
|
||||
use(m_swapchain);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
std::mutex m_mutex;
|
||||
Handle m_swapchain{};
|
||||
uint64_t m_generation = 0; // the published one's; 0: none
|
||||
uint64_t m_lastGeneration = 0;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Which pass draws the game's picture into the window (video/presenter.cpp),
|
||||
// and Settings > Graphics > Sharpening as FSR 1's RCAS strength. Pure
|
||||
// functions, for tests/upscale_test.cpp.
|
||||
//
|
||||
// Settings > Graphics > Upscaling: Bicubic (Catmull-Rom, sharpened and
|
||||
// clamped to the 2x2 texels around each sample), Bilinear, or FSR 1 (AMD
|
||||
// FidelityFX Super Resolution 1: EASU, an edge-adaptive Lanczos-like
|
||||
// upscale, then RCAS, a contrast-adaptive sharpening pass; video/fsr1.h).
|
||||
// FSR 1 is made for enlarging (AMD: 1x to 4x the pixels): it is used only
|
||||
// when the picture on the screen is larger than the frame image, along one
|
||||
// axis or both, and smaller along neither (1x Internal Resolution on a
|
||||
// 1080p, 1440p or MacBook screen; the Steam Deck's 720 rows stretched to
|
||||
// 800). A frame shown at its own size, or shrunk (2x on a 1080p screen),
|
||||
// takes the bicubic pass exactly as before FSR 1 existed.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::upscale
|
||||
{
|
||||
// Settings > Graphics > Upscaling's values (settings::ScalingMode).
|
||||
enum class Mode : int32_t { Bicubic = 0, Linear = 1, Fsr1 = 2 };
|
||||
|
||||
enum class Pass : uint8_t { Linear, Bicubic, Fsr1 };
|
||||
|
||||
inline const char* PassName(Pass pass)
|
||||
{
|
||||
return pass == Pass::Fsr1 ? "FSR 1" : pass == Pass::Bicubic ? "bicubic" : "bilinear";
|
||||
}
|
||||
|
||||
// The picture enlarged: dstW x dstH (its place on the screen) is larger
|
||||
// than the srcW x srcH image along one axis and smaller along neither.
|
||||
inline bool Enlarged(uint32_t srcW, uint32_t srcH, int32_t dstW, int32_t dstH)
|
||||
{
|
||||
if (!srcW || !srcH || dstW <= 0 || dstH <= 0)
|
||||
return false;
|
||||
const bool notSmaller = uint32_t(dstW) >= srcW && uint32_t(dstH) >= srcH;
|
||||
return notSmaller && (uint32_t(dstW) > srcW || uint32_t(dstH) > srcH);
|
||||
}
|
||||
|
||||
// The pass for this frame. An unknown mode is bicubic, the default.
|
||||
inline Pass Choose(int32_t mode, uint32_t srcW, uint32_t srcH, int32_t dstW, int32_t dstH)
|
||||
{
|
||||
switch (Mode(mode))
|
||||
{
|
||||
case Mode::Linear:
|
||||
return Pass::Linear;
|
||||
case Mode::Fsr1:
|
||||
return Enlarged(srcW, srcH, dstW, dstH) ? Pass::Fsr1 : Pass::Bicubic;
|
||||
default:
|
||||
return Pass::Bicubic;
|
||||
}
|
||||
}
|
||||
|
||||
// Sharpening (0..1, default 0.3) for RCAS: the strength AMD's FsrRcasCon
|
||||
// takes as stops below its maximum (0: the most it sharpens, each stop
|
||||
// half as much), here as the linear factor it becomes (2^-stops), which
|
||||
// multiplies RCAS's sharpening lobe: Sharpening 1 is RCAS's maximum,
|
||||
// 0.5 half of it, and 0 turns RCAS off (EASU's picture as it is), as 0
|
||||
// turns bicubic's sharpening off.
|
||||
inline float RcasAmount(float sharpening)
|
||||
{
|
||||
if (!(sharpening > 0.0f)) // 0, negative or NaN
|
||||
return 0.0f;
|
||||
return std::min(sharpening, 1.0f);
|
||||
}
|
||||
|
||||
// The same as FsrRcasCon's stops (infinity: off).
|
||||
inline float RcasStops(float sharpening)
|
||||
{
|
||||
const float amount = RcasAmount(sharpening);
|
||||
return amount > 0.0f ? -std::log2(amount) : INFINITY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See vblank_timer.h.
|
||||
#include "vblank_timer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <string_view>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
// macOS only: iOS keeps the ordinary thread (its vblank timing hasn't been measured).
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
#define SB_REALTIME_VBLANK 1
|
||||
#include <mach/mach.h>
|
||||
#include <mach/mach_time.h>
|
||||
#include <mach/thread_policy.h>
|
||||
#include <pthread.h>
|
||||
#endif
|
||||
|
||||
namespace video
|
||||
{
|
||||
namespace
|
||||
{
|
||||
#ifdef SB_REALTIME_VBLANK
|
||||
uint32_t ToMach(int64_t ns)
|
||||
{
|
||||
static const mach_timebase_info_data_t timebase = [] {
|
||||
mach_timebase_info_data_t tb{};
|
||||
mach_timebase_info(&tb);
|
||||
return tb;
|
||||
}();
|
||||
return uint32_t(double(std::max<int64_t>(ns, 0)) * double(timebase.denom) / double(timebase.numer));
|
||||
}
|
||||
#endif
|
||||
|
||||
int64_t Percentile(std::vector<int64_t> v, size_t permille)
|
||||
{
|
||||
if (v.empty())
|
||||
return 0;
|
||||
size_t k = std::min(v.size() - 1, v.size() * permille / 1000);
|
||||
std::nth_element(v.begin(), v.begin() + ptrdiff_t(k), v.end());
|
||||
return v[k];
|
||||
}
|
||||
}
|
||||
|
||||
VblankTimer ChosenVblankTimer()
|
||||
{
|
||||
const char* v = std::getenv("NFSMW_VBLANK_TIMER");
|
||||
return v && std::string_view(v) == "sleep" ? VblankTimer::Ordinary : VblankTimer::RealTime;
|
||||
}
|
||||
|
||||
bool PrepareVblankThread(VblankTimer timer, int64_t periodNs)
|
||||
{
|
||||
#ifdef SB_REALTIME_VBLANK
|
||||
if (timer != VblankTimer::RealTime)
|
||||
return false;
|
||||
// Woken at its time, ahead of the game's own threads: it runs the
|
||||
// guest's vblank handler (microseconds) and sleeps again.
|
||||
thread_time_constraint_policy_data_t policy{};
|
||||
policy.period = ToMach(periodNs);
|
||||
policy.computation = ToMach(1'000'000);
|
||||
policy.constraint = ToMach(2'000'000);
|
||||
policy.preemptible = 1;
|
||||
return thread_policy_set(pthread_mach_thread_np(pthread_self()), THREAD_TIME_CONSTRAINT_POLICY,
|
||||
reinterpret_cast<thread_policy_t>(&policy), THREAD_TIME_CONSTRAINT_POLICY_COUNT) == KERN_SUCCESS;
|
||||
#else
|
||||
(void)timer;
|
||||
(void)periodNs;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
const char* VblankTimerName(VblankTimer timer, bool realtime)
|
||||
{
|
||||
#ifdef SB_REALTIME_VBLANK
|
||||
if (timer == VblankTimer::RealTime)
|
||||
return realtime ? "real-time" : "ordinary (real-time refused)";
|
||||
return "ordinary (NFSMW_VBLANK_TIMER=sleep)";
|
||||
#else
|
||||
(void)timer;
|
||||
(void)realtime;
|
||||
return "ordinary";
|
||||
#endif
|
||||
}
|
||||
|
||||
void VblankTiming::Add(int64_t lateNs, int64_t handlerNs)
|
||||
{
|
||||
if (m_late.size() < kCount)
|
||||
{
|
||||
m_late.push_back(lateNs);
|
||||
m_handler.push_back(handlerNs);
|
||||
return;
|
||||
}
|
||||
m_late[m_next] = lateNs;
|
||||
m_handler[m_next] = handlerNs;
|
||||
m_next = (m_next + 1) % kCount;
|
||||
}
|
||||
|
||||
VblankTiming::Summary VblankTiming::Summarize() const
|
||||
{
|
||||
Summary s;
|
||||
s.count = uint32_t(m_late.size());
|
||||
if (m_late.empty())
|
||||
return s;
|
||||
s.lateMedianNs = double(Percentile(m_late, 500));
|
||||
s.late99Ns = double(Percentile(m_late, 990));
|
||||
s.lateMaxNs = double(*std::max_element(m_late.begin(), m_late.end()));
|
||||
s.handler99Ns = double(Percentile(m_handler, 990));
|
||||
return s;
|
||||
}
|
||||
}
|
||||