8 Commits
Author SHA1 Message Date
9d2cb93f2d SpeedBreaker v0.1.6
Co-authored-by: aleszczynskig <36401256+aleszczynskig@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 04:39:51 +00:00
ad14799d14 SpeedBreaker v0.1.5
Co-authored-by: love-gamer <340996855+love-gamer@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:30:22 +00:00
d4c206ab98 SpeedBreaker v0.1.4
Co-authored-by: love-gamer <340996855+love-gamer@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:13:47 +00:00
SpeedBreaker ProjectandClaude Opus 5.5 b49fc321d1 SpeedBreaker v0.1.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:53:12 +00:00
SpeedBreaker ProjectandClaude Opus 5.5 34e1f75901 Docs for v0.1.2: screenshots from v0.1.2 (sunlit roads, the round sun glare), the IPA not yet installed on a device; iOS build.sh's symbol check
The README's screenshots are new captures from v0.1.2 at the same moments.
The README, scripts/ios/README.md and the release text now say the IPA
itself hasn't been installed on a device yet (the tested iPhones and iPad
ran Xcode-signed builds), which sideloading tools keep which entitlement
with a paid account, and that an IPA installed over a self-built app is a
separate app. THIRD_PARTY_NOTICES.md names the IPA's licenses folder.

scripts/ios/build.sh counts the vkGetInstanceProcAddr match instead of
using grep -q, which with pipefail could stop a complete build with a false
"stripped" alarm (building it yourself, and make_ipa.sh without --app).
The v0.1.2 downloads were built from the previous commit; this one changes
none of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:36:48 +00:00
e128baca5e SpeedBreaker v0.1.2
Co-authored-by: Lydacious <81742991+lydacious@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:51:57 +00:00
SpeedBreaker ProjectandClaude Opus 5.5 a4a72ce103 CHANGELOG: show the install command as code, so GitHub doesn't render <image> as a tag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:07:52 +00:00
SpeedBreaker ProjectandClaude Opus 5.5 05838d6b8b SpeedBreaker v0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:58:40 +00:00
181 changed files with 37538 additions and 988 deletions

No files matched your search

+285
View File
@@ -4,6 +4,291 @@ 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
- The Steam Deck and 16:10 Macs fill the screen: a slightly taller view, with the HUD kept at
16:9. Settings > Display > Aspect Ratio > 16:9 brings the bars back.
- Sunlit roads and correct shadows. A bug in the shadow map left every road in shade, on every
device: the roads are now as bright as on the Xbox 360, and the line between shade and sun that
followed the car is gone (GitHub issue #3). Thanks to the Reddit tester who spotted it.
- Cutscene bars: the police video camera in the career intro, and the intro's black frames, now
cover the whole screen on taller and wider screens.
- macOS 15 Sequoia support: the Mac app runs on macOS 15 or later (v0.1.0 needed macOS 26). On
macOS 26 it runs exactly as before.
- Linux startup crash fixes: no crash at startup on graphics cards without Resizable BAR (NVIDIA),
and none expected on Intel's Linux driver (GitHub issue #2).
- Choose a folder... in the installer: put the game in any folder you like (GitHub issue #1).
- Check for Updates, in Settings > Advanced.
- A cleaner Linux folder: speedbreaker.sh is the only thing at the top to run, and the program
moved into lib/.
### More about it
- Choose a folder... is on the installer's last page. An empty folder is used as it is; one that
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).
- 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
unless you press Check for Updates.
- Steam Deck, Steam Machine, Steam Frame and Linux: always start the game with speedbreaker.sh, as
your Steam shortcut does. The program in lib/ can't pick its libraries on its own. Unpacked over
v0.1.0, the first start removes v0.1.0's program from the top of the folder.
- 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.0
The first public release of SpeedBreaker: an unofficial static recompilation of the Xbox 360
+57 -7
View File
@@ -11,12 +11,54 @@
cmake_minimum_required(VERSION 3.20)
# macOS (not iOS, which scripts/ios/build.sh sets to 17.0): the oldest macOS
# the game runs on, which the Mac app declares (scripts/macos/make_app.py
# reads it from the binaries and refuses a library that needs a newer one).
# Homebrew's bottles for macOS 26 are built for 26.0, so that's the floor
# while the app bundles them. Before project(): it picks the SDK's settings.
if(CMAKE_HOST_APPLE AND NOT CMAKE_SYSTEM_NAME STREQUAL "iOS" AND NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET)
set(CMAKE_OSX_DEPLOYMENT_TARGET "26.0" CACHE STRING "The oldest macOS the game runs on")
# the game runs on, 15.0 (Sequoia), which the Mac app declares
# (scripts/macos/make_app.py reads it from the binaries and refuses a library
# that needs a newer one). The libraries the app bundles must be built for it
# too: scripts/macos/build_deps.sh builds them from source (Homebrew's
# bottles are built for the macOS they were made on, 26.0 on macOS 26), and
# the build takes them with -DCMAKE_PREFIX_PATH=<its prefix>
# -DFFMPEG_XMA=<its prefix>/ffmpeg-xma. The target sets only which macOS
# APIs and libc++ features the code may use: the CPU code is the same for
# any target (clang's default for arm64 Macs, apple-m1), and
# scripts/macos/check_codegen.py proves it for a release's commit (its game
# code for this target against the same code for macOS 26.0, function by
# function). Before project(): it picks the SDK's settings.
#
# A build folder keeps the target it was first configured with, so a folder
# still holding one of our earlier defaults (26.0, up to v0.1.0) moves to
# this one, with a line saying so. SB_OSX_TARGET_FROM remembers where the
# folder's target came from: "default <version>" (ours, which follows this
# file), or "chosen" (given with -DCMAKE_OSX_DEPLOYMENT_TARGET=..., or
# changed in the cache: kept; scripts/macos/make_app.py warns that such an
# app isn't the release's).
set(SB_OSX_DEFAULT_TARGET "15.0")
set(SB_OSX_EARLIER_DEFAULTS "26.0") # in folders configured before SB_OSX_TARGET_FROM
if(CMAKE_HOST_APPLE AND NOT CMAKE_SYSTEM_NAME STREQUAL "iOS")
set(_sb_follow OFF)
if(NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET)
set(_sb_follow ON)
elseif(DEFINED CACHE{SB_OSX_TARGET_FROM})
# Ours, unless it has been changed since.
set(_sb_follow OFF)
if(SB_OSX_TARGET_FROM STREQUAL "default ${CMAKE_OSX_DEPLOYMENT_TARGET}")
set(_sb_follow ON)
endif()
elseif(DEFINED CACHE{SpeedBreaker_BINARY_DIR} AND (CMAKE_OSX_DEPLOYMENT_TARGET IN_LIST SB_OSX_EARLIER_DEFAULTS
OR CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL SB_OSX_DEFAULT_TARGET))
# Configured before SB_OSX_TARGET_FROM (project() has run in this
# folder before, so it isn't a -D of a first configure) with one of
# our defaults.
set(_sb_follow ON)
endif()
if(_sb_follow)
if(DEFINED CMAKE_OSX_DEPLOYMENT_TARGET AND NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL SB_OSX_DEFAULT_TARGET)
message(STATUS "macOS deployment target: ${CMAKE_OSX_DEPLOYMENT_TARGET} (an earlier default) -> ${SB_OSX_DEFAULT_TARGET}")
endif()
set(CMAKE_OSX_DEPLOYMENT_TARGET "${SB_OSX_DEFAULT_TARGET}" CACHE STRING "The oldest macOS the game runs on" FORCE)
set(SB_OSX_TARGET_FROM "default ${SB_OSX_DEFAULT_TARGET}" CACHE INTERNAL "Where CMAKE_OSX_DEPLOYMENT_TARGET came from")
else()
set(SB_OSX_TARGET_FROM "chosen" CACHE INTERNAL "Where CMAKE_OSX_DEPLOYMENT_TARGET came from")
endif()
endif()
# The version: the one place it is set. Everything else comes from it: the
@@ -24,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.0 LANGUAGES C CXX)
project(SpeedBreaker VERSION 0.1.6 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -40,6 +82,14 @@ if(NOT SB_SOURCE_REAL STREQUAL CMAKE_SOURCE_DIR)
add_compile_options("-ffile-prefix-map=${SB_SOURCE_REAL}=.")
endif()
# macOS: a call to an API newer than the deployment target is an error unless
# it's checked for (@available / __builtin_available), since it would crash
# on an older macOS the app says it runs on. A diagnostic only: the code
# generated is the same.
if(APPLE AND NOT IOS)
add_compile_options("$<$<COMPILE_LANGUAGE:C,CXX,OBJC,OBJCXX>:-Werror=unguarded-availability-new>")
endif()
add_subdirectory(recomp)
add_subdirectory(runtime)
+48
View File
@@ -0,0 +1,48 @@
# Contributing to SpeedBreaker
Thanks for helping. Here's how this project works, so your time isn't wasted.
## Reporting a bug
1. In the game, open the settings (F1, Back + Start on a controller, or a three-finger tap on
iPhone and iPad), go to **Advanced** and choose **Save Bug Report**. You get one zip file
with the logs, your settings and a screenshot. Nothing is uploaded.
2. Check the [open issues](https://github.com/SpeedBreakerProject/speedbreaker/issues) first.
3. Open a new issue and attach the zip. Say which device and download you use, what you were
doing, what you expected, and what happened.
**Never attach game files, disc images or anything from your game folder.** That includes the
generated `ppc/` folder, which is made from the game's code.
## Pull requests
Pull requests are welcome, but they are never merged with the button. SpeedBreaker is developed
in a separate tree, and each release arrives here as a single commit.
1. If a change is accepted, it's applied by hand to the development tree, and you're credited in
the commit and in the changelog.
2. It ships in the next release, and the pull request is closed with a note saying so.
3. If it isn't taken, you'll get the reason.
To make a pull request easy to accept:
- Keep it small and focused: one fix or feature per request.
- Say how you tested it: device, OS, and what you played or ran.
- Include no game code, game data or generated `ppc/` output.
- If you used AI tools, that's fine, as long as you've read, understood and tested the change
yourself. Everything here is held to the same bar.
By contributing, you agree that your contribution is licensed under the project's license,
GPL-3.0-or-later.
## Scope
**Welcome:**
- bug fixes;
- device and OS support;
- performance;
- accuracy to the original Xbox 360 version.
**Out of scope:**
- anything that helps people get game files;
- online services;
- anything that changes how the game plays by default.
+23
View File
@@ -0,0 +1,23 @@
AMD FidelityFX Super Resolution 1 (FSR 1): ffx_a.h and ffx_fsr1.h
https://github.com/GPUOpen-Effects/FidelityFX-FSR (commit a21ffb8f6c13233ba336352bdff293894c706575, files ffx-fsr/ffx_a.h and ffx-fsr/ffx_fsr1.h)
Vendored unmodified in runtime/thirdparty/fsr1; their shader code is compiled into SpeedBreaker.
Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+26
View File
@@ -0,0 +1,26 @@
Copyright (c) 2010, Alan Ott, Signal 11 Software
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of Signal 11 Software nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
+502
View File
@@ -0,0 +1,502 @@
Files: All files except for those called out below.
Copyright (c) 2015-2024 The Khronos Group Inc.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and/or associated documentation files (the
"Materials"), to deal in the Materials without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Materials, and to
permit persons to whom the Materials are 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 Materials.
MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS
KHRONOS STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS
SPECIFICATIONS AND HEADER INFORMATION ARE LOCATED AT
https://www.khronos.org/registry/
THE MATERIALS ARE 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
MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS.
---
Files: include/spirv/spir-v.xml
Copyright (c) 2015-2024 The Khronos Group Inc.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and/or associated documentation files (the
"Materials"), to deal in the Materials without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Materials, and to
permit persons to whom the Materials are 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 Materials.
THE MATERIALS ARE 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
MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS.
---
Files: tools/buildHeaders/jsoncpp/*
The JsonCpp library's source code, including accompanying documentation,
tests and demonstration applications, are licensed under the following
conditions...
The author (Baptiste Lepilleur) explicitly disclaims copyright in all
jurisdictions which recognize such a disclaimer. In such jurisdictions,
this software is released into the Public Domain.
In jurisdictions which do not recognize Public Domain property (e.g. Germany as of
2010), this software is Copyright (c) 2007-2010 by Baptiste Lepilleur, and is
released under the terms of the MIT License (see below).
In jurisdictions which recognize Public Domain property, the user of this
software may choose to accept it either as 1) Public Domain, 2) under the
conditions of the MIT License (see below), or 3) under the terms of dual
Public Domain/MIT License conditions described here, as they choose.
The MIT License is about as close to Public Domain as a license can get, and is
described in clear, concise terms at:
http://en.wikipedia.org/wiki/MIT_License
The full text of the MIT License follows:
========================================================================
Copyright (c) 2007-2010 Baptiste Lepilleur
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.
========================================================================
---
Files: **.md, WORKSPACE, .git**
Attribution 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution 4.0 International Public License ("Public License"). To the
extent this Public License may be interpreted as a contract, You are
granted the Licensed Rights in consideration of Your acceptance of
these terms and conditions, and the Licensor grants You such rights in
consideration of benefits the Licensor receives from making the
Licensed Material available under these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
j. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
k. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part; and
b. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2015-2023 The Khronos Group Inc.
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.
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
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.
+24
View File
@@ -0,0 +1,24 @@
Copyright (c) 2013-2022, Randolph Voorhies, Shane Grant
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+23
View File
@@ -0,0 +1,23 @@
QR Code generator library (C), version 1.8.0
https://www.nayuki.io/page/qr-code-generator-library
https://github.com/nayuki/QR-Code-generator (tag v1.8.0, commit 720f62bddb7226106071d4728c292cb1df519ceb)
Copyright (c) 2022 Project Nayuki. (MIT License)
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.
+27
View File
@@ -0,0 +1,27 @@
Copyright (c) 2016, Adrien Descamps
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of yuv2rgb nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+212 -72
View File
@@ -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">
@@ -38,7 +38,8 @@
> disc images. See the full [legal notice](#legal-notice).
**Contents:** [What it is](#what-is-speedbreaker) · [Highlights](#highlights) ·
[Download](#download) · [Quick start](#quick-start) · [Setup by platform](#setup-by-platform) ·
[Download](#download) · [Quick start](#quick-start) · [How to update](#how-to-update) ·
[Setup by platform](#setup-by-platform) ·
[Controls](#controls) · [Settings](#settings) · [Tips](#tips-for-smooth-play) ·
[FAQ](#faq-and-troubleshooting) · [What's next](#whats-next) ·
[For developers](#for-developers) · [Credits](#credits) · [License](#license) ·
@@ -59,28 +60,32 @@ 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 or a 3:2 screen shows more above and
below instead. 16:10 screens, like the Steam Deck's, keep thin bars.
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.
- **Up to 60 fps,** locked to your 60 Hz display. You can also pick a steady 30 at full game
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:** 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
uploaded, and you decide what to send.
- **Check for Updates.** One press in the settings asks GitHub whether a newer release is out,
shows what changed and opens the download page (or shows it as a QR code for your phone).
Nothing is checked online unless you press Check for Updates.
<table>
<tr>
@@ -97,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
@@ -107,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. 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 | |
@@ -127,10 +133,11 @@ 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.
- **Mac:** Apple silicon and **macOS 26** or later. The app won't open on older versions of
macOS, and there is no Intel Mac build.
- **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
later, M-series iPads), and a controller or keyboard. Devices with less memory than the tested
ones are untested.
@@ -142,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
@@ -150,10 +158,16 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
<img src="assets/screenshots/speedbreaker-12-installer-every-file-verified.png" alt="The installer's last page: every file matched the known-good disc" width="600">
4. **Race.** Your saves and settings are kept apart from the game, so they stay when you update
SpeedBreaker or reinstall the game. The settings open with **F1**, **Back + Start** on a
4. **Race.** Your saves and settings are kept apart from the game, so they stay when you
[update SpeedBreaker](#how-to-update) or reinstall the game. The settings open with **F1**, **Back + Start** on a
controller, or a **three-finger tap** on iPhone and iPad.
### How to update
- **Mac:** drag the new SpeedBreaker into Applications, choose **Replace**, then allow it once more with **Open Anyway** (see [Mac setup](#mac)).
- **Steam Deck, Steam Machine, Steam Frame and Linux:** unpack the new download over the old folder (`~/Games/SpeedBreaker`); your Steam shortcut keeps working.
- **Everywhere:** your saves, settings and the installed game are kept, and no disc image is needed. Settings > Advanced > **Check for Updates** says when there's a new release, and so does GitHub's **Watch > Custom > Releases**.
## Setup by platform
### Steam Deck and Steam Machine
@@ -167,12 +181,12 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
```
Keep it on internal storage or on an SD card formatted by SteamOS, and don't use `:`, `;` or
`$` in the folder names. To update later, delete the old download, download the new one and
unpack it over the same folder.
`$` in the folder names. To update later, unpack the new download over the same folder
([How to update](#how-to-update)).
2. **Add it to Steam.** Steam > Games > *Add a Non-Steam Game to My Library* > Browse. Set the
file type to *All Files* and pick `~/Games/SpeedBreaker/speedbreaker.sh`. Leave
*Compatibility* (Proton) off: this is a native Linux game.
file type to *All Files* and pick `~/Games/SpeedBreaker/speedbreaker.sh`, the launcher (the
program itself is in `lib/`). Leave *Compatibility* (Proton) off: this is a native Linux game.
3. **Put your disc image in Downloads,** or in the top folder of an SD card. SpeedBreaker also
looks in your home folder, Desktop, Documents, Games, USB drives and EmuDeck's
`Emulation/roms/xbox360`. Game Mode only sees SD cards and USB drives formatted by SteamOS;
@@ -180,13 +194,20 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
4. **Install** (back in Game Mode). Start SpeedBreaker from your library. It highlights the image
it found (*Ready to install*; with several, pick yours with Up and Down): press **A**, then
**A** again on *Install*. Use the images it finds rather than *Browse for image...*, which may
not open in Game Mode.
not open in Game Mode. The game goes to the internal storage; to put it on the SD card or in
another folder, press Up and pick with Left and Right before *Install*. *Choose a folder...*
opens a folder picker in Desktop Mode only. From Game Mode, switch to Desktop Mode and choose
the folder there, or run `~/Games/SpeedBreaker/speedbreaker.sh --install <image> --dest <folder>`
from Konsole in Desktop Mode (or over SSH). Use a folder on the internal storage or on a card
formatted by SteamOS, or Game Mode won't find the game.
5. **Check the screen settings.**
- **Steam Deck OLED: set the refresh rate to 60 Hz** (Quick Access > Performance). At 90 Hz
the game stutters. On any Deck, leave the Framerate Limit off.
- **Docked, or on a Steam Machine:** in the shortcut's Properties > General > Game
Resolution, choose **Native**.
- The thin bars above and below the picture on the Deck's screen are intended.
- The picture fills the Deck's 16:10 screen, with a slightly taller view and the HUD kept at
16:9. For the console's 16:9 picture with thin bars above and below, set Settings > Display
> Aspect Ratio to 16:9.
**Controls:** the Deck's controls work through Steam Input (default Gamepad layout).
**Settings:** hold **View** (two squares, left of the screen) and press **Menu** (three lines,
@@ -195,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
@@ -210,8 +238,8 @@ The Steam Frame has its own download. You set it up on the Frame's desktop: open
```
This makes `~/Games/SpeedBreaker`. Keep it on the Frame's internal storage, and don't use `:`,
`;` or `$` in the folder names. To update later, delete the old download, download the new
one and unpack it over the same folder.
`;` or `$` in the folder names. To update later, unpack the new download over the same folder
([How to update](#how-to-update)).
2. **Add it to Steam.** In the file manager, open `~/Games/SpeedBreaker`, right-click
`speedbreaker.sh` and choose *Add to Steam*. Or, in Konsole:
@@ -222,7 +250,7 @@ The Steam Frame has its own download. You set it up on the Frame's desktop: open
4. **Install.** Start SpeedBreaker from your Steam library. It highlights the image it found
(*Ready to install*): press **A**, then **A** again on *Install*. Or install
from Konsole: `~/Games/SpeedBreaker/speedbreaker.sh --install ~/Downloads/your-image.iso`
(with your image's name).
(with your image's name; add `--dest <folder>` to put the game in a folder of your choice).
SpeedBreaker plays as a big flat screen in the headset. It is not a VR mode.
@@ -235,10 +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`. The game starts in a window; Display > Fullscreen switches to full
screen.
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 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
@@ -253,16 +292,35 @@ screen.
place SpeedBreaker looks by itself, so macOS doesn't have to ask for access to your other
folders. For an image anywhere else, use *Browse for image...*.
4. **Install.** The game goes to `~/Library/Application Support/SpeedBreaker/game`, or to an
external drive if you choose one.
external drive or any other folder if you choose one (*Choose a folder...*, with the arrows on
the *To* row). An empty folder is used as it is; a folder that already holds files gets a new
`SpeedBreaker Game` folder inside it. For a folder in Desktop, Documents or Downloads, or on an
external drive, macOS asks once whether SpeedBreaker may use it: choose **Allow**. iCloud Drive
can't hold the game (nor Desktop and Documents, if your Mac keeps them in iCloud): macOS would
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 26 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:
@@ -289,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 |
@@ -297,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:**
@@ -320,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>
@@ -329,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 and 3:2 screens a taller one. 16:9 always shows the classic picture with bars. |
| 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.
@@ -352,9 +425,14 @@ 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), and **Save Bug Report**. The page also shows which build you're running.
and GPU time per frame), **Save Bug Report**, and **Check for Updates** (the only thing that goes
online, and only when you press it). The page also shows which build you're running.
</details>
@@ -362,12 +440,20 @@ and GPU time per frame), and **Save Bug Report**. The page also shows which buil
- **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.
@@ -408,22 +494,49 @@ It always says what to do. The usual causes:
- *Missing or corrupt files:* make a new backup from your disc.
**My Mac says SpeedBreaker can't be opened.** Use **Open Anyway** in System Settings > Privacy &
Security (see [Mac setup](#mac)).
Security (see [Mac setup](#mac)), again after each update. SpeedBreaker needs macOS 15 (Sequoia)
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.
**My Deck shows bars above and below the picture.** That's intended: the game is 16:9 and the
Deck's screen is 16:10.
**Can I have the bars back on my Deck?** Yes. The game is 16:9 and the Deck's screen 16:10, and
since v0.1.1 the picture fills it with a slightly taller view. Set Settings > Display > Aspect
Ratio to 16:9 for the console's picture with thin bars above and below.
**My SD card or USB drive doesn't show up in Game Mode.** Game Mode only sees drives formatted by
SteamOS. Copy the image into Downloads from Desktop Mode.
**Can I install the game somewhere other than the default folder?** Yes. On the installer's last
page, choose where it goes on the *To* row: internal storage, a removable drive, or *Choose a
folder...* for any folder (SteamOS: in Desktop Mode). An empty folder is used as it is; a folder
that already holds files gets a new `SpeedBreaker Game` folder inside it, and the top of a drive
gets the drive's own `speedbreaker/game` (`SpeedBreaker/game` on a Mac). Or install from a
terminal with `--dest` (see [Command-line installer](#command-line-installer)). SpeedBreaker
remembers the place; if it's on a drive that isn't connected when the game starts, the installer
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.
@@ -432,10 +545,13 @@ Touch only works in SpeedBreaker's own screens; a three-finger tap opens the set
| | Linux and SteamOS | Mac | iPhone and iPad |
|---|---|---|---|
| Saves, settings, logs | `~/.local/share/speedbreaker/` | `~/Library/Application Support/SpeedBreaker/` | Inside the app (not shown in the Files app) |
| Installed game | `game/` in that folder, or `speedbreaker/game` on a drive | `game/` in that folder, or `SpeedBreaker/game` on a drive | Inside the app |
| Installed game | `game/` in that folder, `speedbreaker/game` on a drive, or the folder you chose | `game/` in that folder, `SpeedBreaker/game` on a drive, or the folder you chose | Inside the app |
| 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.
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. 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
@@ -443,14 +559,15 @@ Deleting that folder, or deleting the iPhone or iPad app, deletes your saves.
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.0-<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.
The zip holds recent logs, any crash and hang reports, your settings, a summary of your system
and a screenshot of the last game frame. Paths in your home folder show as `~`. If the game
crashed, start it again and save a report: the crash report is included. **Never attach game
files or disc images to an issue.**
files or disc images to an issue.** [CONTRIBUTING.md](CONTRIBUTING.md) says how bug reports and
pull requests are handled.
## What's next
@@ -476,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.
@@ -488,16 +607,22 @@ no game data or assets.
### Command-line installer
The installer also runs from a terminal. On SteamOS, run it through `speedbreaker.sh`; on a Mac,
the program is `SpeedBreaker.app/Contents/MacOS/SpeedBreaker`.
The installer also runs from a terminal. On SteamOS and Linux, run it through `speedbreaker.sh`
(`~/Games/SpeedBreaker/speedbreaker.sh --install ...`); on a Mac, the program is
`SpeedBreaker.app/Contents/MacOS/SpeedBreaker`.
```sh
SpeedBreaker --install <image or folder> [--dest <dir>] # install
SpeedBreaker --install <image or folder> [--dest <dir>] # install (into exactly <dir>, if given)
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`
installs into exactly that folder, which must be new, empty or hold a previous install (not a
temporary folder kept in memory, such as `/tmp` on SteamOS); the game remembers it.
### Building from source
The repository contains no game code: the build recompiles your own `default.xex`, from the USA
@@ -509,8 +634,12 @@ The supported executable is 3,936,256 bytes, with SHA-256
#### 1. Dependencies
- **Mac:** Xcode's command-line tools, and from Homebrew: `brew install llvm lld cmake ninja sdl3
molten-vk vulkan-loader vulkan-headers glslang spirv-tools`
- **Mac:** Xcode's command-line tools, and from Homebrew: `brew install llvm lld cmake ninja
molten-vk`. The libraries the app bundles (SDL3, the Vulkan loader, glslang, SPIRV-Tools and
FFmpeg's XMA decoder) are built from their own source releases by
`scripts/macos/build_deps.sh`, for macOS 15 like the release (step 3). Homebrew's bottles of
them (`brew install sdl3 vulkan-loader vulkan-headers glslang spirv-tools`) also work, for an
app that runs only on the macOS you build on (step 3).
- **SteamOS:** build inside an Arch Linux distrobox (podman and distrobox ship with SteamOS). The
development launcher, `scripts/steamos/speedbreaker.sh`, expects the container to be called
`nfsmw-build`:
@@ -552,10 +681,16 @@ tools/XenonRecomp/build/XenonRecomp/XenonRecomp config/nfsmw.toml tools/XenonRec
cmake -S . -B build/main -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
# Mac (Homebrew's LLVM)
# Mac (Homebrew's LLVM): the libraries for macOS 15, as the release (a few minutes; it downloads
# their source archives and checks each one's SHA-256), then the game against them
scripts/macos/build_deps.sh build/deps
cmake -S . -B build/main -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=/opt/homebrew/opt/llvm/bin/clang \
-DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++
-DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++ \
-DCMAKE_PREFIX_PATH="$PWD/build/deps" -DFFMPEG_XMA="$PWD/build/deps/ffmpeg-xma"
# (or, with Homebrew's libraries instead of build_deps.sh, an app for your own macOS only: the
# same cmake line with -DCMAKE_OSX_DEPLOYMENT_TARGET=<your macOS, e.g. 26.0> instead of the last
# two options; make_app.py names the version if it's wrong)
ninja -C build/main SpeedBreaker
```
@@ -566,11 +701,14 @@ Run `build/main/runtime/SpeedBreaker` from the repository root. With the whole d
#### 4. Package it (optional)
- **Mac app:** `python3 scripts/macos/make_app.py` makes `build/app/SpeedBreaker.app`, with its
libraries, MoltenVK and the license notices inside. Add `--dmg` for
libraries, MoltenVK and the license notices inside. It refuses a library built for a newer
macOS than the build (and says how to fix it); `python3 scripts/macos/check_app.py
build/app/SpeedBreaker.app` runs the release's checks. Add `--dmg` for
`build/app/SpeedBreaker-macos.dmg` and its `.sha256`, laid out like the download on Releases.
- **SteamOS bundle (x86-64):** inside the container, `scripts/steamos/make_bundle.sh` makes
`build/bundle/SpeedBreaker-steamos-x86_64.tar.xz` and its `.sha256` (plus a copy named by the
version, `SpeedBreaker-<version>-steamos-x86_64.tar.xz`). Install it as described in
version, `SpeedBreaker-<version>-steamos-x86_64.tar.xz`): `speedbreaker.sh` at the top, the
program and its libraries in `lib/`. Install it as described in
[Steam Deck setup](#steam-deck-and-steam-machine).
- **Steam Frame bundle (arm64):** the Steam Frame download on Releases is built on a Frame, and
so is your own: the Frame's SteamOS ships clang, CMake, Ninja, glslang and the Vulkan headers,
@@ -620,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 |
@@ -633,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`.
@@ -657,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.
+12 -6
View File
@@ -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)
@@ -27,21 +27,27 @@ collected when the bundle is made).
| **stb_truetype, stb_rect_pack, stb_textedit**: Copyright (c) 2017 Sean Barrett | Font rasterising and text editing, inside Dear ImGui. | MIT (or public domain) | [`LICENSES/stb-MIT.txt`](LICENSES/stb-MIT.txt) |
| **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. | zlib | [`LICENSES/SDL3-Zlib.txt`](LICENSES/SDL3-Zlib.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). | Apache-2.0 | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt) |
| **Vulkan Loader**: The Khronos Group Inc., Valve Corporation, LunarG, Inc. | In the Mac app (SteamOS has its own). | 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 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/` |
Every SteamOS download's `licenses/` folder also holds the license files of the exact packages its
libraries came from.
libraries came from. The Mac app's libraries (all but MoltenVK) are built from their projects' own
source releases by [`scripts/macos/build_deps.sh`](scripts/macos/build_deps.sh), for the oldest macOS
the app supports; the app's `Contents/Resources/licenses/README.txt` names the version, source archive
and SHA-256 of each, next to its license files.
## Source code of the LGPL and GPL parts
Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 KiB

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 294 KiB

+3 -3
View File
@@ -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 },
+45 -26
View File
@@ -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
+45
View File
@@ -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
+5 -1
View File
@@ -22,6 +22,9 @@ from you.
buttons on either side of the screen).
- The D-pad picks a row, left/right changes it, A selects, LB/RB switch
pages, B closes. The game pauses while the menu is open.
- The **Controls** page: choose a row, Left or Right for its first or second key, press A (or
Enter), then the key you want; Esc or B cancels. Y (Backspace) clears a key, X (Delete) restores
the row, *Reset page* the whole table.
## When something goes wrong
@@ -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
+47 -2
View File
@@ -17,6 +17,10 @@ target_compile_options(XenonUtilsLite PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-include
file(GLOB_RECURSE RUNTIME_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
list(FILTER RUNTIME_SOURCES EXCLUDE REGEX "/thirdparty/")
# Nayuki's QR Code generator (thirdparty/qrcodegen, vendored, C): the
# download page's code in Check for Updates. Part of the runtime itself, not
# a library of its own, so the iOS app (scripts/ios/build.sh) links it too.
list(APPEND RUNTIME_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/qrcodegen/qrcodegen.c)
if(APPLE)
# Apple's own glue for macOS and iOS (the thermal state) is
# Objective-C++ on Foundation alone, platform/apple/*.mm. Built with the
@@ -75,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,
@@ -138,9 +156,11 @@ target_link_libraries(SpeedBreaker PRIVATE NfsmwRecompLib XenonUtilsLite o1heap
if(TARGET glslang::SPIRV)
target_link_libraries(SpeedBreaker PRIVATE glslang::SPIRV)
endif()
# platform/apple/*.mm (iOS: linked by scripts/ios/build.sh).
# platform/apple/*.mm (iOS: linked by scripts/ios/build.sh): Foundation, and
# on macOS AppKit (file_dialog.mm) and CoreFoundation (install/placement.cpp:
# is a folder in iCloud Drive).
if(APPLE AND NOT IOS)
target_link_libraries(SpeedBreaker PRIVATE "-framework Foundation")
target_link_libraries(SpeedBreaker PRIVATE "-framework Foundation" "-framework AppKit" "-framework CoreFoundation")
endif()
target_include_directories(SpeedBreaker SYSTEM PRIVATE ${FFMPEG_INCLUDE_DIR} ${XR}/thirdparty/tomlplusplus/include)
# Assertions stay on in every build type: ported code marks its unhandled
@@ -154,3 +174,28 @@ if(IOS)
# have; Apple clang's iOS default is far older.
target_compile_options(SpeedBreaker PRIVATE -mcpu=apple-a15)
endif()
# macOS: the build folder gets MoltenVK's manifest too, next to the game
# (runtime/vulkan/icd.d/MoltenVK_icd.json), naming the MoltenVK that
# scripts/macos/make_app.py bundles (MOLTENVK in the environment, else
# Homebrew's molten-vk; -DSB_MOLTENVK=<libMoltenVK.dylib> to choose). The game
# points the Vulkan loader at <its folder>/vulkan/icd.d/MoltenVK_icd.json when
# there is one, as in SpeedBreaker.app (video/presenter.cpp). The loader
# scripts/macos/build_deps.sh builds searches only the system's folders
# (/usr/local/etc, /etc, ...), never Homebrew's, where molten-vk keeps its
# manifest: without this, the game run from the build folder (and
# scripts/bench.sh, test_suspend.sh, verify_settings.py) finds no Vulkan
# driver. VK_DRIVER_FILES or VK_ICD_FILENAMES set by hand still win.
if(APPLE AND NOT IOS)
if(DEFINED ENV{MOLTENVK})
set(_sb_moltenvk "$ENV{MOLTENVK}")
else()
set(_sb_moltenvk "/opt/homebrew/opt/molten-vk/lib/libMoltenVK.dylib")
endif()
set(SB_MOLTENVK "${_sb_moltenvk}" CACHE FILEPATH "The MoltenVK the game in the build folder uses")
if(EXISTS "${SB_MOLTENVK}")
file(GENERATE OUTPUT "$<TARGET_FILE_DIR:SpeedBreaker>/vulkan/icd.d/MoltenVK_icd.json"
CONTENT "{\n \"file_format_version\": \"1.0.0\",\n \"ICD\": {\n \"library_path\": \"${SB_MOLTENVK}\",\n \"api_version\": \"1.4.0\",\n \"is_portability_driver\": true\n }\n}\n")
else()
message(STATUS "No MoltenVK at ${SB_MOLTENVK}: the game in the build folder takes the Vulkan loader's own drivers (-DSB_MOLTENVK=<libMoltenVK.dylib> names one)")
endif()
endif()
+125 -7
View File
@@ -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
+6
View File
@@ -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();
}
+96
View File
@@ -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;
};
}
+35 -14
View File
@@ -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;
+6
View File
@@ -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();
}
+57
View File
@@ -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;
};
}
+110
View File
@@ -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]);
}
}
+63
View File
@@ -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;
}
}
}
+120
View File
@@ -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);
}
+174
View File
@@ -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;
}
}
+35 -2
View File
@@ -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)
+94 -23
View File
@@ -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;
}
};
}
+37 -14
View File
@@ -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;
}
+106
View File
@@ -0,0 +1,106 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// 24-bit depth as the resolve shader packs it: formats 22 and 23 (k_24_8,
// k_24_8_FLOAT), stored as unsigned normalized depth in a word's top 24
// bits, the stencil byte below them 0. The expression is written once
// (SB_DEPTH24_PACK_EXPR). The shader gets it as text (GLSL_DEPTH24_PACK,
// spliced into renderer.cpp's kResolveGlsl the way shared_memory_glsl.h's
// GLSL_SHARED_MEMORY_WRITE is), and C++ evaluates the same tokens
// (PackDepth24, through Depth24Glsl's GLSL meanings for them). So the text
// the GPU compiles and the arithmetic tests/depth24_pack_test.cpp checks
// over every binary32 input that can reach the top of the range cannot
// drift apart.
//
// The clamp to 0xFFFFFF is the point. For depth 1.0, v * 16777215.0 + 0.5
// is 16777215.5, which binary32 rounds (ties to even) to 16777216: one past
// 24 bits, so `<< 8` wrapped the far plane to 0, the nearest depth. The game
// clears its 1600x1600 sun shadow map to 1.0, so every texel without a
// caster read "nearest" and every receiver under it was in shadow: dark
// roads, cars without their sun, on every device and setting from the
// first Vulkan renderer (2f796ec) to v0.1.0. 1.0 is the only binary32
// input that overflows, whether the multiply and the add round separately
// or a driver fuses them into one fma (SPIR-V allows that contraction):
// both give 16777216.
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
// packDepth24's body, in tokens that are both GLSL and (with Depth24Glsl's
// definitions of min, uint, clamp, * and +) C++. Its literals must be exact
// in binary32: GLSL reads them as float, Depth24Glsl rounds C++'s double.
#define SB_DEPTH24_PACK_EXPR(v) min(uint(clamp(v, 0.0, 1.0) * 16777215.0 + 0.5), 0xFFFFFFu)
#define SB_DEPTH24_STRINGIFY_(...) #__VA_ARGS__
#define SB_DEPTH24_STRINGIFY(...) SB_DEPTH24_STRINGIFY_(__VA_ARGS__)
// The definition as the shader has it, on a line of its own:
// uint packDepth24(float v) { return min(uint(clamp(v, 0.0, 1.0) * 16777215.0 + 0.5), 0xFFFFFFu); }
#define GLSL_DEPTH24_PACK_DEFINITION "uint packDepth24(float v) { return " SB_DEPTH24_STRINGIFY(SB_DEPTH24_PACK_EXPR(v)) "; }"
#define GLSL_DEPTH24_PACK "\n" \
"// 24-bit unsigned normalized depth, clamped: 1.0 rounds to 2^24 otherwise\n" \
"// (gpu/depth24_glsl.h).\n" \
GLSL_DEPTH24_PACK_DEFINITION "\n"
namespace gpu
{
// How the shader's `v * 16777215.0 + 0.5` may be evaluated: rounded
// after the multiply and again after the add (SPIR-V OpFMul, OpFAdd),
// or rounded once (a driver may contract the two into an fma; SPIR-V
// allows it unless NoContraction is set, and glslang doesn't set it).
enum class Depth24Rounding { Separate, Fused };
// SB_DEPTH24_PACK_EXPR's tokens as GLSL reads them, in binary32, for the
// C++ mirror. Only what the expression uses is here: an expression that
// needs more stops compiling until its GLSL meaning is added.
template <Depth24Rounding R>
struct Depth24Glsl
{
struct Float { float f; };
struct Product { float a, b; }; // a * b: rounded on its own (Separate) or only with the add (Fused)
// GLSL's uint(float) truncates. Outside uint's range (reachable only
// without the clamp) it is undefined; this saturates, as GPUs do.
struct uint
{
uint32_t u;
explicit uint(Float x) : u(!(x.f > 0.0f) ? 0u : x.f >= 4294967296.0f ? 0xFFFFFFFFu : uint32_t(x.f)) {}
operator uint32_t() const { return u; }
};
// GLSL's clamp is min(max(x, lo), hi), with a NaN undefined:
// fmax/fmin return the number, as GPUs' max/min do.
static Float clamp(Float x, double lo, double hi) { return { std::fmin(std::fmax(x.f, float(lo)), float(hi)) }; }
static uint32_t min(uint32_t a, uint32_t b) { return std::min(a, b); }
friend Product operator*(Float x, double y) { return { x.f, float(y) }; }
friend Float operator+(Product p, double y)
{
if constexpr (R == Depth24Rounding::Fused)
return { std::fmaf(p.a, p.b, float(y)) };
else
{
volatile float product = p.a * p.b; // (volatile: never contracted into an fma)
return { product + float(y) };
}
}
static uint32_t Pack(float value)
{
const Float v{ value };
return SB_DEPTH24_PACK_EXPR(v);
}
};
// GLSL_DEPTH24_PACK's packDepth24 in C++, from the shader's own tokens:
// the 24-bit depth for `v` (the resolve stores it shifted left by 8).
// Negatives, -0 and NaN give 0, 1.0 and above 0xFFFFFF.
inline uint32_t PackDepth24(float v, Depth24Rounding rounding = Depth24Rounding::Separate)
{
return rounding == Depth24Rounding::Fused ? Depth24Glsl<Depth24Rounding::Fused>::Pack(v)
: Depth24Glsl<Depth24Rounding::Separate>::Pack(v);
}
// The depth a 24-bit value stands for, as the untile (convert 4) and the
// resolve's store into a cached texture compute it from a word's top 24
// bits: float(d) / 16777215.0. (Correctly rounded here; GLSL allows a
// division 2.5 ULP of error.) 0xFFFFFF is exactly 1.0.
inline float UnpackDepth24(uint32_t d24)
{
return float(d24 & 0xFFFFFFu) / 16777215.0f;
}
}
+35
View File
@@ -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";
}
+777
View File
@@ -0,0 +1,777 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Edge bars: front-end black masks drawn for a 16:9 frame, and what fills
// the screen past the 16:9 band around them. Vulkan-free:
// tests/edge_bars_test.cpp checks it alone; gpu/renderer.cpp hands
// PlanFill a front-end draw (its registers, what the other front-end rules
// did, a reader for its vertices) and draws what the plan says.
//
// The career intro's "ROCKPORT POLICE VID CAM" overlay ends with a mask of
// six front-end quads: four textured CRT-bezel quadrants, each reaching one
// edge of the 16:9 frame (about 3% past it, black there), and two solid
// black side bars (the game's 32x32 white texture times vertex colour
// FD000000) spanning both edges (~3% past) and one side (41% past). On a
// 16:9 screen they cover everything outside the CRT window. Under Vert+ the
// front-end matrix is narrowed in y, so they end ~3% past the 16:9 band and
// the world shows in the rows above and below (28/31 rows on a 1280x800
// Deck, 87/90 at 1024x768); under Hor+ the bars end at 1.406 s, short of
// the screen's sides past a 2.50:1 screen (2880x810: 424 px a side).
//
// The intro also cuts between two shots (the van crash, ~95 s into the
// standard script) through a few frames of black: one black quad, centred,
// 0.970 of the 16:9 frame's width and 0.939 of its height (on a 16:9 screen
// 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. 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 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
// 16:9 frame's edges, what a 16:9 screen shows.
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
namespace gpu::bars
{
enum class Side : uint8_t { None, Left, Right };
// A quad's box in the frame's clip space.
struct Box
{
float x0, x1, y0, y1;
};
// One ePoly vertex: clip x and y through the front-end matrix (c0..c7),
// and its colour word as read (ARGB, after the endian swap).
struct Vertex
{
float x, y;
uint32_t argb;
};
// The thresholds, in band units. Measured (six screen shapes): y edges
// -1.027/-1.024 and 1.035/1.039, inner x 0.651/0.655, outer 1.406/1.410,
// colour FD000000. The nearest draws they must not take: the fades and
// dims (x +-1.29, y +-1.25..1.33), the pause dim's side gradients (x
// 0.53..1.08 and 0.57..1.12, y +-1.91, alpha C8 to 00), the graffiti
// wipes and splats (y to 1.23..3.3, one edge only).
struct Rule
{
float pastMin = 1.01f, pastMax = 1.10f; // each y edge, past the band's
float innerMin = 0.50f, innerMax = 0.80f; // |x| of the edge toward the centre
float outerMin = 1.20f, outerMax = 2.00f; // |x| of the outer edge
uint32_t minAlpha = 0xF0; // vertex alpha, all four the same
float cornerTolerance = 1e-3f; // clip units: a vertex on its box's corner
};
// How far past the frame the stretched copy reaches, in clip units (the
// draw's scissor trims it back to the frame). Not 1: with D3D's
// half-pixel centres at 2x or 3x, clip +-1 lands a whole host pixel in
// (2x: row and column 0 have their centres at 0.5, the edge at 1.0), and
// that row would show the world.
constexpr float kFillEdge = 1.02f;
// Black, at least minAlpha.
inline bool Black(uint32_t argb, uint32_t minAlpha)
{
return (argb & 0xFFFFFFu) == 0 && (argb >> 24) >= minAlpha;
}
// A side bar's colour: black, at least minAlpha. The renderer reads one
// vertex's colour first and asks this before it reads the other three
// (a black frame's is the same: black, at least F0).
inline bool BarColour(uint32_t argb, const Rule& rule = {})
{
return Black(argb, rule.minAlpha);
}
// The box of a quad that is one axis-aligned rectangle: every vertex on
// a corner (within `tolerance`), all four corners used, each edge along
// x or y, so the two triangles (0,1,2) and (0,2,3) cover the box. False
// for anything else (a diagonal, a repeated vertex, a bow-tie).
inline bool RectangleBox(const Vertex v[4], float tolerance, Box& b)
{
b = { v[0].x, v[0].x, v[0].y, v[0].y };
for (int i = 1; i < 4; i++)
{
b.x0 = std::min(b.x0, v[i].x);
b.x1 = std::max(b.x1, v[i].x);
b.y0 = std::min(b.y0, v[i].y);
b.y1 = std::max(b.y1, v[i].y);
}
// (Not "near": <windows.h> defines near and far as empty macros.)
const float t = tolerance;
auto within = [t](float a, float c) { return std::fabs(a - c) <= t; };
uint32_t corners = 0;
for (int i = 0; i < 4; i++)
{
const bool lx = within(v[i].x, b.x0), hx = within(v[i].x, b.x1), ly = within(v[i].y, b.y0), hy = within(v[i].y, b.y1);
if ((lx == hx) || (ly == hy)) // off the corners (or a box too thin to tell)
return false;
corners |= 1u << ((hx ? 1 : 0) | (hy ? 2 : 0));
const Vertex& n = v[(i + 1) & 3];
if (within(n.x, v[i].x) == within(n.y, v[i].y)) // a diagonal, or a repeated vertex
return false;
}
return corners == 0xF;
}
// A side bar: one axis-aligned rectangle (its vertices in order around
// it), one colour on all four vertices, black, at least minAlpha; in y
// a little past both band edges; in x from inside the band to well past
// one side. Its box goes to `box`.
inline Side MatchSideBar(const Vertex v[4], float scaleX, float scaleY, const Rule& rule = {}, Box* box = nullptr)
{
if (!(scaleX > 0.0f && scaleY > 0.0f))
return Side::None;
for (int i = 1; i < 4; i++)
if (v[i].argb != v[0].argb)
return Side::None;
if (!BarColour(v[0].argb, rule))
return Side::None;
Box b;
if (!RectangleBox(v, rule.cornerTolerance, b))
return Side::None;
const float lo = b.x0 / scaleX, hi = b.x1 / scaleX, loY = b.y0 / scaleY, hiY = b.y1 / scaleY;
auto in = [](float a, float lo_, float hi_) { return a >= lo_ && a <= hi_; };
if (!in(-loY, rule.pastMin, rule.pastMax) || !in(hiY, rule.pastMin, rule.pastMax))
return Side::None;
Side side = Side::None;
if (in(lo, rule.innerMin, rule.innerMax) && in(hi, rule.outerMin, rule.outerMax))
side = Side::Right;
else if (in(-hi, rule.innerMin, rule.innerMax) && in(-lo, rule.outerMin, rule.outerMax))
side = Side::Left;
if (side != Side::None && box)
*box = b;
return side;
}
// 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); 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
float extentMin = 0.90f, extentMax = 0.99f; // half its width and half its height
uint32_t minAlpha = 0xF0; // vertex alpha, all four the same
float cornerTolerance = 1e-3f; // clip units, as Rule's
};
// A black frame: one axis-aligned rectangle, one colour on all four
// vertices, black, at least minAlpha, centred in the band and covering
// most of it in x and y while stopping short of its edges. Its box goes
// to `box`.
inline bool MatchBlackout(const Vertex v[4], float scaleX, float scaleY, const BlackoutRule& 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;
if (!Black(v[0].argb, 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.extentMin && halfX <= rule.extentMax && halfY >= rule.extentMin && halfY <= rule.extentMax))
return false;
if (box)
*box = b;
return true;
}
// Host pixels, [x0, x1) x [y0, y1).
struct Rect
{
int32_t x0, y0, x1, y1;
};
inline bool Empty(const Rect& r)
{
return r.x1 <= r.x0 || r.y1 <= r.y0;
}
inline Rect Intersect(const Rect& a, const Rect& b)
{
return { std::max(a.x0, b.x0), std::max(a.y0, b.y0), std::min(a.x1, b.x1), std::min(a.y1, b.y1) };
}
// The first host pixel whose centre lies past NDC `ndc` over `size`
// pixels, the edge snapped to 1/256 pixel as the rasteriser snaps
// vertices. The band clip's rows (BandRows) and the fills' edges both
// come from it, so they meet exactly.
inline int32_t Edge(float ndc, float size)
{
const float a = std::round((ndc + 1.0f) * 0.5f * size * 256.0f) / 256.0f;
return int32_t(std::ceil(a - 0.5f));
}
// Host rows [top, bottom).
struct Rows
{
int32_t top, bottom;
};
// The host rows whose centres lie between clip y `lo` and `hi` through a
// draw's posScale y (NDC = y * ps1 + ps3, either sign of ps1): gpu/
// renderer.cpp clips the rest of the front end to the band's rows with
// this, and the fills cover the rows it leaves.
inline Rows BandRows(float lo, float hi, float ps1, float ps3, float hostH)
{
const int32_t a = Edge(lo * ps1 + ps3, hostH), b = Edge(hi * ps1 + ps3, hostH);
return { std::min(a, b), std::max(a, b) };
}
// The host pixels of clip rectangle [cx0, cx1] x [cy0, cy1] through a
// draw's posScale (NDC = clip * ps.xy + ps.zw, w = 1), either sign of
// ps0/ps1 (the front end's viewport y scale is negative).
inline Rect ClipToHost(float cx0, float cx1, float cy0, float cy1, const float ps[4], float hostW, float hostH)
{
const int32_t a = Edge(cx0 * ps[0] + ps[2], hostW), b = Edge(cx1 * ps[0] + ps[2], hostW);
const int32_t c = Edge(cy0 * ps[1] + ps[3], hostH), d = Edge(cy1 * ps[1] + ps[3], hostH);
return { std::min(a, b), std::min(c, d), std::max(a, b), std::max(c, d) };
}
// What a matched bar fills, inside the draw's scissor (at most two
// rectangles): under Vert+ (scaleY < 1) its half of the frame (centre to
// its side's edge) in the rows above and below the band (|y| > scaleY);
// under Hor+ (scaleX < 1), only if the bar ends short of its side of the
// screen (clip 1, the screen edge itself), the columns past the band on
// its side (|x| > scaleX). The far edges reach kFillEdge, which the
// scissor trims back to the frame. The band is centred: the caller
// checks the matrix has no translation.
inline uint32_t FillRects(Side side, const Box& box, const float ps[4], float hostW, float hostH, const Rect& scissor,
float scaleX, float scaleY, Rect out[2])
{
uint32_t n = 0;
auto add = [&](const Rect& r) {
const Rect c = Intersect(r, scissor);
if (!Empty(c))
out[n++] = c;
};
if (side == Side::None)
return 0;
const bool right = side == Side::Right;
if (scaleY < 1.0f)
{
const float e = kFillEdge, xa = right ? 0.0f : -e, xb = right ? e : 0.0f;
add(ClipToHost(xa, xb, scaleY, e, ps, hostW, hostH));
add(ClipToHost(xa, xb, -e, -scaleY, ps, hostW, hostH));
}
else if (scaleX < 1.0f && (right ? box.x1 < 1.0f : box.x0 > -1.0f))
{
const float e = kFillEdge;
add(right ? ClipToHost(scaleX, e, -e, e, ps, hostW, hostH) : ClipToHost(-e, -scaleX, -e, e, ps, hostW, hostH));
}
return n;
}
// What a black frame fills, inside the draw's scissor: everything
// 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 = [&](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;
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)
{
// 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 (scaleY < 1.0f)
{
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
// the signs of c5 and the viewport; a tile's window offset (in ps2/ps3,
// NFSMW_TILING=1) passes through, so the copy lands where this draw's
// own viewport puts clip +-edge.
inline void CoverFrame(const Box& b, const float ps[4], float out[4], float edge = kFillEdge)
{
const float ax = 2.0f * edge / (b.x1 - b.x0), bx = -edge - ax * b.x0;
const float ay = 2.0f * edge / (b.y1 - b.y0), by = -edge - ay * b.y0;
out[0] = ax * ps[0];
out[1] = ay * ps[1];
out[2] = ps[2] + bx * ps[0];
out[3] = ps[3] + by * ps[1];
}
// A front-end draw as gpu/renderer.cpp sees it once the widen, stretch,
// cover and band-clip rules are done: what PlanFill decides from.
struct Draw
{
uint32_t prim = 0; // VGT_DRAW_INITIATOR prim_type: 13 a quad list
uint32_t count = 0; // vertices
const float* c = nullptr; // shader constants c0..c7: the front-end matrix's x and y rows
float frontEndY = 0.0f; // game::FrontEndScaleY(), c5's magnitude; 0 from a ppc/ without the Vert+ hooks
float scaleX = 1.0f, scaleY = 1.0f; // the view scale
bool frontEndShader = false; // the vertex shader reads c0..c3 and the ePolys (fetch constant 95)
bool leftAlone = false; // not widened, stretched, covered or clipped to the band
bool occlusion = false; // inside an occlusion query's bracket
uint32_t vte = 0; // PA_CL_VTE_CNTL
uint32_t blend0 = 0; // RB_BLENDCONTROL0 & 0x1FFF1FFF
bool t0Sampled = false; // the pixel shader samples t0
uint32_t t0Width = 0, t0Height = 0; // t0's size in texels
uint32_t t0Format = 0; // t0's fetch constant format (6: k_8_8_8_8, 32-bit texels)
float posScale[4] = {}; // the draw's own: NDC = clip * ps.xy + ps.zw
float hostW = 0.0f, hostH = 0.0f; // the host viewport: the target's size times the render scale
Rect scissor{}; // the draw's scissor (host pixels)
};
enum class Fill : uint8_t { None, LeftBar, RightBar, Blackout, Backdrop, Vignette };
// What to draw after the draw: for a bar or a black frame, `rectCount`
// more copies of it (none for a bar that reaches the screen edge), with
// `posScale` and each rectangle as the scissor; for a backdrop, each
// rectangle cleared to opaque black (its picture must not be copied);
// 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[8]{}; // a bar's 2, a black frame's 4, a backdrop's 8
float posScale[4]{};
};
// The register checks, before any vertex is read: one quad of the
// front-end shader, the other rules left it alone, outside an occlusion
// query, no translation, c5 the front end's y scale (unless the ppc/
// predates the Vert+ hooks, which report none: then the view is never
// narrowed in y and only Hor+ can need a fill), drawn through its
// viewport transform (x and y scaled and offset: posScale is that
// transform), with alpha blending or none (the copy must look like the
// draw), over a small t0 (the game's white texture is 32x32).
inline bool Candidate(const Draw& d)
{
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 <= 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
// first, then vertex 0's colour (one read: a front-end frame has
// hundreds of quads and few are black), then the rest.
template <class ReadWord>
Plan PlanFill(const Draw& d, ReadWord&& read)
{
Plan p;
if (!Candidate(d))
return p;
uint32_t colour = 0;
if (!read(0u, 3u, colour) || !BarColour(colour))
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 (const Side side = MatchSideBar(v, d.scaleX, d.scaleY, {}, &box); side != Side::None)
{
p.fill = side == Side::Right ? Fill::RightBar : Fill::LeftBar;
p.rectCount = FillRects(side, box, d.posScale, d.hostW, d.hostH, d.scissor, d.scaleX, d.scaleY, p.rects);
}
else if (MatchBlackout(v, d.scaleX, d.scaleY, {}, &box))
{
p.fill = Fill::Blackout;
p.rectCount = BlackoutRects(box, d.posScale, d.hostW, d.hostH, d.scissor, p.rects);
}
else
return p;
p.box = box;
p.argb = v[0].argb;
CoverFrame(box, d.posScale, p.posScale);
return p;
}
}
+357
View File
@@ -0,0 +1,357 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Where the renderer (gpu/renderer.cpp) puts the two big allocations the CPU
// writes: guest physical memory as the GPU sees it (512 MB: imported without
// a copy, or a copy the write watch keeps current) and the upload ring
// (192 MB: draw constants, converted indices, texture staging). A plan is a
// list of places to try in turn; the renderer goes on to the next when the
// driver refuses one, so a refused allocation is never fatal. Vulkan-free
// (the flags are Vulkan's values): tests/memory_plan_test.cpp checks it alone
// against the memory of the devices we know.
//
// Chosen by capability, not by device type alone. "CPU-mapped VRAM" (a
// device-local, host-visible memory type) is all of VRAM with resizable BAR,
// but only a 256 MB window without it (NVIDIA before Ampere, and any GPU with
// resizable BAR off in the firmware). v0.1.0 put a discrete GPU's 512 MB copy
// there whatever its size, and the driver's refusal (out of device memory)
// aborted the game at startup (issue #2: a GTX 1080 Ti and an RTX 4070 on
// Linux). Now a place is planned first only when its heap holds what we put
// there with room to spare: at most half the heap (kHeapShareDivisor; the
// rest is for the driver, the game's textures and render targets, and other
// programs). Otherwise, on a discrete GPU: guest memory imported (the GPU
// reads system memory over PCIe without a copy: VK_EXT_external_memory_host,
// which NVIDIA's driver has), then a copy in system memory, uncached
// (write-combined) first; the ring in cached system memory, then uncached.
// Every list ends with the places that didn't fit, as a last try.
//
// Devices that fit keep exactly what v0.1.0 chose (the tests pin each one):
// the Steam Machine (discrete RDNA3, resizable BAR: copy and ring in VRAM),
// the Steam Deck (integrated: import, which RADV refuses for the guest's
// shared mapping, then a copy in uncached system memory; ring in VRAM), the
// Steam Frame and other unified-memory devices (a copy in their one
// host-visible device memory), and Apple's (imported).
#pragma once
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
namespace gpu::memplan
{
// VkMemoryPropertyFlagBits.
constexpr uint32_t kDeviceLocal = 0x1, kHostVisible = 0x2, kHostCoherent = 0x4, kHostCached = 0x8;
constexpr uint32_t kMapped = kHostVisible | kHostCoherent;
// Never planned: lazily allocated, protected, and AMD's device-coherent
// and device-uncached types (slower; they come after the plain ones).
constexpr uint32_t kSpecial = 0x10 | 0x20 | 0x40 | 0x80;
constexpr uint32_t kNoType = UINT32_MAX;
constexpr uint64_t kGuestBytes = 512ull << 20;
constexpr uint64_t kRingBytes = 192ull << 20;
// What we plan into a heap: at most this share of it (1/2).
constexpr uint64_t kHeapShareDivisor = 2;
// VkDriverId values the plan knows.
constexpr uint32_t kDriverIntelMesa = 6; // VK_DRIVER_ID_INTEL_OPEN_SOURCE_MESA (ANV, Linux)
enum class Gpu : uint8_t { kDiscrete, kIntegrated, kOther };
// kVram: device-local and host-visible ("CPU-mapped VRAM"; on a unified
// memory device, its one host-visible memory). kSystem: host-visible, not
// device-local, not cached (write-combined system memory). kCached:
// host-visible and cached; not device-local for guest memory, any for
// the ring (NFSMW_RING_MEMORY=cached chose so before).
enum class Place : uint8_t { kImport, kVram, kSystem, kCached };
struct Type
{
uint32_t flags = 0, heap = 0;
};
struct Memory
{
std::vector<Type> types;
std::vector<uint64_t> heaps; // bytes
};
struct Candidate
{
Place place;
uint32_t type; // kNoType for kImport (the import finds its own)
bool fits; // its heap holds it (kHeapShareDivisor); always true for kImport
};
struct Plan
{
std::vector<Candidate> order; // try in turn
std::string why; // the reason for the order, for the log
};
// The environment's overrides (unset or unknown: kAuto).
// NFSMW_SHARED_MEMORY=import|shadow: import first / never import.
// NFSMW_SHADOW_MEMORY=vram|gtt: the copy's first place.
// NFSMW_RING_MEMORY=vram|cached|system: the ring's first place.
// An override is tried first whatever its heap's size, and the default
// order follows it.
struct Overrides
{
enum class Shared : uint8_t { kAuto, kImport, kShadow } shared = Shared::kAuto;
Place copy = Place::kImport; // kImport: none (kVram or kSystem when set)
Place ring = Place::kImport; // kImport: none
};
inline bool Is(const char* v, const char* s)
{
return v && std::string(v) == s;
}
inline Overrides ParseOverrides(const char* shared, const char* shadow, const char* ring)
{
Overrides o;
o.shared = Is(shared, "import") ? Overrides::Shared::kImport : Is(shared, "shadow") ? Overrides::Shared::kShadow : Overrides::Shared::kAuto;
o.copy = Is(shadow, "vram") ? Place::kVram : Is(shadow, "gtt") ? Place::kSystem : Place::kImport;
o.ring = Is(ring, "vram") ? Place::kVram : Is(ring, "cached") ? Place::kCached : Is(ring, "system") ? Place::kSystem : Place::kImport;
return o;
}
// Host-memory import that the driver offers but that can't work here:
// nullptr when it can, else why not (for the log). Intel's Linux driver
// (ANV) imports host memory as an i915 (or xe) userptr object. Each
// protection change on those pages (the write watch's mprotect, many a
// frame) invalidates it, and the next submission pins all its pages
// again for writing; any page the write watch holds read-only or
// inaccessible then fails that (EFAULT), the kernel refuses the
// submission, and ANV reports the device lost: issue #2's Arrow Lake
// laptop, VK_ERROR_DEVICE_LOST in vkQueueSubmit 0.8 s after the import,
// with ANV's dump of the refused batch (the 512 MB "host-ptr" object at
// the guest's 0xA0000000 window) just before. NVIDIA's driver pins the
// pages once at import and MoltenVK wraps them in a Metal buffer, so
// protection changes don't touch the GPU's mapping there.
inline const char* ImportRefusal(uint32_t driverId)
{
if (driverId == kDriverIntelMesa)
return "Intel's Linux driver can't keep imported memory mapped while the write watch protects its pages (issue #2)";
return nullptr;
}
// NFSMW_TEST_BAR_HEAP_MB (a test hook): every heap holding a CPU-mapped
// VRAM type reads as `bytes`, as on a GPU without resizable BAR.
inline void ShrinkMappedVram(Memory& m, uint64_t bytes)
{
for (const Type& t : m.types)
if ((t.flags & (kDeviceLocal | kHostVisible)) == (kDeviceLocal | kHostVisible) && t.heap < m.heaps.size())
m.heaps[t.heap] = bytes;
}
// The first memory type among `bits` for `place` (kNoType: none).
inline uint32_t FindType(const Memory& m, uint32_t bits, Place place, bool ring)
{
auto find = [&](auto&& ok) {
for (uint32_t i = 0; i < m.types.size() && i < 32; i++)
if ((bits & (1u << i)) && !(m.types[i].flags & kSpecial) && (m.types[i].flags & kMapped) == kMapped && ok(m.types[i].flags))
return i;
return kNoType;
};
switch (place)
{
case Place::kVram:
return find([](uint32_t f) { return (f & kDeviceLocal) != 0; });
case Place::kSystem:
return find([](uint32_t f) { return !(f & (kDeviceLocal | kHostCached)); });
case Place::kCached:
{
uint32_t t = find([](uint32_t f) { return (f & kHostCached) && !(f & kDeviceLocal); });
return t != kNoType || !ring ? t : find([](uint32_t f) { return (f & kHostCached) != 0; });
}
case Place::kImport:
break;
}
return kNoType;
}
inline uint64_t HeapOf(const Memory& m, uint32_t type)
{
return type < m.types.size() && m.types[type].heap < m.heaps.size() ? m.heaps[m.types[type].heap] : 0;
}
// `bytes` more in `type`'s heap, beside `also` already there, within kHeapShareDivisor.
inline bool Fits(const Memory& m, uint32_t type, uint64_t bytes, uint64_t also = 0)
{
return type != kNoType && also + bytes <= HeapOf(m, type) / kHeapShareDivisor;
}
inline std::string Size(uint64_t bytes)
{
char s[32];
if (bytes >= (10ull << 30))
snprintf(s, sizeof(s), "%.0f GB", double(bytes) / double(1ull << 30));
else if (bytes >= (1ull << 30))
snprintf(s, sizeof(s), "%.2f GB", double(bytes) / double(1ull << 30));
else
snprintf(s, sizeof(s), "%llu MB", (unsigned long long)(bytes >> 20));
return s;
}
// For NFSMW_TEST_ALLOC_FAIL and the log.
inline const char* Name(Place place, bool ring)
{
switch (place)
{
case Place::kImport: return "import";
case Place::kVram: return ring ? "ring-vram" : "copy-vram";
case Place::kSystem: return ring ? "ring-system" : "copy-system";
case Place::kCached: return ring ? "ring-cached" : "copy-cached";
}
return "?";
}
namespace detail
{
// `places` in order, each once, those with a type (or kImport).
inline void Add(Plan& p, const Memory& m, uint32_t bits, const std::vector<Place>& places, bool ring, uint64_t bytes, uint64_t also)
{
for (Place place : places)
{
bool seen = false;
for (const Candidate& c : p.order)
seen |= c.place == place;
if (seen)
continue;
if (place == Place::kImport)
{
p.order.push_back({ place, kNoType, true });
continue;
}
uint32_t type = FindType(m, bits, place, ring);
if (type != kNoType)
p.order.push_back({ place, type, Fits(m, type, bytes, place == Place::kVram ? also : 0) });
}
}
inline std::string HeapText(const Memory& m, uint32_t type)
{
return "heap " + std::to_string(m.types[type].heap) + ", " + Size(HeapOf(m, type));
}
}
struct GuestInput
{
Gpu gpu = Gpu::kOther;
bool importable = false; // VK_EXT_external_memory_host
const char* importRefusal = nullptr; // ImportRefusal()
uint32_t bits = ~0u; // the copy buffer's memoryTypeBits
Overrides overrides;
};
// Guest physical memory.
inline Plan PlanGuestMemory(const Memory& m, const GuestInput& in)
{
Plan p;
const Overrides& o = in.overrides;
uint32_t vram = FindType(m, in.bits, Place::kVram, false);
bool vramFits = Fits(m, vram, kGuestBytes);
bool import = in.importable && o.shared != Overrides::Shared::kShadow &&
(!in.importRefusal || o.shared == Overrides::Shared::kImport);
// The copy's places, best first.
std::vector<Place> copy;
switch (in.gpu)
{
case Gpu::kDiscrete:
copy = vramFits ? std::vector<Place>{ Place::kVram, Place::kSystem, Place::kCached }
: std::vector<Place>{ Place::kSystem, Place::kCached, Place::kVram };
break;
case Gpu::kIntegrated:
// Its "VRAM" is a carve-out of the same RAM, 1 GB on a Deck, and
// the copy there with the textures and targets didn't fit: at
// every world load the kernel moved ~500 MB of it out to GTT,
// and the GPU reads GTT as fast. So system memory (write-combined
// GTT on amdgpu, uncached first), and the device's host-visible
// memory where that is all there is (unified memory: the Steam
// Frame, Intel).
copy = { Place::kSystem, Place::kCached, Place::kVram };
break;
case Gpu::kOther:
copy = { Place::kVram, Place::kSystem, Place::kCached };
break;
}
if (o.copy != Place::kImport)
copy.insert(copy.begin(), o.copy);
// Import: first, except on a discrete GPU whose own memory takes the
// copy (VRAM it holds, or the place NFSMW_SHADOW_MEMORY names): the
// GPU reads its VRAM faster than system memory over PCIe.
std::vector<Place> places;
bool copyFirst = in.gpu == Gpu::kDiscrete && o.shared != Overrides::Shared::kImport && (vramFits || o.copy != Place::kImport);
if (import && !copyFirst)
places.push_back(Place::kImport);
places.push_back(copy.front());
if (import && copyFirst)
places.push_back(Place::kImport);
places.insert(places.end(), copy.begin() + 1, copy.end());
detail::Add(p, m, in.bits, places, false, kGuestBytes, 0);
// Why.
std::string why;
switch (in.gpu)
{
case Gpu::kDiscrete:
if (vram == kNoType)
why = "a discrete GPU without CPU-mapped VRAM";
else if (vramFits)
why = "a discrete GPU whose CPU-mapped VRAM (" + detail::HeapText(m, vram) + ") holds the 512 MB copy: resizable BAR";
else
why = "a discrete GPU whose CPU-mapped VRAM (" + detail::HeapText(m, vram) +
") is too small for the 512 MB copy: no resizable BAR";
break;
case Gpu::kIntegrated:
why = "an integrated GPU (system memory)";
break;
case Gpu::kOther:
why = "a GPU neither discrete nor integrated";
break;
}
if (o.shared == Overrides::Shared::kImport)
why += in.importable ? "; NFSMW_SHARED_MEMORY=import" : "; NFSMW_SHARED_MEMORY=import, but the driver can't import host memory";
else if (o.shared == Overrides::Shared::kShadow)
why += "; NFSMW_SHARED_MEMORY=shadow";
else if (!in.importable && (in.gpu != Gpu::kDiscrete || !vramFits))
why += "; the driver can't import host memory";
else if (in.importRefusal)
why += std::string("; not imported: ") + in.importRefusal;
if (o.copy == Place::kVram)
why += "; NFSMW_SHADOW_MEMORY=vram";
else if (o.copy == Place::kSystem)
why += "; NFSMW_SHADOW_MEMORY=gtt";
p.why = why;
return p;
}
// The upload ring. `guestType`: the memory type guest memory's copy went
// to (kNoType: imported), whose 512 MB count against the ring's heap.
inline Plan PlanRing(const Memory& m, uint32_t bits, uint32_t guestType, Place override = Place::kImport)
{
Plan p;
uint32_t vram = FindType(m, bits, Place::kVram, true);
uint64_t also = guestType != kNoType && vram != kNoType && guestType < m.types.size() &&
m.types[guestType].heap == m.types[vram].heap ? kGuestBytes : 0;
bool vramFits = Fits(m, vram, kRingBytes, also);
std::vector<Place> places = vramFits ? std::vector<Place>{ Place::kVram, Place::kCached, Place::kSystem }
: std::vector<Place>{ Place::kCached, Place::kSystem, Place::kVram };
if (override != Place::kImport)
places.insert(places.begin(), override);
detail::Add(p, m, bits, places, true, kRingBytes, also);
if (vram == kNoType)
p.why = "no CPU-mapped VRAM";
else if (vramFits)
p.why = "CPU-mapped VRAM (" + detail::HeapText(m, vram) + ") holds it";
else
p.why = "CPU-mapped VRAM (" + detail::HeapText(m, vram) + ") is too small for the 192 MB ring" +
(also ? " beside the copy" : "");
if (override == Place::kVram)
p.why += "; NFSMW_RING_MEMORY=vram";
else if (override == Place::kCached)
p.why += "; NFSMW_RING_MEMORY=cached";
else if (override == Place::kSystem)
p.why += "; NFSMW_RING_MEMORY=system";
return p;
}
}
+163
View File
@@ -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 {};
}
}
}
+240
View File
@@ -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;
}
}
+1323 -292
View File
File diff suppressed because it is too large. Load diff
+102 -26
View File
@@ -1,13 +1,30 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See input.h.
#include <stdafx.h>
#include "input.h"
#include "key_map.h"
#include <cpu/guest_time.h>
#include <kernel/function.h>
#include <ui/ui.h>
#include <user/settings.h>
#include <user/key_names.h>
#include <SDL3/SDL.h>
// key_map.h names XInput's bits as literals (it has no kernel header): keep
// them the bits the game reads.
static_assert(hid::keymap::button::DpadUp == XAMINPUT_GAMEPAD_DPAD_UP && hid::keymap::button::DpadDown == XAMINPUT_GAMEPAD_DPAD_DOWN &&
hid::keymap::button::DpadLeft == XAMINPUT_GAMEPAD_DPAD_LEFT && hid::keymap::button::DpadRight == XAMINPUT_GAMEPAD_DPAD_RIGHT &&
hid::keymap::button::Start == XAMINPUT_GAMEPAD_START && hid::keymap::button::Back == XAMINPUT_GAMEPAD_BACK &&
hid::keymap::button::LeftThumb == XAMINPUT_GAMEPAD_LEFT_THUMB && hid::keymap::button::RightThumb == XAMINPUT_GAMEPAD_RIGHT_THUMB &&
hid::keymap::button::LeftShoulder == XAMINPUT_GAMEPAD_LEFT_SHOULDER && hid::keymap::button::RightShoulder == XAMINPUT_GAMEPAD_RIGHT_SHOULDER &&
hid::keymap::button::A == XAMINPUT_GAMEPAD_A && hid::keymap::button::B == XAMINPUT_GAMEPAD_B &&
hid::keymap::button::X == XAMINPUT_GAMEPAD_X && hid::keymap::button::Y == XAMINPUT_GAMEPAD_Y,
"key_map.h's button bits are XInput's");
// The key codes of user/key_names.h are SDL's scancodes (USB HID usages);
// tests/key_names_test.cpp checks every row, this pins a few.
static_assert(settings::keynames::Code("a") == SDL_SCANCODE_A && settings::keynames::Code("keypad_1") == SDL_SCANCODE_KP_1 &&
settings::keynames::Code("right_alt") == SDL_SCANCODE_RALT && settings::keynames::Code("escape") == SDL_SCANCODE_ESCAPE);
namespace
{
constexpr uint32_t ERROR_SUCCESS = 0;
@@ -231,12 +248,20 @@ namespace
// NFSMW_VIRTUAL_PAD="2:LX=0.05,4:LX=0.2,6:RT=0.01": an SDL virtual
// controller, the only one opened, whose axes move to those values at
// seconds since launch (sticks LX/LY/RX/RY -1..1, up positive; triggers
// LT/RT 0..1) and hold them. NFSMW_INPUT_SCRIPT feeds the game after the
// LT/RT 0..1) and hold them. Its buttons too: "5:BACK=1" holds one down,
// "5.2:BACK=0" lets it go, "6:DOWN" taps it (0.15 s); A B X Y BACK START
// LB RB LS RS and the D-pad's UP DOWN LEFT RIGHT. Being a real SDL
// controller, it drives the menus (Back + Start, the D-pad, A, B) as
// well as the game. A button changes once a frame at most, so a tap due
// whole within one long frame (a capture's stall) still shows a frame
// pressed. "12:SHOT" saves the next presented frame (ui::RequestShot), on
// the pad's clock. NFSMW_INPUT_SCRIPT feeds the game after the
// device path; this goes through it, so the deadzones and Vibration
// reach it as they would a real controller, for tests without one. Each
// step logs what the game then gets; the rumble the game asks for and
// the rumble the pad receives are logged.
struct PadStep { double time; SDL_GamepadAxis axis; float value; std::string text; };
struct PadStep { double time; SDL_GamepadAxis axis; float value; std::string text; int button = -1; bool down = false; };
constexpr int kShotStep = -2; // PadStep::button: a capture, not a button
std::vector<PadStep> s_padSteps;
size_t s_padNext = 0;
SDL_JoystickID s_virtualPadId = 0;
@@ -267,10 +292,47 @@ namespace
std::string item(rest.substr(0, comma));
rest = comma == std::string_view::npos ? std::string_view() : rest.substr(comma + 1);
size_t colon = item.find(':'), equals = item.find('=');
if (colon == std::string::npos || equals == std::string::npos || equals < colon)
if (colon == std::string::npos || (equals != std::string::npos && equals < colon))
continue;
std::string name = item.substr(colon + 1, equals - colon - 1);
std::string name = item.substr(colon + 1, equals == std::string::npos ? std::string::npos : equals - colon - 1);
static const std::pair<const char*, SDL_GamepadButton> buttons[] = {
{ "A", SDL_GAMEPAD_BUTTON_SOUTH }, { "B", SDL_GAMEPAD_BUTTON_EAST }, { "X", SDL_GAMEPAD_BUTTON_WEST },
{ "Y", SDL_GAMEPAD_BUTTON_NORTH }, { "BACK", SDL_GAMEPAD_BUTTON_BACK }, { "START", SDL_GAMEPAD_BUTTON_START },
{ "LB", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER }, { "RB", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER },
{ "LS", SDL_GAMEPAD_BUTTON_LEFT_STICK }, { "RS", SDL_GAMEPAD_BUTTON_RIGHT_STICK },
{ "UP", SDL_GAMEPAD_BUTTON_DPAD_UP }, { "DOWN", SDL_GAMEPAD_BUTTON_DPAD_DOWN },
{ "LEFT", SDL_GAMEPAD_BUTTON_DPAD_LEFT }, { "RIGHT", SDL_GAMEPAD_BUTTON_DPAD_RIGHT },
};
if (name == "SHOT")
{
s_padSteps.push_back({ std::atof(item.c_str()), SDL_GAMEPAD_AXIS_INVALID, 0.0f, name, kShotStep, false });
continue;
}
bool found = false;
for (auto& [n, button] : buttons)
if (name == n)
{
const double at = std::atof(item.c_str());
if (equals == std::string::npos)
{
// A tap: down, and up again 0.15 s later.
s_padSteps.push_back({ at, SDL_GAMEPAD_AXIS_INVALID, 0.0f, name + " down", int(button), true });
s_padSteps.push_back({ at + 0.15, SDL_GAMEPAD_AXIS_INVALID, 0.0f, name + " up", int(button), false });
}
else
{
const bool down = std::atof(item.c_str() + equals + 1) != 0.0;
s_padSteps.push_back({ at, SDL_GAMEPAD_AXIS_INVALID, 0.0f, name + (down ? " down" : " up"), int(button), down });
}
found = true;
}
if (found)
continue;
if (equals == std::string::npos)
{
fprintf(stderr, "[input] NFSMW_VIRTUAL_PAD: unknown button in \"%s\"\n", item.c_str());
continue;
}
for (auto& [n, axis] : axes)
if (name == n)
{
@@ -305,9 +367,25 @@ namespace
return {};
double t = SecondsSinceLaunch();
std::string taken;
uint32_t changed = 0; // buttons changed this frame
for (; s_padNext < s_padSteps.size() && s_padSteps[s_padNext].time <= t; s_padNext++)
{
const PadStep& step = s_padSteps[s_padNext];
if (step.button == kShotStep)
{
ui::RequestShot();
taken += (taken.empty() ? "" : " ") + step.text;
continue;
}
if (step.button >= 0)
{
if (changed & (1u << step.button))
break; // its next change waits a frame (the steps stay in order)
changed |= 1u << step.button;
SDL_SetJoystickVirtualButton(s_virtualPad, step.button, step.down);
taken += (taken.empty() ? "" : " ") + step.text;
continue;
}
float value = step.value;
bool trigger = step.axis == SDL_GAMEPAD_AXIS_LEFT_TRIGGER || step.axis == SDL_GAMEPAD_AXIS_RIGHT_TRIGGER;
if (step.axis == SDL_GAMEPAD_AXIS_LEFTY || step.axis == SDL_GAMEPAD_AXIS_RIGHTY)
@@ -547,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 -4
View File
@@ -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
@@ -19,7 +22,8 @@
// - NFSMW_VIRTUAL_PAD="2:LX=0.05,4:RT=0.06": an SDL virtual controller whose
// axes take those values at those seconds, through the deadzones, and
// whose rumble is logged with what the game asked for (tests of the
// Input settings without a person).
// Input settings without a person). Buttons too ("5:BACK=1", "5.2:BACK=0",
// "6:DOWN" for a tap), which reach the menus as a real controller's do.
// Both count seconds of guest time (cpu/guest_time.h): a suspension
// doesn't count, so what falls due during one happens after it.
// - NFSMW_INPUT_LOG=1: the left stick and triggers once a second, raw and
+142
View File
@@ -0,0 +1,142 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The keyboard as an Xbox 360 controller (Settings > Controls): the 24
// controls a key can drive, in the page's order, each with the two settings
// that hold its keys (user/settings.h, the [keys] table of settings.toml),
// and how a set of held keys becomes buttons, triggers and stick axes.
// Header only and SDL-free: hid/input.cpp samples the keys and feeds the
// result to the pad; ui/settings_menu.cpp draws one row per entry;
// tests/key_map_test.cpp checks the rules.
#pragma once
#include <cstddef>
#include <cstdint>
#include <span>
#include <user/settings.h>
namespace hid::keymap
{
enum class Control : uint8_t
{
RightTrigger, LeftTrigger,
LeftStickLeft, LeftStickRight, LeftStickUp, LeftStickDown,
A, B, X, Y, LB, RB,
DpadUp, DpadDown, DpadLeft, DpadRight,
Start, Back,
RightStickUp, RightStickDown, RightStickLeft, RightStickRight,
LeftStickClick, RightStickClick,
Count
};
constexpr size_t kControls = size_t(Control::Count);
enum class Role : uint8_t { Button, LeftTrigger, RightTrigger, AxisNeg, AxisPos };
enum class Axis : uint8_t { LX, LY, RX, RY, Count };
// XInput's button bits, as the game reads them (xbox.h's
// XAMINPUT_GAMEPAD_*; input.cpp asserts they agree). Literals here so
// this header needs no kernel header.
namespace button
{
constexpr uint16_t DpadUp = 0x0001, DpadDown = 0x0002, DpadLeft = 0x0004, DpadRight = 0x0008;
constexpr uint16_t Start = 0x0010, Back = 0x0020, LeftThumb = 0x0040, RightThumb = 0x0080;
constexpr uint16_t LeftShoulder = 0x0100, RightShoulder = 0x0200;
constexpr uint16_t A = 0x1000, B = 0x2000, X = 0x4000, Y = 0x8000;
}
struct Entry
{
Control control;
settings::Id id; // the option holding its first key
settings::Id id2; // ...and its second (always id + 1)
Role role;
uint16_t button = 0; // Role::Button
Axis axis = Axis::LX; // Role::AxisNeg, AxisPos
};
constexpr Entry kEntries[kControls] = {
{ Control::RightTrigger, settings::Id::KeyRightTrigger, settings::Id::KeyRightTrigger2, Role::RightTrigger },
{ Control::LeftTrigger, settings::Id::KeyLeftTrigger, settings::Id::KeyLeftTrigger2, Role::LeftTrigger },
{ Control::LeftStickLeft, settings::Id::KeyLeftStickLeft, settings::Id::KeyLeftStickLeft2, Role::AxisNeg, 0, Axis::LX },
{ Control::LeftStickRight, settings::Id::KeyLeftStickRight, settings::Id::KeyLeftStickRight2, Role::AxisPos, 0, Axis::LX },
{ Control::LeftStickUp, settings::Id::KeyLeftStickUp, settings::Id::KeyLeftStickUp2, Role::AxisPos, 0, Axis::LY },
{ Control::LeftStickDown, settings::Id::KeyLeftStickDown, settings::Id::KeyLeftStickDown2, Role::AxisNeg, 0, Axis::LY },
{ Control::A, settings::Id::KeyA, settings::Id::KeyA2, Role::Button, button::A },
{ Control::B, settings::Id::KeyB, settings::Id::KeyB2, Role::Button, button::B },
{ Control::X, settings::Id::KeyX, settings::Id::KeyX2, Role::Button, button::X },
{ Control::Y, settings::Id::KeyY, settings::Id::KeyY2, Role::Button, button::Y },
{ Control::LB, settings::Id::KeyLB, settings::Id::KeyLB2, Role::Button, button::LeftShoulder },
{ Control::RB, settings::Id::KeyRB, settings::Id::KeyRB2, Role::Button, button::RightShoulder },
{ Control::DpadUp, settings::Id::KeyDpadUp, settings::Id::KeyDpadUp2, Role::Button, button::DpadUp },
{ Control::DpadDown, settings::Id::KeyDpadDown, settings::Id::KeyDpadDown2, Role::Button, button::DpadDown },
{ Control::DpadLeft, settings::Id::KeyDpadLeft, settings::Id::KeyDpadLeft2, Role::Button, button::DpadLeft },
{ Control::DpadRight, settings::Id::KeyDpadRight, settings::Id::KeyDpadRight2, Role::Button, button::DpadRight },
{ Control::Start, settings::Id::KeyStart, settings::Id::KeyStart2, Role::Button, button::Start },
{ Control::Back, settings::Id::KeyBack, settings::Id::KeyBack2, Role::Button, button::Back },
{ Control::RightStickUp, settings::Id::KeyRightStickUp, settings::Id::KeyRightStickUp2, Role::AxisPos, 0, Axis::RY },
{ Control::RightStickDown, settings::Id::KeyRightStickDown, settings::Id::KeyRightStickDown2, Role::AxisNeg, 0, Axis::RY },
{ Control::RightStickLeft, settings::Id::KeyRightStickLeft, settings::Id::KeyRightStickLeft2, Role::AxisNeg, 0, Axis::RX },
{ Control::RightStickRight, settings::Id::KeyRightStickRight, settings::Id::KeyRightStickRight2, Role::AxisPos, 0, Axis::RX },
{ Control::LeftStickClick, settings::Id::KeyLeftStickClick, settings::Id::KeyLeftStickClick2, Role::Button, button::LeftThumb },
{ Control::RightStickClick, settings::Id::KeyRightStickClick, settings::Id::KeyRightStickClick2, Role::Button, button::RightThumb },
};
consteval bool EntriesInOrder()
{
for (size_t i = 0; i < kControls; i++)
if (size_t(kEntries[i].control) != i || size_t(kEntries[i].id) != size_t(settings::Id::KeyRightTrigger) + 2 * i ||
size_t(kEntries[i].id2) != size_t(kEntries[i].id) + 1)
return false;
return true;
}
static_assert(EntriesInOrder(), "one Entry per Control, in Control order, over the Key options in pairs");
// The control either of a Key option's keys belongs to.
constexpr const Entry& EntryOf(settings::Id id)
{
return kEntries[(size_t(id) - size_t(settings::Id::KeyRightTrigger)) / 2];
}
// What the held keys amount to.
struct Resolved
{
uint16_t buttons = 0;
bool leftTrigger = false, rightTrigger = false; // a trigger key is down: that trigger at full
bool axisSet[size_t(Axis::Count)] = {}; // either key of the axis is down: the keyboard has the axis
float axis[size_t(Axis::Count)] = {}; // +1, 0 (both keys) or -1 while set; up is positive, as XInput has it
};
// pressed[i]: either key bound to Control(i) is down. Buttons OR in; an axis
// is its positive key minus its negative one, and belongs to the
// keyboard while either is held (both held: centred).
constexpr Resolved Resolve(std::span<const bool, kControls> pressed)
{
Resolved r;
for (size_t i = 0; i < kControls; i++)
{
if (!pressed[i])
continue;
const Entry& e = kEntries[i];
switch (e.role)
{
case Role::Button:
r.buttons |= e.button;
break;
case Role::LeftTrigger:
r.leftTrigger = true;
break;
case Role::RightTrigger:
r.rightTrigger = true;
break;
case Role::AxisNeg:
r.axisSet[size_t(e.axis)] = true;
r.axis[size_t(e.axis)] -= 1.0f;
break;
case Role::AxisPos:
r.axisSet[size_t(e.axis)] = true;
r.axis[size_t(e.axis)] += 1.0f;
break;
}
}
return r;
}
}
+108 -9
View File
@@ -2,6 +2,14 @@
#include "install_cli.h"
#include "installer.h"
#include "locate.h"
#include "placement.h"
#include "portable_switch.h"
#include <user/paths.h>
#ifdef __APPLE__
#include <TargetConditionals.h>
#endif
#include <chrono>
#include <csignal>
@@ -115,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;
}
@@ -129,23 +138,46 @@ namespace install
return {};
}
int RunInstall(const char* sourcePath, const fs::path& destination)
// `named`: the player gave the folder (--dest). Without --dest it's
// the default folder, which gets what the installer screen's
// "Internal storage" entry gets: only Install's own checks.
int RunInstall(const char* sourcePath, const fs::path& destination, bool named)
{
// --dest: into exactly that folder (no subfolder, unlike the
// installer screen's "Choose a folder..."), with the screen's
// refusals for a folder the player picks: not SpeedBreaker's own
// folder, inside an install, the home folder, iCloud Drive, a
// folder kept in memory... (install/placement.h). Then the folder
// as the checks saw it (absolute, links resolved, no trailing
// separator) is installed into and recorded as such.
fs::path folder = destination;
std::vector<std::string> notes;
if (named)
{
FolderChoice where = CheckExactFolder(destination, true);
if (!where.result.Ok())
return Failed(where.result);
folder = where.gameDir;
notes = std::move(where.notes);
}
std::unique_ptr<DiscSource> source;
if (Result r = OpenAndCheck(sourcePath, source); !r.Ok())
return Failed(r);
printf("Installing %s to %s\n", FormatSize(GameManifest().totalBytes).c_str(), destination.c_str());
for (const std::string& note : notes)
printf("Note: %s\n", note.c_str());
printf("Installing %s to %s\n", FormatSize(GameManifest().totalBytes).c_str(), folder.string().c_str());
InterruptGuard guard;
ProgressPrinter printer;
Result r = Install(*source, destination, std::ref(printer), &s_cancel);
Result r = Install(*source, folder, std::ref(printer), &s_cancel);
printer.Finish();
if (!r.Ok())
return Failed(r);
printf("Installed and verified in %.0f s: %s\n", printer.Seconds(), destination.c_str());
printf("Installed and verified in %.0f s: %s\n", printer.Seconds(), folder.string().c_str());
// So the game finds it there (the default location clears the record).
if (!RecordInstallPath(destination))
fprintf(stderr, "Couldn't record the install location; start the game with NFSMW_GAME_DIR=%s\n", destination.c_str());
if (!RecordInstallPath(folder))
fprintf(stderr, "Couldn't record the install location; start the game with NFSMW_GAME_DIR=%s\n",
folder.string().c_str());
return 0;
}
@@ -183,17 +215,72 @@ 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();
if (!game)
{
printf("No game install found. Install one with --install <image|folder>.\n");
// The installer screen's notice: a recorded folder that isn't there now, say.
RecordedInstall recorded = CheckRecordedInstall();
#if defined(__APPLE__) && !TARGET_OS_IOS
std::string notice = DescribeRecordedInstall(recorded, recorded.path.string(), true);
#else
std::string notice = DescribeRecordedInstall(recorded, recorded.path.string(), false);
#endif
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;
}
}
@@ -209,14 +296,18 @@ namespace install
return Usage("--install needs the disc image or folder to install from.");
const char* source = argv[i + 1];
fs::path destination = DefaultInstallPath();
bool named = false;
for (int j = i + 2; j < argc; j++)
{
if (std::string_view(argv[j]) == "--dest" && j + 1 < argc)
{
destination = argv[++j];
named = true;
}
else
return Usage((std::string("unexpected argument: ") + argv[j]).c_str());
}
return RunInstall(source, destination);
return RunInstall(source, destination, named);
}
if (arg == "--verify")
{
@@ -228,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;
}
+3 -1
View File
@@ -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>
+48 -19
View File
@@ -1,12 +1,14 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See locate.h.
#include "locate.h"
#include "installer.h"
#include "placement.h"
#include <user/paths.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <format>
#include <set>
#include <pwd.h>
@@ -141,8 +143,14 @@ namespace install
RecoverInterruptedInstall(*recorded, manifest);
if (std::optional<fs::path> xex = UsableXex(*recorded, true, manifest))
return Found(*recorded, *xex, InstallOrigin::User, manifest);
fprintf(stderr, "[install] the game was installed in %s, which isn't usable now (drive not inserted?)\n",
recorded->c_str());
// Why, for the log (the installer screen says it to the player:
// DescribeRecordedInstall).
std::error_code ec;
fs::file_status status = fs::status(*recorded, ec);
std::string why = status.type() == fs::file_type::not_found ? "isn't there now (a drive not connected?)"
: ec ? std::format("can't be read ({})", ec.message())
: "isn't a current install (files missing, or an older version)";
fprintf(stderr, "[install] the game was installed in %s, which %s\n", recorded->string().c_str(), why.c_str());
}
fs::path user = DefaultInstallPath();
@@ -178,39 +186,30 @@ 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;
return fs::path(text);
// 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 RecordInstallPath(const std::filesystem::path& dir)
bool WriteInstallRecord(const std::filesystem::path& file, const std::string& text)
{
std::error_code ec;
fs::path file = LocationFile();
if (fs::absolute(dir, ec).lexically_normal() == fs::absolute(DefaultInstallPath(), ec).lexically_normal())
{
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 = fs::absolute(dir, ec).lexically_normal().string() + "\n";
bool ok = fwrite(text.data(), 1, text.size(), f) == text.size();
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);
@@ -219,9 +218,39 @@ namespace install
return ok && !ec;
}
std::optional<std::filesystem::path> RecordedInstallPath()
{
return ReadInstallRecord(LocationFile(), portable::Current());
}
bool RecordInstallPath(const std::filesystem::path& dir)
{
std::error_code ec;
fs::path file = LocationFile();
// As the installer compares folders: absolute, links resolved, no
// trailing separator (`--dest dir/` and `dir` are one record).
fs::path folder = ResolvedFolder(dir);
if (SameFolder(folder, DefaultInstallPath()))
{
fs::remove(file, ec);
return !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)
{
+12 -1
View File
@@ -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
+919
View File
@@ -0,0 +1,919 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See placement.h.
#include "placement.h"
#include "locate.h"
#include <user/paths.h>
#include <algorithm>
#include <cctype>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <format>
#include <fstream>
#include <string_view>
#include <system_error>
// The folder reads are std::filesystem's; what it has no word for (the
// device a folder is on, permission to write, the file system's name, macOS
// ACLs) is POSIX, and assumed fine on Windows (placement.h).
#ifndef _WIN32
#include <sys/stat.h>
#include <unistd.h>
#endif
#ifdef __APPLE__
#include <TargetConditionals.h>
#include <mach-o/dyld.h>
#include <sys/mount.h>
#include <sys/param.h>
#if !TARGET_OS_IOS
#include <CoreFoundation/CoreFoundation.h>
#include <sys/acl.h>
#endif
#endif
namespace install
{
namespace
{
namespace fs = std::filesystem;
// Absolute, 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;
}
fs::path Resolved(const fs::path& path)
{
std::error_code ec;
fs::path absolute = fs::absolute(path, ec);
if (ec)
absolute = path;
fs::path canonical = fs::weakly_canonical(absolute, ec);
return Normal(ec ? absolute : canonical);
}
// The errno an error code stands for (std::filesystem's errors are
// the system's: errno on POSIX, mapped to it on Windows).
int ErrnoOf(const std::error_code& ec)
{
if (!ec)
return 0;
std::error_condition condition = ec.default_error_condition();
return condition.category() == std::generic_category() ? condition.value() : EIO;
}
bool IsRoot(const fs::path& path)
{
return path.has_root_path() && Normal(path) == path.root_path();
}
Result Refusal(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;
}
std::string Lower(std::string_view text)
{
std::string out(text);
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return char(std::tolower(c)); });
return out;
}
bool IsFat(std::string_view fileSystem)
{
std::string fs = Lower(fileSystem);
return fs == "vfat" || fs == "msdos" || fs == "fat" || fs == "fat32" || fs == "fat16";
}
// The name a player knows the format by.
std::string FormatName(std::string_view fileSystem)
{
std::string fs = Lower(fileSystem);
if (IsFat(fs))
return "FAT32";
if (fs == "exfat")
return "exFAT";
if (fs == "fuseblk" || fs.starts_with("ntfs"))
return "NTFS";
return std::string(fileSystem);
}
// Why a folder can't be read, and what to do.
std::string CantRead(const fs::path& path, int error, bool macOS)
{
if (macOS && error == EPERM)
return std::format("macOS doesn't let SpeedBreaker use {}. Allow it in System Settings > Privacy & Security > "
"Files and Folders, or choose another folder.", path.string());
return std::format("SpeedBreaker can't read {} ({}). Choose another folder.", path.string(),
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
// the player never saw.
Result Refuse(const fs::path& gameDir, const fs::path& picked, const PlacementContext& context)
{
if (IsRoot(gameDir))
return Refusal(Error::DestinationNotWritable, std::format("{} is the system's root folder. Choose a folder in "
"your home folder or on a drive.", gameDir.string()), gameDir);
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);
// 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;
if (bundle.extension() == ".app")
message = std::format("{} is inside the SpeedBreaker app. The game can't go there: an update replaces the "
"app, and files added to it stop macOS from opening it. Choose another folder.", named.string());
else if (named == bundle)
message = std::format("{} is SpeedBreaker's own folder. The game can't go there: updating or removing "
"SpeedBreaker replaces that folder. Choose another folder.", named.string());
else
message = std::format("{} is inside SpeedBreaker's own folder, {}. The game can't go there: updating or "
"removing SpeedBreaker replaces that folder. Choose another folder.", named.string(), bundle.string());
return Refusal(Error::DestinationNotWritable, std::move(message), gameDir);
}
for (const fs::path& install : context.installs)
{
if (install.empty())
continue;
fs::path base = Normal(install);
if (gameDir != base && IsAtOrInside(gameDir, base))
{
// The player's folder as they named it; when that is the
// install folder itself (the game would go in a new
// folder inside it), it isn't "inside" itself.
const fs::path& named = IsAtOrInside(picked, base) ? picked : gameDir;
return Refusal(Error::DestinationNotWritable, named == base
? std::format("{} is the game's own install folder. Choose a folder outside it.", base.string())
: std::format("{} is inside the game installed in {}. Choose a folder outside it.", named.string(),
base.string()), gameDir);
}
}
return {};
}
// The file system's rules and notes, for a game folder that passed
// Refuse. `picked`: as for Refuse.
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
// them there: the system says so), or a cloud service's
// folder (Dropbox, OneDrive, Google Drive...).
bool cloud = facts.cloud;
if (!context.home.empty())
for (const char* sub : { "Library/Mobile Documents", "Library/CloudStorage" })
cloud = cloud || IsAtOrInside(gameDir, Normal(context.home) / sub);
if (cloud)
return Refusal(Error::DestinationNotWritable, std::format("{} is kept in iCloud Drive or another cloud "
"service. The game can't go there: macOS would upload its files and can remove the copies on this Mac "
"to free space, while the game reads them as it plays. Choose a folder outside it.", picked.string()),
gameDir);
}
if (IsMemoryFileSystem(facts.fileSystem))
return Refusal(Error::DestinationNotWritable, std::format("{} is kept in memory ({}), which is emptied when "
"{} restarts. Choose a folder on a drive.", picked.string(), facts.fileSystem,
context.steamOS ? "SteamOS" : "the computer"), gameDir);
constexpr uint64_t kFatFileLimit = 1ull << 32; // FAT32: a file is at most 4 GB - 1 byte
if (IsFat(facts.fileSystem) && context.largestFile >= kFatFileLimit)
return Refusal(Error::DestinationNotWritable, std::format("{} is on a drive formatted FAT32, which can't hold "
"files of 4 GB or more, and one of the game's files is {}. Choose a drive formatted exFAT, NTFS or ext4.",
picked.string(), FormatSize(context.largestFile)), gameDir);
if (context.macOS && Lower(facts.fileSystem) == "ntfs")
return Refusal(Error::DestinationNotWritable, std::format("{} is on an NTFS drive, which macOS can only read. "
"Choose another drive, or one formatted exFAT.", picked.string()), gameDir);
if (IsWindowsFileSystem(facts.fileSystem))
notes.push_back(context.steamOS
? std::format("This drive is formatted for Windows ({}). That's fine for the game's files, but SteamOS "
"doesn't mount such drives in Game Mode by itself, so the game would start only in Desktop Mode.",
FormatName(facts.fileSystem))
: std::format("This drive is formatted for Windows ({}). That's fine: the game only reads its files.",
FormatName(facts.fileSystem)));
if (context.macOS)
{
// Folders macOS asks about before an app may use them (the
// open panel only grants this run).
bool asks = gameDir.parent_path() != "/Volumes" && IsAtOrInside(gameDir, "/Volumes");
if (!context.home.empty())
for (const char* sub : { "Desktop", "Documents", "Downloads" })
asks = asks || IsAtOrInside(gameDir, Normal(context.home) / sub);
if (asks)
notes.push_back("macOS may ask once whether SpeedBreaker can use this folder when the game starts: choose "
"Allow.");
}
return {};
}
// A folder's state from its status and a listing that stops at the
// first visible entry. `hidden`: an Empty folder holds hidden files.
FolderState Classify(const fs::path& path, int& error, bool* hidden = nullptr)
{
error = 0;
if (hidden)
*hidden = false;
std::error_code ec;
fs::file_status status = fs::status(path, ec);
if (status.type() == fs::file_type::not_found)
{
error = ec ? ErrnoOf(ec) : ENOENT;
return FolderState::Missing;
}
if (ec)
{
error = ErrnoOf(ec);
return error == EACCES || error == EPERM ? FolderState::Unreadable : FolderState::Missing;
}
if (!fs::is_directory(status))
return FolderState::NotAFolder;
fs::directory_iterator it(path, ec);
if (ec)
{
error = ErrnoOf(ec);
return FolderState::Unreadable;
}
bool visible = false, anyHidden = false;
for (fs::directory_iterator end; !ec && it != end; it.increment(ec))
{
const fs::path name = it->path().filename();
if (!name.empty() && name.native()[0] == fs::path::value_type('.'))
anyHidden = true;
else
{
visible = true;
break;
}
}
if (ReadInstallMarker(path))
return FolderState::Install;
if (hidden && !visible)
*hidden = anyHidden;
return visible ? FolderState::Other : FolderState::Empty;
}
// `path` is on another device than `parent`: the top of a mounted
// drive. (Windows: a drive's top is its root, IsRoot.)
bool OnAnotherDevice(const fs::path& path, const fs::path& parent)
{
#ifndef _WIN32
struct stat self{}, above{};
return stat(path.c_str(), &self) == 0 && stat(parent.c_str(), &above) == 0 && self.st_dev != above.st_dev;
#else
(void)path;
(void)parent;
return false;
#endif
}
// A new file or folder can be made in `folder`. (Windows: access()
// ignores ACLs there; CheckDestination is left to tell.)
bool CanWrite(const fs::path& folder)
{
#ifndef _WIN32
return access(folder.c_str(), W_OK | X_OK) == 0;
#else
(void)folder;
return true;
#endif
}
// macOS: Install may move `folder` aside (rename it). Not with a
// "deny delete" in its ACL (fresh ~/Music, ~/Pictures, ~/Movies and
// ~/Desktop have "group:everyone deny delete") or an immutable or
// append-only flag. Any deny-delete entry counts, whoever it names:
// the cost of a wrong guess is only a subfolder.
bool CanMoveAside(const fs::path& folder)
{
#if defined(__APPLE__) && !TARGET_OS_IOS
struct stat st{};
if (stat(folder.c_str(), &st) == 0 && (st.st_flags & (UF_IMMUTABLE | SF_IMMUTABLE | UF_APPEND | SF_APPEND)))
return false;
acl_t acl = acl_get_file(folder.c_str(), ACL_TYPE_EXTENDED);
if (!acl)
return true;
bool denied = false;
acl_entry_t entry;
for (int which = ACL_FIRST_ENTRY; !denied && acl_get_entry(acl, which, &entry) == 0; which = ACL_NEXT_ENTRY)
{
acl_tag_t tag;
acl_permset_t permissions;
denied = acl_get_tag_type(entry, &tag) == 0 && tag == ACL_EXTENDED_DENY
&& acl_get_permset(entry, &permissions) == 0 && acl_get_perm_np(permissions, ACL_DELETE) == 1;
}
acl_free(acl);
return !denied;
#else
(void)folder;
return true;
#endif
}
// The nearest folder at or above `path` that is there.
fs::path NearestExisting(fs::path path)
{
std::error_code ec;
while (!path.empty() && !fs::exists(path, ec) && path != path.parent_path())
path = path.parent_path();
return path;
}
#if !defined(__APPLE__) && !defined(_WIN32)
// /proc/self/mountinfo escapes spaces and the like as \ooo.
std::string Unescape(std::string_view text)
{
std::string out;
for (size_t i = 0; i < text.size(); i++)
{
if (text[i] == '\\' && i + 3 < text.size())
{
int value = 0;
bool octal = true;
for (size_t j = 1; j <= 3; j++)
{
if (text[i + j] < '0' || text[i + j] > '7')
{
octal = false;
break;
}
value = value * 8 + (text[i + j] - '0');
}
if (octal)
{
out += char(value);
i += 3;
continue;
}
}
out += text[i];
}
return out;
}
#endif
std::string FileSystemOf(const fs::path& path)
{
#if defined(_WIN32)
(void)path;
return {};
#elif defined(__APPLE__)
struct statfs sfs{};
if (statfs(path.c_str(), &sfs) != 0)
return {};
return sfs.f_fstypename;
#else
// The longest mount point holding `path`.
std::ifstream mounts("/proc/self/mountinfo");
std::string line, best, type;
while (std::getline(mounts, line))
{
// id parent major:minor root mount-point options [optional...] - type source super-options
size_t dash = line.find(" - ");
if (dash == std::string::npos)
continue;
std::string_view head(line.data(), dash);
size_t field = 0, at = 0;
std::string_view mountPoint;
while (at <= head.size() && field <= 4)
{
size_t end = head.find(' ', at);
if (end == std::string_view::npos)
end = head.size();
if (field == 4)
mountPoint = head.substr(at, end - at);
field++;
at = end + 1;
}
std::string point = Unescape(mountPoint);
if (point.empty() || !IsAtOrInside(path, point) || point.size() < best.size())
continue;
std::string_view tail(line.data() + dash + 3, line.size() - dash - 3);
best = point;
type = std::string(tail.substr(0, tail.find(' ')));
}
return type;
#endif
}
// macOS: is `path` (or, if it isn't there, the nearest folder above it)
// in iCloud Drive? A metadata read: no privacy prompt, no download.
bool InICloud(fs::path path)
{
#if defined(__APPLE__) && !TARGET_OS_IOS
path = NearestExisting(path);
std::string text = path.string();
CFURLRef url = CFURLCreateFromFileSystemRepresentation(nullptr, reinterpret_cast<const UInt8*>(text.data()),
CFIndex(text.size()), true);
if (!url)
return false;
CFTypeRef value = nullptr;
bool ubiquitous = CFURLCopyResourcePropertyForKey(url, kCFURLIsUbiquitousItemKey, &value, nullptr) && value
&& CFGetTypeID(value) == CFBooleanGetTypeID() && CFBooleanGetValue(static_cast<CFBooleanRef>(value));
if (value)
CFRelease(value);
CFRelease(url);
return ubiquitous;
#else
(void)path;
return false;
#endif
}
bool IsSteamOS()
{
#if defined(__linux__)
std::ifstream release("/etc/os-release");
std::string line;
while (std::getline(release, line))
if (line == "ID=steamos" || line == "ID=\"steamos\"")
return true;
#endif
return false;
}
fs::path& ProgramPath()
{
static fs::path path;
return path;
}
// Where the running program is, without argv[0].
fs::path ExecutablePath()
{
#if defined(_WIN32)
return {}; // argv[0] (SetProgramPath)
#elif defined(__APPLE__)
char buffer[MAXPATHLEN];
uint32_t size = sizeof(buffer);
if (_NSGetExecutablePath(buffer, &size) == 0)
return Resolved(buffer);
return {};
#else
std::error_code ec;
fs::path exe = fs::read_symlink("/proc/self/exe", ec);
// Started through the bundled C library's loader, that's the
// loader, not the game.
if (ec || exe.filename().string().starts_with("ld-"))
return {};
return Normal(exe);
#endif
}
}
std::filesystem::path SubfolderName(bool driveTop)
{
return driveTop ? fs::path(kUserFolderName) / "game" : fs::path(kPickedSubfolder);
}
std::filesystem::path ResolvedFolder(const std::filesystem::path& path)
{
return path.empty() ? path : Resolved(path);
}
bool SameFolder(const std::filesystem::path& a, const std::filesystem::path& b)
{
return !a.empty() && !b.empty() && Resolved(a) == Resolved(b);
}
bool IsAtOrInside(const std::filesystem::path& path, const std::filesystem::path& base)
{
fs::path p = Normal(path), b = Normal(base);
if (p.empty() || b.empty())
return false;
fs::path relative = p.lexically_relative(b);
if (relative.empty())
return false;
return relative == "." || *relative.begin() != "..";
}
bool IsWindowsFileSystem(std::string_view fileSystem)
{
std::string fs = Lower(fileSystem);
return IsFat(fs) || fs == "exfat" || fs == "ntfs" || fs == "ntfs3" || fs == "fuseblk";
}
bool IsMemoryFileSystem(std::string_view fileSystem)
{
std::string fs = Lower(fileSystem);
return fs == "tmpfs" || fs == "ramfs";
}
FolderChoice PlaceInPicked(const FolderFacts& picked, const PlacementContext& context)
{
FolderChoice choice;
const fs::path path = Normal(picked.path);
choice.picked = path;
choice.gameDir = path;
auto refuse = [&](Error error, std::string message, const fs::path& file) {
choice.result = Refusal(error, std::move(message), file);
return choice;
};
switch (picked.state)
{
case FolderState::Missing:
return refuse(Error::NotFound, std::format("{} isn't there any more. If it's on a drive, connect it, or choose "
"another folder.", path.string()), path);
case FolderState::NotAFolder:
return refuse(Error::DestinationNotEmpty, std::format("{} is a file. Choose a folder.", path.string()), path);
case FolderState::Unreadable:
return refuse(Error::DestinationNotWritable, CantRead(path, picked.error, context.macOS), path);
default:
break;
}
if (IsRoot(path))
return refuse(Error::DestinationNotWritable, std::format("{} is the system's root folder. Choose a folder in your "
"home folder or on a drive.", path.string()), path);
// In the folder itself, or in a new folder inside it.
const bool home = !context.home.empty() && path == Normal(context.home);
if (picked.driveTop)
choice.subfolder = SubfolderReason::DriveTop;
else if (home)
choice.subfolder = SubfolderReason::Home;
else if (picked.state == FolderState::Install)
choice.subfolder = SubfolderReason::None;
else if (picked.state == FolderState::Empty)
choice.subfolder = !picked.parentWritable ? SubfolderReason::ParentReadOnly
: picked.hidden && !picked.movable ? SubfolderReason::KeptInPlace
: SubfolderReason::None;
else
choice.subfolder = SubfolderReason::NotEmpty;
if (choice.subfolder != SubfolderReason::None)
{
choice.gameDir = path / SubfolderName(picked.driveTop);
const SubfolderFacts& sub = picked.subfolder;
// A link would take the game somewhere the rules below never
// looked (SpeedBreaker's own folder, an install, iCloud Drive).
if (sub.link)
{
// A link that leads nowhere resolves to itself: say where it
// points (or that it points nowhere), not "a link to" itself.
if (sub.dangling)
return refuse(Error::DestinationNotEmpty, sub.target.empty()
? std::format("{} is a link that leads nowhere. Remove it, or choose another folder.",
choice.gameDir.string())
: std::format("{} is a link to {}, which isn't there. Remove the link, or choose another folder.",
choice.gameDir.string(), sub.target.string()), choice.gameDir);
if (sub.state == FolderState::NotAFolder)
return refuse(Error::DestinationNotEmpty, std::format("{} is a link to a file{}{}. Move it, or choose "
"another folder.", choice.gameDir.string(), sub.target.empty() ? "" : ", ", sub.target.string()),
choice.gameDir);
return refuse(Error::DestinationNotEmpty, std::format("{} is a link to {}. Choose that folder itself, or "
"another folder.", choice.gameDir.string(), sub.target.empty() ? std::string("another folder")
: sub.target.string()), choice.gameDir);
}
switch (sub.state)
{
case FolderState::NotAFolder:
return refuse(Error::DestinationNotEmpty, std::format("{} is a file, so the game can't go in a folder of that "
"name. Move it, or choose another folder.", choice.gameDir.string()), choice.gameDir);
case FolderState::Other:
return refuse(Error::DestinationNotEmpty, std::format("{} already has a folder named \"{}\" with other files "
"in it. Empty it or move it, or choose another folder.", path.string(),
SubfolderName(picked.driveTop).string()), choice.gameDir);
case FolderState::Unreadable:
return refuse(Error::DestinationNotWritable, CantRead(choice.gameDir, sub.error, context.macOS),
choice.gameDir);
default:
break; // new, empty, or a previous install (replaced)
}
}
if (Result r = Refuse(choice.gameDir, path, context); !r.Ok())
{
choice.result = std::move(r);
return choice;
}
choice.result = FileSystemRules(choice.gameDir, path, picked, context, choice.notes);
if (!choice.result.Ok())
return choice;
// A folder that is there is replaced, or moved aside first: macOS
// refuses either for a folder its owner can't write, after the copy.
const bool there = choice.subfolder == SubfolderReason::None || picked.subfolder.state == FolderState::Empty
|| picked.subfolder.state == FolderState::Install;
const bool writable = choice.subfolder == SubfolderReason::None ? picked.writable : picked.subfolder.writable;
if (there && !writable)
return refuse(Error::DestinationNotWritable, std::format("SpeedBreaker can't write to {}. Choose another folder.",
choice.gameDir.string()), choice.gameDir);
return choice;
}
FolderChoice PlaceExact(const FolderFacts& gameDir, bool parentExists, const PlacementContext& context)
{
FolderChoice choice;
const fs::path path = Normal(gameDir.path);
choice.picked = path;
choice.gameDir = path;
auto refuse = [&](Error error, std::string message) {
choice.result = Refusal(error, std::move(message), path);
return choice;
};
if (!parentExists && gameDir.state == FolderState::Missing)
return refuse(Error::NotFound, std::format("{} isn't there now. If it's on a drive or SD card, connect it, or "
"choose another folder.", path.string()));
if (gameDir.state == FolderState::NotAFolder)
return refuse(Error::DestinationNotEmpty, std::format("{} is a file. Choose a folder.", path.string()));
if (gameDir.state == FolderState::Unreadable)
return refuse(Error::DestinationNotWritable, CantRead(path, gameDir.error, context.macOS));
if (Result r = Refuse(path, path, context); !r.Ok())
{
choice.result = std::move(r);
return choice;
}
if (gameDir.driveTop)
return refuse(Error::DestinationNotWritable, std::format("{} is a whole drive. Choose a folder on it instead.",
path.string()));
choice.result = FileSystemRules(path, path, gameDir, context, choice.notes);
if (!choice.result.Ok())
return choice;
const bool usable = gameDir.state == FolderState::Empty || gameDir.state == FolderState::Install;
if (usable && !gameDir.writable)
return refuse(Error::DestinationNotWritable, std::format("SpeedBreaker can't write to {}. Choose another folder.",
path.string()));
if (gameDir.state == FolderState::Empty && gameDir.hidden && !gameDir.movable)
return refuse(Error::DestinationNotWritable, std::format("macOS keeps {} in place, so the game can't take its "
"place. Choose a new folder inside it, such as {}.", path.string(), (path / kPickedSubfolder).string()));
return choice;
}
std::string SubfolderExplanation(const FolderChoice& choice)
{
std::string name = choice.picked.filename().string();
if (name.empty())
name = choice.picked.string();
switch (choice.subfolder)
{
case SubfolderReason::NotEmpty:
return std::format("{} already holds other files, so the game goes in a new folder inside it.", name);
case SubfolderReason::DriveTop:
return std::format("That's the top of the drive {}, so the game goes in a folder on it.", name);
case SubfolderReason::Home:
return "That's your home folder, so the game goes in a new folder inside it.";
case SubfolderReason::ParentReadOnly:
return std::format("SpeedBreaker can't write next to {}, so the game goes in a new folder inside it.", name);
case SubfolderReason::KeptInPlace:
return std::format("macOS keeps {} in place, so the game goes in a new folder inside it.", name);
case SubfolderReason::None:
break;
}
return {};
}
FolderFacts ProbeFolder(const std::filesystem::path& path)
{
FolderFacts facts;
facts.path = Resolved(path);
facts.state = Classify(facts.path, facts.error, &facts.hidden);
facts.cloud = InICloud(facts.path);
fs::path parent = facts.path.parent_path();
if (facts.state == FolderState::Missing)
{
// What it would be made on (--dest makes missing folders).
facts.fileSystem = FileSystemOf(NearestExisting(facts.path));
facts.parentWritable = CanWrite(NearestExisting(parent));
return facts;
}
if (facts.state == FolderState::NotAFolder)
return facts;
facts.driveTop = IsRoot(facts.path) || OnAnotherDevice(facts.path, parent);
facts.parentWritable = CanWrite(parent);
facts.fileSystem = FileSystemOf(facts.path);
if (facts.state == FolderState::Unreadable)
return facts;
facts.writable = CanWrite(facts.path);
if (facts.hidden)
facts.movable = CanMoveAside(facts.path);
// The folder the game would go in inside it, and whether a link on
// the way leads elsewhere.
const fs::path name = SubfolderName(facts.driveTop);
const fs::path sub = facts.path / name;
SubfolderFacts& inner = facts.subfolder;
fs::path at = facts.path;
for (const fs::path& part : name)
{
at /= part;
std::error_code ec;
if (fs::is_symlink(fs::symlink_status(at, ec)))
{
inner.link = true;
inner.target = Resolved(sub);
// A link that leads nowhere: weakly_canonical stops at it and
// gives the link back. Where it points instead (relative to
// the link's folder), and the rest of the subfolder's name.
std::error_code there;
if (!fs::exists(at, there))
{
inner.dangling = true;
std::error_code read;
fs::path to = fs::read_symlink(at, read);
inner.target.clear();
if (!read && !to.empty())
inner.target = Normal((to.is_absolute() ? to : at.parent_path() / to) / sub.lexically_relative(at));
}
break;
}
}
inner.state = Classify(sub, inner.error);
if (inner.state == FolderState::Empty || inner.state == FolderState::Install)
inner.writable = CanWrite(sub);
return facts;
}
PlacementContext CurrentPlacementContext(const Manifest& manifest)
{
PlacementContext context;
#ifdef _WIN32
const char* home = std::getenv("USERPROFILE"); // not $HOME: Git Bash and MSYS2 set their own
#else
const char* home = std::getenv("HOME");
#endif
if (home && *home)
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));
for (const ManifestFile& file : manifest.files)
context.largestFile = std::max(context.largestFile, file.size);
#if defined(__APPLE__) && !TARGET_OS_IOS
context.macOS = true;
#endif
context.steamOS = IsSteamOS();
return context;
}
FolderChoice CheckPickedFolder(const std::filesystem::path& picked, const Manifest& manifest)
{
FolderChoice choice = PlaceInPicked(ProbeFolder(picked), CurrentPlacementContext(manifest));
if (choice.result.Ok())
choice.result = CheckDestination(choice.gameDir, &choice.info, manifest);
return choice;
}
FolderChoice CheckExactFolder(const std::filesystem::path& gameDir, bool mayCreateParents, const Manifest& manifest)
{
FolderFacts facts = ProbeFolder(gameDir);
std::error_code ec;
bool parentExists = mayCreateParents || fs::is_directory(facts.path.parent_path(), ec);
FolderChoice choice = PlaceExact(facts, parentExists, CurrentPlacementContext(manifest));
if (choice.result.Ok())
choice.result = CheckDestination(choice.gameDir, &choice.info, manifest);
return choice;
}
void SetProgramPath(const char* argv0)
{
// A bare name came from $PATH: the platform knows better.
#ifdef _WIN32
const bool hasFolder = argv0 && std::strpbrk(argv0, "/\\");
#else
const bool hasFolder = argv0 && std::strchr(argv0, '/');
#endif
if (hasFolder)
ProgramPath() = Resolved(argv0);
else
ProgramPath() = ExecutablePath();
}
std::filesystem::path BundleFolder()
{
fs::path program = ProgramPath().empty() ? ExecutablePath() : ProgramPath();
if (program.empty())
return {};
fs::path dir = program.parent_path();
#ifdef __APPLE__
for (fs::path p = dir; !p.empty() && p != p.root_path(); p = p.parent_path())
if (p.extension() == ".app")
return p;
#else
// The SteamOS bundle keeps speedbreaker.sh at its top; the program is
// beside it, or in its lib/.
std::error_code ec;
if (dir.filename() == "lib" && fs::exists(dir.parent_path() / "speedbreaker.sh", ec))
return dir.parent_path();
#endif
return dir;
}
std::string FolderPickerHint(bool gameMode, bool macOS, bool noPickerApp, const std::string& program,
const std::string& image)
{
if (macOS)
return std::format("The folder picker couldn't open. Install from Terminal instead: {} --install {} --dest <folder>",
program, image);
if (gameMode)
return std::format("The folder picker can't open in Game Mode. Choose a folder in Desktop Mode, or install from a "
"terminal: {} --install {} --dest <folder>", program, image);
if (noPickerApp)
return std::format("The folder picker couldn't open (it needs a file chooser portal or zenity). Install from a "
"terminal instead: {} --install {} --dest <folder>", program, image);
return std::format("The folder picker couldn't open. Install from a terminal instead: {} --install {} --dest <folder>",
program, image);
}
RecordedInstall CheckRecordedInstall(const Manifest& manifest)
{
RecordedInstall recorded;
std::optional<fs::path> path = RecordedInstallPath();
if (!path)
return recorded;
recorded.path = *path;
std::error_code ec;
fs::file_status status = fs::status(*path, ec);
if (status.type() == fs::file_type::not_found || ec)
{
recorded.error = ec ? ErrnoOf(ec) : ENOENT;
recorded.state = recorded.error == EACCES || recorded.error == EPERM ? RecordedState::NotAllowed
: RecordedState::Missing;
return recorded;
}
if (IsUsableInstall(*path, true, manifest))
{
recorded.state = RecordedState::Usable;
return recorded;
}
// There, but not usable: unreadable (macOS privacy settings can
// allow the status and refuse the listing), or not a current install.
fs::directory_iterator listing(*path, ec);
recorded.error = ErrnoOf(ec);
recorded.state = recorded.error == EACCES || recorded.error == EPERM ? RecordedState::NotAllowed
: RecordedState::NotCurrent;
return recorded;
}
std::string DescribeRecordedInstall(const RecordedInstall& recorded, const std::string& shown, bool macOS)
{
switch (recorded.state)
{
case RecordedState::Missing:
return std::format("The game is installed in {}, which isn't there now. If it's on a drive or SD card, connect it "
"and start SpeedBreaker again, or install the game again here.", shown);
case RecordedState::NotAllowed:
return macOS
? std::format("SpeedBreaker isn't allowed to read {}, where the game is installed. Allow it in System "
"Settings > Privacy & Security > Files and Folders and start SpeedBreaker again, or install the game "
"again here.", shown)
: std::format("SpeedBreaker can't read {}, where the game is installed ({}). Fix its permissions and start "
"SpeedBreaker again, or install the game again here.", shown, strerror(recorded.error ? recorded.error : EACCES));
case RecordedState::NotCurrent:
return std::format("The game in {} is incomplete or from an older version. Install it again here: it goes back "
"into the same folder.", shown);
case RecordedState::None:
case RecordedState::Usable:
break;
}
return {};
}
}
+252
View File
@@ -0,0 +1,252 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// "Choose a folder..." in the installer: where in a folder the player picked
// the game goes, and whether it may go there. GitHub issue #1 asked for an
// install outside ~/.local/share; the --install command's --dest already
// takes any folder as it is, and this is the installer screen's way to do it.
//
// The rules (PlaceInPicked):
// - A new or empty folder (hidden files such as .DS_Store don't count) is
// used as it is, and so is one that holds a previous SpeedBreaker install
// (it is replaced, keeping DLC and the player's own files, as Install
// always does).
// - Any other folder gets a new folder inside it, kPickedSubfolder, which
// must be new, empty or a previous install (and not a link: it would take
// the game somewhere else). So do three folders that are empty but can't
// be used as they are: the home folder itself, a folder whose parent
// SpeedBreaker can't write (Install copies into a sibling
// "<folder>.partial" first), and on macOS a folder holding only hidden
// files that macOS won't let be moved (Install moves such a folder aside;
// fresh ~/Music, ~/Pictures and ~/Desktop deny that to everyone). The top
// of a drive (Install renames its copy into place, and a drive's top
// can't be renamed) gets the drive's own install folder instead,
// <drive>/speedbreaker/game (SpeedBreaker/game on macOS): the one the To
// row's entry for that drive uses, so the two never make two copies.
// - Never into the system's root folder, the home folder itself,
// SpeedBreaker's own folder (the SteamOS bundle or the Mac app: an update
// 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.
// - macOS: never into iCloud Drive (Desktop and Documents too, when the Mac
// keeps them there) or another cloud folder (~/Library/CloudStorage):
// macOS would upload the 7 GB and may remove the local copies to save
// space, and the game reads its files as it plays.
// - Linux: never into a folder kept in memory (tmpfs, such as /tmp on
// SteamOS): it is emptied when the system restarts.
// - Drives formatted for Windows are fine: the game only reads its files.
// FAT32 can't hold a file of 4 GB or more (no game file is that big;
// the check is the manifest's), NTFS is read-only on macOS, and SteamOS
// doesn't mount such drives in Game Mode by itself (its automount takes
// ext4 only), which the player is told.
// - Free space and permission to write are CheckDestination's (Install's
// own checks), on the folder the game would go to. The rules look at the
// picked folder with its links resolved, and refuse a link where the
// subfolder goes, so they see the folder Install will really use.
// The command line's --dest is exact (no subfolder), but gets the same
// refusals (CheckExactFolder).
//
// The deciding is pure (PlaceInPicked, PlaceExact: facts in, a choice out,
// tested in tests/install_folder_test.cpp); ProbeFolder is the only part that
// looks at the disk, and CheckPickedFolder / CheckExactFolder put the two
// together with CheckDestination.
//
// Windows: the disk reads are std::filesystem's where it has one, so this
// builds there; what it lacks (drive tops of mounted folders, writability,
// the file system's name) is POSIX-only and assumed fine. The screen hides
// "Choose a folder..." there until a port does those properly (the windows
// branch's platform/ layer: CheckWritableFolder, OneDrive as a cloud folder,
// drive roots other than the system's, SpeedBreaker.exe in the hints).
#pragma once
#include "installer.h"
#include "result.h"
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace install
{
// The folder made inside a picked folder that isn't empty. Not
// "SpeedBreaker": on SteamOS that's the bundle's own folder in ~/Games,
// the folder players are most likely to pick.
inline constexpr const char* kPickedSubfolder = "SpeedBreaker Game";
enum class FolderState
{
Missing, // nothing there
Empty, // a folder holding nothing, or only hidden files
Install, // a folder holding Install's marker (a previous install)
Other, // a folder holding other files
NotAFolder, // a file (or a link to one)
Unreadable, // permission denied (on macOS, maybe privacy settings); `error` says
};
// What is at the folder the game would go in inside a picked one
// (SubfolderName): ProbeFolder fills it in.
struct SubfolderFacts
{
FolderState state = FolderState::Missing;
int error = 0; // errno of the failed look, if any
bool writable = true; // it can be written (when it is there)
bool link = false; // it, or a folder on the way to it, is a link (to `target`)
bool dangling = false; // that link leads nowhere (`target`: where it points, as far as it says)
std::filesystem::path target;
};
// What is on disk at a folder (ProbeFolder fills it in).
struct FolderFacts
{
std::filesystem::path path; // absolute, links resolved
FolderState state = FolderState::Missing;
int error = 0; // errno of the failed look, if any
bool hidden = false; // Empty, but holding hidden files: Install moves it aside rather than replace it
bool writable = true; // the folder itself can be written (when it is there)
bool movable = true; // macOS: no "deny delete" in its ACL, no immutable flag (it can be moved aside)
bool driveTop = false; // the top of a mounted drive (or the system's root folder)
bool parentWritable = true; // the folder above it can be written (Install's .partial goes there)
std::string fileSystem; // "apfs", "ext4", "exfat", "vfat", "msdos", "ntfs", "fuseblk", "tmpfs"...; "" unknown.
// A missing folder: the nearest folder above it that is there.
bool cloud = false; // macOS: in iCloud Drive (the system says so; ~/Library paths are the rules')
SubfolderFacts subfolder; // path / SubfolderName(driveTop)
};
// What the rules need to know besides the folder itself.
struct PlacementContext
{
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;
bool steamOS = false;
};
// Why the game goes in a new folder inside the picked one.
enum class SubfolderReason { None, NotEmpty, DriveTop, Home, ParentReadOnly, KeptInPlace };
// The folder made inside a picked one: kPickedSubfolder, or on the top
// of a drive the drive's own install folder (InstallDestinations').
std::filesystem::path SubfolderName(bool driveTop);
struct FolderChoice
{
std::filesystem::path picked; // the folder the player picked (links resolved)
std::filesystem::path gameDir; // where the game goes: `picked`, or SubfolderName() in it
SubfolderReason subfolder = SubfolderReason::None;
std::vector<std::string> notes; // things to know that don't stop the install
Result result; // Ok, or why not (DestinationNotWritable, DestinationNotEmpty, NotFound, NotEnoughSpace)
DestinationInfo info; // CheckDestination's for gameDir, when it got that far
};
// Pure: where in the picked folder the game goes, and the refusals and
// notes above. No free-space or permission check (CheckPickedFolder adds
// CheckDestination's).
FolderChoice PlaceInPicked(const FolderFacts& picked, const PlacementContext& context);
// Pure: the same refusals for an exact game folder (the one recorded
// last time, offered again, or the command line's --dest).
// `parentExists`: the folder above it is there; if not, it's on a drive
// that isn't (NotFound), and nothing should be created where the drive
// mounts. (The command line passes true: it makes missing folders.)
FolderChoice PlaceExact(const FolderFacts& gameDir, bool parentExists, const PlacementContext& context);
// One line under the folder's path: why the game goes in a new folder
// inside the picked one ("" for SubfolderReason::None).
std::string SubfolderExplanation(const FolderChoice& choice);
// Reads the disk: what is at `path` (not following it further than
// links). Cheap: a few stat calls, two folder listings that stop at the
// first visible entry, and on Linux one read of /proc/self/mountinfo.
FolderFacts ProbeFolder(const std::filesystem::path& path);
// The running game's context: $HOME, BundleFolder(), the default and
// recorded installs, the manifest's biggest file, the platform.
PlacementContext CurrentPlacementContext(const Manifest& manifest = GameManifest());
// ProbeFolder + PlaceInPicked + CheckDestination on the chosen game
// folder: everything the installer screen shows for a picked folder.
FolderChoice CheckPickedFolder(const std::filesystem::path& picked, const Manifest& manifest = GameManifest());
// The same for an exact game folder (no subfolder): the one recorded last
// time (RecordedInstallPath; `mayCreateParents` false: a missing drive is
// NotFound), or the command line's --dest (true: Install makes the
// folders above it).
FolderChoice CheckExactFolder(const std::filesystem::path& gameDir, bool mayCreateParents,
const Manifest& manifest = GameManifest());
// main() calls this first with argv[0], so BundleFolder() can say which
// folder the game runs from (the SteamOS launcher passes the program's
// absolute path as argv[0]; the bundled C library's loader would be
// /proc/self/exe).
void SetProgramPath(const char* argv0);
// SpeedBreaker's own folder: the Mac app (SpeedBreaker.app), the SteamOS
// bundle (the folder with speedbreaker.sh; also when the program is in
// its lib/), or the program's folder (a development build). Empty if
// unknown.
std::filesystem::path BundleFolder();
// Is `path` `base` or inside it? (Lexical: both absolute and normalized.)
bool IsAtOrInside(const std::filesystem::path& path, const std::filesystem::path& base);
// A folder as the installer records and compares it: absolute, links
// resolved as far as it exists, no trailing separator. SameFolder: the
// two are the same folder that way (`--dest dir/` and `dir`, or a link
// and where it leads).
std::filesystem::path ResolvedFolder(const std::filesystem::path& path);
bool SameFolder(const std::filesystem::path& a, const std::filesystem::path& b);
// A drive formatted for Windows (FAT, exFAT, NTFS, or ntfs-3g's fuseblk).
bool IsWindowsFileSystem(std::string_view fileSystem);
// A file system kept in memory and emptied at a restart (tmpfs, ramfs).
bool IsMemoryFileSystem(std::string_view fileSystem);
// Pure: the one-line hint when "Choose a folder..." can't show a folder
// picker: in SteamOS Game Mode (nothing of the desktop is on screen, so
// it isn't tried), or where no dialog could open (on Linux,
// `noPickerApp`: SDL found neither a file chooser portal nor zenity;
// otherwise the picker failed some other way, logged; on macOS, any
// failure). The command line installs into any folder, so it says how:
// `program` is what to run (the bundle's speedbreaker.sh, the Mac app's
// binary), `image` the image already chosen.
std::string FolderPickerHint(bool gameMode, bool macOS, bool noPickerApp, const std::string& program,
const std::string& image);
// Why the install recorded last time (RecordedInstallPath) wasn't used.
enum class RecordedState
{
None, // nothing recorded
Usable, // it's there and current
Missing, // the folder isn't there (a drive not connected, or deleted)
NotAllowed, // it can't be read (on macOS, privacy settings)
NotCurrent, // the folder is there, but not a current install (files missing, an older version)
};
struct RecordedInstall
{
std::filesystem::path path;
RecordedState state = RecordedState::None;
int error = 0;
};
// Looks at the recorded install (a few stat calls).
RecordedInstall CheckRecordedInstall(const Manifest& manifest = GameManifest());
// Pure: the installer's notice for a recorded install that wasn't used,
// with the path as the player reads it (`shown`, ~ for home): what
// happened and what to do. "" for None and Usable.
std::string DescribeRecordedInstall(const RecordedInstall& recorded, const std::string& shown, bool macOS);
}
+374
View File
@@ -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 {};
}
}
+58
View File
@@ -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());
}
+35 -3
View File
@@ -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;
+7
View File
@@ -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
{
+237
View File
@@ -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
}
}
+105
View File
@@ -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"
}
+108 -6
View File
@@ -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"
@@ -21,7 +22,9 @@
#include <cpu/host_cpu.h>
#include <install/install_cli.h>
#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>
@@ -170,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)
{
@@ -185,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;
}
@@ -276,9 +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), 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))
@@ -295,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;
}
@@ -312,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] == '-')
@@ -345,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)
@@ -359,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
@@ -373,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();
@@ -483,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;
@@ -500,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();
+31
View File
@@ -0,0 +1,31 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ../file_dialog.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/file_dialog.h>
#import <AppKit/AppKit.h>
namespace platform::file_dialog
{
int CancelOpenPanels()
{
@autoreleasepool
{
int cancelled = 0;
// A copy: a cancelled sheet leaves the window list.
NSArray<NSWindow*>* windows = [[[NSApp windows] copy] autorelease];
for (NSWindow* window in windows)
if ([window isKindOfClass:[NSSavePanel class]] && [window isVisible])
{
[(NSSavePanel*)window cancel:nil];
cancelled++;
}
return cancelled;
}
}
}
#endif
+126
View File
@@ -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
+182
View File
@@ -0,0 +1,182 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See update/fetch.h.
//
// macOS and iOS: NSURLSession (Foundation only, so it builds for both), in
// an ephemeral session of its own: no cookies sent or kept, no cache, no
// stored credentials, the system's proxy settings and certificates. Besides
// our headers CFNetwork sends Connection, Accept-Encoding and Cache-Control
// (no-cache); Accept-Language is set to a fixed "en". Manual
// reference counting like the rest of the runtime: the session is
// invalidated (which releases it) whether the request ends or is cut off.
//
// The completion handler can run after Fetch has given up waiting (the
// deadline is the request's timeout plus two seconds): what it writes lives
// in a shared_ptr the block holds, so it never touches a returned frame.
#include <update/fetch.h>
#include <condition_variable>
#include <format>
#include <memory>
#include <mutex>
#import <Foundation/Foundation.h>
namespace update
{
namespace
{
struct Pending
{
std::mutex mutex;
std::condition_variable done;
bool finished = false;
Fetched result;
};
// NSURLErrorDomain's codes as the transport's outcomes.
Transport FromError(NSError* error)
{
if (![error.domain isEqualToString:NSURLErrorDomain])
return Transport::Failed;
switch (error.code)
{
case NSURLErrorNotConnectedToInternet:
case NSURLErrorCannotFindHost:
case NSURLErrorCannotConnectToHost:
case NSURLErrorNetworkConnectionLost:
case NSURLErrorDNSLookupFailed:
case NSURLErrorInternationalRoamingOff:
case NSURLErrorDataNotAllowed:
case NSURLErrorCallIsActive:
return Transport::Offline;
case NSURLErrorTimedOut:
return Transport::Timeout;
case NSURLErrorSecureConnectionFailed:
case NSURLErrorServerCertificateHasBadDate:
case NSURLErrorServerCertificateUntrusted:
case NSURLErrorServerCertificateHasUnknownRoot:
case NSURLErrorServerCertificateNotYetValid:
case NSURLErrorClientCertificateRejected:
case NSURLErrorClientCertificateRequired:
case NSURLErrorAppTransportSecurityRequiresSecureConnection:
return Transport::Secure;
case NSURLErrorDataLengthExceedsMaximum:
return Transport::TooLarge;
case NSURLErrorBadServerResponse:
case NSURLErrorCannotDecodeRawData:
case NSURLErrorCannotDecodeContentData:
case NSURLErrorCannotParseResponse:
return Transport::Garbled;
default:
return Transport::Failed;
}
}
NSString* String(const std::string& text)
{
return [NSString stringWithUTF8String:text.c_str()] ?: @"";
}
}
Fetched Fetch(const Request& request)
{
auto pending = std::make_shared<Pending>();
NSURLSession* session = nil;
NSURLSessionDataTask* task = nil;
@autoreleasepool
{
NSURL* url = [NSURL URLWithString:String(request.url)];
NSString* scheme = url.scheme.lowercaseString;
if (!url || !url.host || !([scheme isEqualToString:@"https"] || (request.allowHttp && [scheme isEqualToString:@"http"])))
{
Fetched result;
result.detail = "not an https address";
return result;
}
NSURLSessionConfiguration* config = [NSURLSessionConfiguration ephemeralSessionConfiguration];
config.timeoutIntervalForRequest = request.timeoutSeconds;
config.timeoutIntervalForResource = request.timeoutSeconds;
config.requestCachePolicy = NSURLRequestReloadIgnoringLocalAndRemoteCacheData;
config.URLCache = nil;
config.HTTPCookieStorage = nil;
config.HTTPCookieAcceptPolicy = NSHTTPCookieAcceptPolicyNever;
config.HTTPShouldSetCookies = NO;
config.URLCredentialStorage = nil;
config.waitsForConnectivity = NO;
NSMutableURLRequest* get = [NSMutableURLRequest requestWithURL:url
cachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData
timeoutInterval:request.timeoutSeconds];
get.HTTPMethod = @"GET";
get.HTTPShouldHandleCookies = NO;
[get setValue:String(request.userAgent) forHTTPHeaderField:@"User-Agent"];
// CFNetwork would add the system's languages ("en-US,en;q=0.9"):
// a fixed one says nothing about the player (curl sends none).
[get setValue:@"en" forHTTPHeaderField:@"Accept-Language"];
for (const auto& [name, value] : request.headers)
[get setValue:String(value) forHTTPHeaderField:String(name)];
const size_t maxBytes = request.maxBytes;
session = [[NSURLSession sessionWithConfiguration:config] retain];
task = [[session dataTaskWithRequest:get
completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) {
Fetched result;
if (error)
{
result.transport = FromError(error);
result.detail = std::format("{} {}", error.domain.UTF8String ?: "", int64_t(error.code));
}
else if (![response isKindOfClass:[NSHTTPURLResponse class]])
{
result.transport = Transport::Garbled;
result.detail = "not an HTTP answer";
}
else if (data.length > maxBytes)
{
result.transport = Transport::TooLarge;
result.detail = std::format("{} bytes", uint64_t(data.length));
}
else
{
NSHTTPURLResponse* http = (NSHTTPURLResponse*)response;
result.transport = Transport::Ok;
result.response.status = int(http.statusCode);
for (NSString* key in http.allHeaderFields)
{
id value = http.allHeaderFields[key];
if (![key isKindOfClass:[NSString class]] || ![value isKindOfClass:[NSString class]])
continue;
std::string name = key.lowercaseString.UTF8String ?: "";
result.response.headers.emplace_back(std::move(name), [(NSString*)value UTF8String] ?: "");
}
if (data.length)
result.response.body.assign(static_cast<const char*>(data.bytes), data.length);
}
std::lock_guard lock(pending->mutex);
pending->result = std::move(result);
pending->finished = true;
pending->done.notify_all();
}] retain];
[task resume];
}
Fetched result;
bool finished;
{
std::unique_lock lock(pending->mutex);
finished = pending->done.wait_for(lock, std::chrono::duration<double>(request.timeoutSeconds + 2.0), [&] { return pending->finished; });
if (finished)
result = std::move(pending->result);
}
if (finished)
[session finishTasksAndInvalidate];
else
{
[task cancel];
[session invalidateAndCancel];
result.transport = Transport::Timeout;
result.detail = "no answer by the deadline";
}
[task release];
[session release];
return result;
}
}
+242
View File
@@ -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
}
}
+66
View File
@@ -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();
}
+14
View File
@@ -0,0 +1,14 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// macOS: the system file dialogs SDL shows (open panels, as sheets on the
// game's window), for automated runs of the installer screen.
#pragma once
namespace platform::file_dialog
{
// Cancels every file dialog on screen, as its Cancel button would: SDL's
// callback then gets the cancel, as from the player. Main thread only.
// Returns how many there were. (NFSMW_TEST_DIALOG_CANCEL in
// ui/installer_screen.cpp; macOS only.)
int CancelOpenPanels();
}
+52
View File
@@ -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();
}
+238
View File
@@ -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;
}
}
}
+50
View File
@@ -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);
}
+84
View File
@@ -0,0 +1,84 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// A requested termination ends the game the way its window's close button
// does: main.cpp's quit path (settings saved, the renderer, UI and window
// shut down, the session's crash marker cleared, the log flushed, exit code 0).
// - SIGTERM: a shutdown or logout (systemd), `kill`, and the way a launcher
// such as Steam asks a game to end (Steam's own reaper only forks, waits
// and reaps: the stop comes from the Steam client; SIGKILL, which can't
// be caught, is a stop that doesn't wait);
// - SIGINT: Ctrl+C in the terminal the game was started from;
// - SIGHUP: that terminal closed.
// Until v0.1.6 SDL turned SIGTERM and SIGINT into its quit event (SDL puts its
// handler only where a signal's action is still the default) once the window
// was up, and SIGHUP, or SIGTERM before the window, ended the game on the
// spot: no settings saved, and the crash marker left as by a crash
// (report/crash_marker.h).
//
// The handler only records the first request (async-signal-safe); the main
// thread's frame loop (video::RunFrame, which also runs the installer and the
// startup error page) reads it and leaves; with no window (NFSMW_HEADLESS=1)
// main() waits for it. Another request while quitting changes nothing, as
// SDL's didn't. A signal the game was started with set to be ignored
// (nohup's SIGHUP) stays ignored. SIGKILL can't be caught: a session it ends
// keeps its crash marker, and the next launch logs that it ended without
// quitting.
//
// Header only (tests/clean_exit_test.cpp).
#pragma once
#include <atomic>
#include <csignal>
namespace quitsignals
{
namespace detail
{
inline std::atomic<int> g_requested{ 0 };
static_assert(std::atomic<int>::is_always_lock_free, "the handler needs a lock-free atomic");
inline void Handle(int sig)
{
int none = 0;
g_requested.compare_exchange_strong(none, sig, std::memory_order_relaxed);
}
}
// The signals a player or the system ends the game with.
inline constexpr int kSignals[] = { SIGTERM, SIGINT, SIGHUP };
// Main thread, before SDL starts (video::Initialize), so SDL leaves these
// signals to us.
inline void Install()
{
for (int sig : kSignals)
{
struct sigaction old{};
sigaction(sig, nullptr, &old);
if (!(old.sa_flags & SA_SIGINFO) && old.sa_handler == SIG_IGN)
continue;
struct sigaction sa{};
sa.sa_handler = detail::Handle;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(sig, &sa, nullptr);
}
}
// The signal that asked the game to quit, 0 if none has. Any thread.
inline int Requested()
{
return detail::g_requested.load(std::memory_order_relaxed);
}
// For the log line.
inline const char* Name(int sig)
{
switch (sig)
{
case SIGTERM: return "SIGTERM";
case SIGINT: return "SIGINT";
case SIGHUP: return "SIGHUP";
default: return "a signal";
}
}
}
+35 -22
View File
@@ -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);
+82
View File
@@ -0,0 +1,82 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// A session's crash marker, logs/.crash-pending-<session> (crash.cpp): made
// empty at startup and kept open for the crash handler, which writes the
// crash report into it and renames it crash-<session>.txt. A session that
// ends the way it is meant to (the window's close button, Quit Game, Cmd+Q,
// a requested termination: platform/quit_signals.h) removes it, so a marker
// left in the logs folder is a session that hasn't ended or didn't end
// cleanly: empty, it was stopped from outside (SIGKILL, the power went, the
// system ended it) or crashed too hard to report; written, the handler's
// rename didn't happen. The next launch reports what it finds (crash.cpp's
// NoticePreviousCrash). Until v0.1.6 every session left its empty marker,
// even after Quit Game, until the next launch removed it, so a marker after
// the game had gone (a Steam Frame session its player closed from Steam,
// 2026-10-15) said nothing about how it ended.
//
// POSIX file calls only. Header only (tests/clean_exit_test.cpp).
#pragma once
#include <cstdint>
#include <filesystem>
#include <string>
#include <system_error>
#include <vector>
#include <fcntl.h>
#include <unistd.h>
namespace report::marker
{
inline constexpr const char kPrefix[] = ".crash-pending-";
inline std::filesystem::path PathFor(const std::filesystem::path& dir, const std::string& session)
{
return dir / (kPrefix + session);
}
// Startup: the session's empty marker, open for the crash handler (-1:
// it couldn't be made).
inline int Arm(const char* path)
{
return open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
}
// The session ended cleanly: the marker closed and removed.
inline void Clear(int fd, const char* path)
{
if (fd >= 0)
close(fd);
unlink(path);
}
// An earlier session's marker, as a launch finds it.
enum class Ended
{
WithoutQuitting, // empty: stopped from outside, or no report could be written
Crashed, // the handler's report (it couldn't rename it)
};
struct Leftover
{
std::filesystem::path path;
std::string session;
Ended ended;
};
// The markers in `dir` other than the `current` session's.
inline std::vector<Leftover> Find(const std::filesystem::path& dir, const std::string& current)
{
std::vector<Leftover> found;
std::error_code ec;
for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
{
const std::string name = it->path().filename().string();
if (!name.starts_with(kPrefix) || name == kPrefix + current)
continue;
std::error_code sizeError;
const uintmax_t size = std::filesystem::file_size(it->path(), sizeError);
found.push_back({ it->path(), name.substr(sizeof(kPrefix) - 1),
(size == 0 || sizeError) ? Ended::WithoutQuitting : Ended::Crashed });
}
return found;
}
}
+10 -2
View File
@@ -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) ---
+2 -1
View File
@@ -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;
}
+4
View File
@@ -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)
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
# AMD FidelityFX Super Resolution 1 (vendored)
`ffx_a.h` and `ffx_fsr1.h` from https://github.com/GPUOpen-Effects/FidelityFX-FSR at commit
a21ffb8f6c13233ba336352bdff293894c706575 (2021-11-15; its `ffx-fsr/` folder), unmodified, MIT
license (`LICENSE.txt`, the repository's `license.txt` as it is; also
`LICENSES/FidelityFX-FSR-MIT.txt`).
SHA-256:
f60e2722fcd13989523b9164d776ab382b3692791767f3bf8bb19967f763f3fb ffx_a.h
93c3922362ea7fc99cbcc698ca30c98de4f8c246d1fbb0b09e015ddef38ce3a5 ffx_fsr1.h
db089274ce766da70f5b7d791029c3486f9f9e27c8c79c652689603d3192e802 LICENSE.txt
Used by Settings > Graphics > Upscaling's FSR 1 (`runtime/video/fsr1.cpp`): their 32-bit GLSL
EASU and RCAS functions are compiled into the presenter's shaders (the build embeds both files'
text, `generated/fsr1_headers.inc`), and their CPU code computes the shaders' constants.
+2656
View File
File diff suppressed because it is too large. Load diff
+1199
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
QR Code generator library (C), version 1.8.0
https://www.nayuki.io/page/qr-code-generator-library
https://github.com/nayuki/QR-Code-generator (tag v1.8.0, commit 720f62bddb7226106071d4728c292cb1df519ceb)
Copyright (c) 2022 Project Nayuki. (MIT License)
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.
File diff suppressed because it is too large. Load diff
+385
View File
@@ -0,0 +1,385 @@
/*
* QR Code generator library (C)
*
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* 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.
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* This library creates QR Code symbols, which is a type of two-dimension barcode.
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
* A QR Code structure is an immutable square grid of dark and light cells.
* The library provides functions to create a QR Code from text or binary data.
* The library covers the QR Code Model 2 specification, supporting all versions (sizes)
* from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
*
* Ways to create a QR Code object:
* - High level: Take the payload data and call qrcodegen_encodeText() or qrcodegen_encodeBinary().
* - Low level: Custom-make the list of segments and call
* qrcodegen_encodeSegments() or qrcodegen_encodeSegmentsAdvanced().
* (Note that all ways require supplying the desired error correction level and various byte buffers.)
*/
/*---- Enum and struct types----*/
/*
* The error correction level in a QR Code symbol.
*/
enum qrcodegen_Ecc {
// Must be declared in ascending order of error protection
// so that an internal qrcodegen function works properly
qrcodegen_Ecc_LOW = 0 , // The QR Code can tolerate about 7% erroneous codewords
qrcodegen_Ecc_MEDIUM , // The QR Code can tolerate about 15% erroneous codewords
qrcodegen_Ecc_QUARTILE, // The QR Code can tolerate about 25% erroneous codewords
qrcodegen_Ecc_HIGH , // The QR Code can tolerate about 30% erroneous codewords
};
/*
* The mask pattern used in a QR Code symbol.
*/
enum qrcodegen_Mask {
// A special value to tell the QR Code encoder to
// automatically select an appropriate mask pattern
qrcodegen_Mask_AUTO = -1,
// The eight actual mask patterns
qrcodegen_Mask_0 = 0,
qrcodegen_Mask_1,
qrcodegen_Mask_2,
qrcodegen_Mask_3,
qrcodegen_Mask_4,
qrcodegen_Mask_5,
qrcodegen_Mask_6,
qrcodegen_Mask_7,
};
/*
* Describes how a segment's data bits are interpreted.
*/
enum qrcodegen_Mode {
qrcodegen_Mode_NUMERIC = 0x1,
qrcodegen_Mode_ALPHANUMERIC = 0x2,
qrcodegen_Mode_BYTE = 0x4,
qrcodegen_Mode_KANJI = 0x8,
qrcodegen_Mode_ECI = 0x7,
};
/*
* A segment of character/binary/control data in a QR Code symbol.
* The mid-level way to create a segment is to take the payload data
* and call a factory function such as qrcodegen_makeNumeric().
* The low-level way to create a segment is to custom-make the bit buffer
* and initialize a qrcodegen_Segment struct with appropriate values.
* Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
* Any segment longer than this is meaningless for the purpose of generating QR Codes.
* Moreover, the maximum allowed bit length is 32767 because
* the largest QR Code (version 40) has 31329 modules.
*/
struct qrcodegen_Segment {
// The mode indicator of this segment.
enum qrcodegen_Mode mode;
// The length of this segment's unencoded data. Measured in characters for
// numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
// Always zero or positive. Not the same as the data's bit length.
int numChars;
// The data bits of this segment, packed in bitwise big endian.
// Can be null if the bit length is zero.
uint8_t *data;
// The number of valid data bits used in the buffer. Requires
// 0 <= bitLength <= 32767, and bitLength <= (capacity of data array) * 8.
// The character count (numChars) must agree with the mode and the bit buffer length.
int bitLength;
};
/*---- Macro constants and functions ----*/
#define qrcodegen_VERSION_MIN 1 // The minimum version number supported in the QR Code Model 2 standard
#define qrcodegen_VERSION_MAX 40 // The maximum version number supported in the QR Code Model 2 standard
// Calculates the number of bytes needed to store any QR Code up to and including the given version number,
// as a compile-time constant. For example, 'uint8_t buffer[qrcodegen_BUFFER_LEN_FOR_VERSION(25)];'
// can store any single QR Code from version 1 to 25 (inclusive). The result fits in an int (or int16).
// Requires qrcodegen_VERSION_MIN <= n <= qrcodegen_VERSION_MAX.
#define qrcodegen_BUFFER_LEN_FOR_VERSION(n) ((((n) * 4 + 17) * ((n) * 4 + 17) + 7) / 8 + 1)
// The worst-case number of bytes needed to store one QR Code, up to and including
// version 40. This value equals 3918, which is just under 4 kilobytes.
// Use this more convenient value to avoid calculating tighter memory bounds for buffers.
#define qrcodegen_BUFFER_LEN_MAX qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX)
/*---- Functions (high level) to generate QR Codes ----*/
/*
* Encodes the given text string to a QR Code, returning true if successful.
* If the data is too long to fit in any version in the given range
* at the given ECC level, then false is returned.
*
* The input text must be encoded in UTF-8 and contain no NULs.
* Requires 1 <= minVersion <= maxVersion <= 40.
*
* The smallest possible QR Code version within the given range is automatically
* chosen for the output. Iff boostEcl is true, then the ECC level of the result
* may be higher than the ecl argument if it can be done without increasing the
* version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or
* qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow).
*
* About the arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion):
* - Before calling the function:
* - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow
* reading and writing; hence each array must have a length of at least len.
* - The two ranges must not overlap (aliasing).
* - The initial state of both ranges can be uninitialized
* because the function always writes before reading.
* - After the function returns:
* - Both ranges have no guarantee on which elements are initialized and what values are stored.
* - tempBuffer contains no useful data and should be treated as entirely uninitialized.
* - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule().
*
* If successful, the resulting QR Code may use numeric,
* alphanumeric, or byte mode to encode the text.
*
* In the most optimistic case, a QR Code at version 40 with low ECC
* can hold any UTF-8 string up to 2953 bytes, or any alphanumeric string
* up to 4296 characters, or any digit string up to 7089 characters.
* These numbers represent the hard upper limit of the QR Code standard.
*
* Please consult the QR Code specification for information on
* data capacities per version, ECC level, and text encoding mode.
*/
bool qrcodegen_encodeText(const char *text, uint8_t tempBuffer[], uint8_t qrcode[],
enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl);
/*
* Encodes the given binary data to a QR Code, returning true if successful.
* If the data is too long to fit in any version in the given range
* at the given ECC level, then false is returned.
*
* Requires 1 <= minVersion <= maxVersion <= 40.
*
* The smallest possible QR Code version within the given range is automatically
* chosen for the output. Iff boostEcl is true, then the ECC level of the result
* may be higher than the ecl argument if it can be done without increasing the
* version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or
* qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow).
*
* About the arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion):
* - Before calling the function:
* - The array ranges dataAndTemp[0 : len] and qrcode[0 : len] must allow
* reading and writing; hence each array must have a length of at least len.
* - The two ranges must not overlap (aliasing).
* - The input array range dataAndTemp[0 : dataLen] should normally be
* valid UTF-8 text, but is not required by the QR Code standard.
* - The initial state of dataAndTemp[dataLen : len] and qrcode[0 : len]
* can be uninitialized because the function always writes before reading.
* - After the function returns:
* - Both ranges have no guarantee on which elements are initialized and what values are stored.
* - dataAndTemp contains no useful data and should be treated as entirely uninitialized.
* - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule().
*
* If successful, the resulting QR Code will use byte mode to encode the data.
*
* In the most optimistic case, a QR Code at version 40 with low ECC can hold any byte
* sequence up to length 2953. This is the hard upper limit of the QR Code standard.
*
* Please consult the QR Code specification for information on
* data capacities per version, ECC level, and text encoding mode.
*/
bool qrcodegen_encodeBinary(uint8_t dataAndTemp[], size_t dataLen, uint8_t qrcode[],
enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl);
/*---- Functions (low level) to generate QR Codes ----*/
/*
* Encodes the given segments to a QR Code, returning true if successful.
* If the data is too long to fit in any version at the given ECC level,
* then false is returned.
*
* The smallest possible QR Code version is automatically chosen for
* the output. The ECC level of the result may be higher than the
* ecl argument if it can be done without increasing the version.
*
* About the byte arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX):
* - Before calling the function:
* - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow
* reading and writing; hence each array must have a length of at least len.
* - The two ranges must not overlap (aliasing).
* - The initial state of both ranges can be uninitialized
* because the function always writes before reading.
* - The input array segs can contain segments whose data buffers overlap with tempBuffer.
* - After the function returns:
* - Both ranges have no guarantee on which elements are initialized and what values are stored.
* - tempBuffer contains no useful data and should be treated as entirely uninitialized.
* - Any segment whose data buffer overlaps with tempBuffer[0 : len]
* must be treated as having invalid values in that array.
* - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule().
*
* Please consult the QR Code specification for information on
* data capacities per version, ECC level, and text encoding mode.
*
* This function allows the user to create a custom sequence of segments that switches
* between modes (such as alphanumeric and byte) to encode text in less space.
* This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary().
*/
bool qrcodegen_encodeSegments(const struct qrcodegen_Segment segs[], size_t len,
enum qrcodegen_Ecc ecl, uint8_t tempBuffer[], uint8_t qrcode[]);
/*
* Encodes the given segments to a QR Code, returning true if successful.
* If the data is too long to fit in any version in the given range
* at the given ECC level, then false is returned.
*
* Requires 1 <= minVersion <= maxVersion <= 40.
*
* The smallest possible QR Code version within the given range is automatically
* chosen for the output. Iff boostEcl is true, then the ECC level of the result
* may be higher than the ecl argument if it can be done without increasing the
* version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or
* qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow).
*
* About the byte arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX):
* - Before calling the function:
* - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow
* reading and writing; hence each array must have a length of at least len.
* - The two ranges must not overlap (aliasing).
* - The initial state of both ranges can be uninitialized
* because the function always writes before reading.
* - The input array segs can contain segments whose data buffers overlap with tempBuffer.
* - After the function returns:
* - Both ranges have no guarantee on which elements are initialized and what values are stored.
* - tempBuffer contains no useful data and should be treated as entirely uninitialized.
* - Any segment whose data buffer overlaps with tempBuffer[0 : len]
* must be treated as having invalid values in that array.
* - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule().
*
* Please consult the QR Code specification for information on
* data capacities per version, ECC level, and text encoding mode.
*
* This function allows the user to create a custom sequence of segments that switches
* between modes (such as alphanumeric and byte) to encode text in less space.
* This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary().
*/
bool qrcodegen_encodeSegmentsAdvanced(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl,
int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl, uint8_t tempBuffer[], uint8_t qrcode[]);
/*
* Tests whether the given string can be encoded as a segment in numeric mode.
* A string is encodable iff each character is in the range 0 to 9.
*/
bool qrcodegen_isNumeric(const char *text);
/*
* Tests whether the given string can be encoded as a segment in alphanumeric mode.
* A string is encodable iff each character is in the following set: 0 to 9, A to Z
* (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
*/
bool qrcodegen_isAlphanumeric(const char *text);
/*
* Returns the number of bytes (uint8_t) needed for the data buffer of a segment
* containing the given number of characters using the given mode. Notes:
* - Returns SIZE_MAX on failure, i.e. numChars > INT16_MAX or the internal
* calculation of the number of needed bits exceeds INT16_MAX (i.e. 32767).
* - Otherwise, all valid results are in the range [0, ceil(INT16_MAX / 8)], i.e. at most 4096.
* - It is okay for the user to allocate more bytes for the buffer than needed.
* - For byte mode, numChars measures the number of bytes, not Unicode code points.
* - For ECI mode, numChars must be 0, and the worst-case number of bytes is returned.
* An actual ECI segment can have shorter data. For non-ECI modes, the result is exact.
*/
size_t qrcodegen_calcSegmentBufferSize(enum qrcodegen_Mode mode, size_t numChars);
/*
* Returns a segment representing the given binary data encoded in
* byte mode. All input byte arrays are acceptable. Any text string
* can be converted to UTF-8 bytes and encoded as a byte mode segment.
*/
struct qrcodegen_Segment qrcodegen_makeBytes(const uint8_t data[], size_t len, uint8_t buf[]);
/*
* Returns a segment representing the given string of decimal digits encoded in numeric mode.
*/
struct qrcodegen_Segment qrcodegen_makeNumeric(const char *digits, uint8_t buf[]);
/*
* Returns a segment representing the given text string encoded in alphanumeric mode.
* The characters allowed are: 0 to 9, A to Z (uppercase only), space,
* dollar, percent, asterisk, plus, hyphen, period, slash, colon.
*/
struct qrcodegen_Segment qrcodegen_makeAlphanumeric(const char *text, uint8_t buf[]);
/*
* Returns a segment representing an Extended Channel Interpretation
* (ECI) designator with the given assignment value.
*/
struct qrcodegen_Segment qrcodegen_makeEci(long assignVal, uint8_t buf[]);
/*---- Functions to extract raw data from QR Codes ----*/
/*
* Returns the side length of the given QR Code, assuming that encoding succeeded.
* The result is in the range [21, 177]. Note that the length of the array buffer
* is related to the side length - every 'uint8_t qrcode[]' must have length at least
* qrcodegen_BUFFER_LEN_FOR_VERSION(version), which equals ceil(size^2 / 8 + 1).
*/
int qrcodegen_getSize(const uint8_t qrcode[]);
/*
* Returns the color of the module (pixel) at the given coordinates, which is false
* for light or true for dark. The top left corner has the coordinates (x=0, y=0).
* If the given coordinates are out of bounds, then false (light) is returned.
*/
bool qrcodegen_getModule(const uint8_t qrcode[], int x, int y);
#ifdef __cplusplus
}
#endif
+57
View File
@@ -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;
}
};
}
+411 -32
View File
@@ -10,6 +10,8 @@
#include <install/installer.h>
#include <install/locate.h>
#include <install/placement.h>
#include <video/presenter.h>
#include <imgui.h>
#include <imgui_internal.h>
@@ -23,6 +25,8 @@
#if defined(__APPLE__) && TARGET_OS_IOS
#include <platform/ios_files.h>
#elif defined(__APPLE__)
#include <platform/file_dialog.h>
#endif
namespace ui
@@ -38,7 +42,14 @@ namespace ui
std::optional<std::string> s_dialogPick;
uint64_t s_pickAccess = 0; // iOS: the Files app's grant to read s_dialogPick (ios_files.h)
bool s_dialogFailed = false;
std::string s_dialogError; // SDL's word on why, when s_dialogFailed
bool s_dialogCancelled = false; // the player closed the dialog without choosing
std::string s_dialogNotice; // iOS: the pick isn't on the device yet; what to do instead
// What the latest dialog is for: the disc image or folder to install
// from (the Choose page), or the folder to install into (Confirm's
// "Choose a folder...").
enum class DialogFor { Source, Destination };
DialogFor s_dialogFor = DialogFor::Source;
// Gives back a grant to read a picked file (iOS; elsewhere there are
// none, and `access` is 0).
@@ -69,13 +80,19 @@ namespace ui
else if (!files)
{
s_dialogFailed = true;
fprintf(stderr, "[installer] file dialog failed: %s\n", SDL_GetError());
s_dialogError = SDL_GetError();
fprintf(stderr, "[installer] file dialog failed: %s\n", s_dialogError.c_str());
}
else
{
s_dialogCancelled = true;
fprintf(stderr, "[installer] file dialog cancelled\n");
}
}
#endif
// Opens a file (or folder) dialog for a new request.
void Browse(bool folder)
void Browse(bool folder, DialogFor purpose = DialogFor::Source)
{
void* request;
{
@@ -83,7 +100,10 @@ namespace ui
request = reinterpret_cast<void*>(++s_dialogRequest);
ForgetPick();
s_dialogFailed = false;
s_dialogError.clear();
s_dialogCancelled = false;
s_dialogNotice.clear();
s_dialogFor = purpose;
}
#if defined(__APPLE__) && TARGET_OS_IOS
// SDL has no file dialog on iOS: the Files app's document picker,
@@ -114,7 +134,28 @@ namespace ui
}
#else
static const SDL_DialogFileFilter filters[] = { { "Xbox 360 disc image", "iso" }, { "All files", "*" } };
if (folder)
if (purpose == DialogFor::Destination)
{
// Where to install: titled as such (where the platform shows a
// title: the portal's and zenity's windows; macOS's panel shows
// the accept label). Not the accept label elsewhere: SDL hands
// it to zenity as --ok-label, which zenity 3.44 (SteamOS)
// 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());
// 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__
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_ACCEPT_STRING, "Choose");
#endif
SDL_ShowFileDialogWithProperties(SDL_FILEDIALOG_OPENFOLDER, OnDialog, request, props);
SDL_DestroyProperties(props);
}
else if (folder)
SDL_ShowOpenFolderDialog(OnDialog, request, GetWindow(), std::getenv("HOME"), false);
else
SDL_ShowOpenFileDialog(OnDialog, request, GetWindow(), filters, 2, std::getenv("HOME"), false);
@@ -127,6 +168,9 @@ namespace ui
std::lock_guard lock(s_dialogMutex);
++s_dialogRequest;
ForgetPick();
s_dialogFailed = false;
s_dialogError.clear();
s_dialogCancelled = false;
}
std::string FileName(const std::filesystem::path& path)
@@ -251,6 +295,48 @@ namespace ui
"the keyboard).";
#endif
#if !(defined(__APPLE__) && TARGET_OS_IOS)
// A path to type in a terminal: ~ for home, quoted (and absolute) if
// it has a space or another character the shell would split at.
std::string ShellPath(const std::filesystem::path& path)
{
std::string text = path.string();
if (text.find_first_of(" \t'\"$`\\;&|<>()*?[]!#") == std::string::npos)
return HomeRelative(path);
std::string quoted = "'";
for (char c : text)
quoted += c == '\'' ? std::string("'\\''") : std::string(1, c);
return quoted + "'";
}
// The hint when the folder picker can't be shown (install::FolderPickerHint),
// with the command for this install: the bundle's launcher or the Mac
// app's binary, and the image already chosen. Game Mode is SteamOS's
// (on a Mac only NFSMW_TEST_PICK_FOLDER=gamemode gets there): its
// words and launcher wherever it shows.
// `noPickerApp`: SDL found neither a file chooser portal nor zenity.
std::string FolderPickerHint(bool gameMode, const std::filesystem::path& source, bool noPickerApp = false)
{
std::filesystem::path bundle = install::BundleFolder();
std::string image = source.empty() ? std::string("<image>") : ShellPath(source);
std::error_code ec;
std::string launcher = !bundle.empty() && std::filesystem::exists(bundle / "speedbreaker.sh", ec)
? ShellPath(bundle / "speedbreaker.sh") : std::string("speedbreaker.sh");
#ifdef __APPLE__
if (!gameMode)
return install::FolderPickerHint(false, true, false, bundle.extension() == ".app"
? ShellPath(bundle / "Contents/MacOS/SpeedBreaker") : std::string("SpeedBreaker"), image);
#endif
return install::FolderPickerHint(gameMode, false, noPickerApp, launcher, image);
}
#else
// iOS: no "Choose a folder..." (the game stays in the app's storage).
std::string FolderPickerHint(bool, const std::filesystem::path&, bool = false)
{
return {};
}
#endif
const char* StatusText(const install::DiscImageCandidate& c)
{
using install::Error;
@@ -365,16 +451,52 @@ namespace ui
std::filesystem::path destination = install::DefaultInstallPath();
// Where to install: internal storage or a removable drive, each
// checked (space, permissions) when the Confirm page opens.
// checked (space, permissions) when the Confirm page opens, then
// (not on iOS, whose app keeps the game in its own storage, nor yet
// on Windows: install/placement.h) a folder the player picks.
struct Destination
{
install::InstallDestination where;
install::InstallDestination where; // the custom entry's path: where in the picked folder the game goes
install::Result check;
install::DestinationInfo info;
bool custom = false; // "Choose a folder..."
install::FolderChoice choice; // custom, once a folder is picked
};
std::vector<Destination> destinations;
size_t chosen = 0;
// "Choose a folder...": the folder picked (kept across visits to the
// Confirm page), or the one recorded last time when it isn't one of
// the others (`exact`: offered as it is, no subfolder).
std::optional<std::filesystem::path> customFolder;
bool customExact = false;
bool preferCustom = false; // the player picked it: keep it chosen
enum class Pick { Idle, Waiting, Unavailable };
Pick pick = Pick::Idle; // the folder picker: open, or couldn't be shown (pickHint says why)
std::string pickHint;
// The system's picker asked for (not the test hook's), and when:
// NFSMW_TEST_DIALOG_CANCEL cancels it after that many seconds (macOS).
bool pickShown = false;
std::chrono::steady_clock::time_point pickStart{};
Destination CustomDestination()
{
Destination d;
d.custom = true;
d.where.label = "Choose a folder...";
if (!customFolder)
{
d.check.error = install::Error::DestinationNotWritable; // nothing chosen yet (not shown as an error)
return d;
}
d.choice = customExact ? install::CheckExactFolder(*customFolder, false) : install::CheckPickedFolder(*customFolder);
d.where.label = customExact ? "Folder chosen last time" : "Chosen folder";
d.where.path = d.choice.gameDir;
d.check = d.choice.result;
d.info = d.choice.info;
return d;
}
void LoadDestinations()
{
destinations.clear();
@@ -384,13 +506,35 @@ namespace ui
d.check = install::CheckDestination(where.path, &d.info);
destinations.push_back(std::move(d));
}
// The one used last time if it still works, else internal storage,
// else the first drive that works.
chosen = 0;
std::optional<std::filesystem::path> recorded = install::RecordedInstallPath();
#if !(defined(__APPLE__) && TARGET_OS_IOS) && !defined(_WIN32)
// A folder chosen last time (not internal storage or a drive's
// own folder) comes back as the chosen folder, as it is. The same
// folder however it was spelled (`--dest dir/`, or through a link).
if (!customFolder && recorded && std::none_of(destinations.begin(), destinations.end(),
[&](const Destination& d) { return install::SameFolder(d.where.path, *recorded); }))
{
customFolder = *recorded;
customExact = true;
}
destinations.push_back(CustomDestination());
#endif
// The folder the player just picked, else the one used last time,
// if it still works; else internal storage, else the first that works.
auto preferred = [&](const Destination& d) {
if (!d.check.Ok())
return false;
if (preferCustom)
return d.custom;
return recorded && install::SameFolder(d.where.path, *recorded);
};
chosen = 0;
for (size_t i = 0; i < destinations.size(); i++)
if (recorded && destinations[i].where.path == *recorded && destinations[i].check.Ok())
if (preferred(destinations[i]))
{
chosen = i;
break;
}
if (!destinations[chosen].check.Ok())
for (size_t i = 0; i < destinations.size(); i++)
if (destinations[i].check.Ok())
@@ -401,6 +545,104 @@ namespace ui
destination = destinations[chosen].where.path;
}
// The player picked `folder` for "Choose a folder...": check it and choose it.
void PickFolder(const std::filesystem::path& folder)
{
customFolder = folder;
customExact = false;
preferCustom = true;
pick = Pick::Idle;
pickShown = false;
for (size_t i = 0; i < destinations.size(); i++)
if (destinations[i].custom)
{
destinations[i] = CustomDestination();
chosen = i;
const install::FolderChoice& c = destinations[i].choice;
fprintf(stderr, "[installer] folder picked: %s; the game would go to %s%s%s\n", folder.string().c_str(),
c.gameDir.string().c_str(), c.result.Ok() ? "" : ": refused, ", c.result.Ok() ? "" : c.result.message.c_str());
}
destination = destinations[chosen].where.path;
focusPage = true;
}
// "Choose a folder...": the system's folder picker, or the hint when
// it can't be shown. Never waits: the answer comes in a later frame.
void RequestFolder()
{
#if !(defined(__APPLE__) && TARGET_OS_IOS) && !defined(_WIN32)
// The system's picker is already open: A again (a controller
// still reaches the page behind a macOS sheet) waits for it. A
// second request would make the open one's answer a stale one,
// and on the Mac queue a second sheet that never comes.
if (pick == Pick::Waiting && pickShown)
{
fprintf(stderr, "[installer] the folder picker is already open\n");
return;
}
pickShown = false;
// NFSMW_TEST_PICK_FOLDER stands in for the picker (automated runs):
// a folder (absolute, or ~/...) is the player's pick; "cancel",
// "fail" (no dialog could open), "gamemode" (as in Game Mode) and
// "wait" (it never answers) are the other ways a picker ends.
// Several, split by |, answer one request each (the last repeats).
if (const char* test = std::getenv("NFSMW_TEST_PICK_FOLDER"); test && *test)
{
static size_t answered = 0;
std::string_view rest = test;
std::string value;
for (size_t i = 0; i <= answered && !rest.empty(); i++)
{
size_t bar = rest.find('|');
value = std::string(rest.substr(0, bar));
rest = bar == std::string_view::npos ? std::string_view() : rest.substr(bar + 1);
}
answered++;
fprintf(stderr, "[installer] NFSMW_TEST_PICK_FOLDER stands in for the folder picker: %s\n", value.c_str());
if (value.starts_with("~/"))
if (const char* home = std::getenv("HOME"))
value = std::string(home) + value.substr(1);
if (value == "gamemode" || value == "fail")
{
DropDialog();
pick = Pick::Unavailable;
pickHint = FolderPickerHint(value == "gamemode", sourcePath);
}
else if (value == "cancel")
{
DropDialog();
pick = Pick::Idle;
}
else
{
std::lock_guard lock(s_dialogMutex);
++s_dialogRequest;
ForgetPick();
s_dialogFailed = s_dialogCancelled = false;
s_dialogFor = DialogFor::Destination;
pick = Pick::Waiting;
if (value != "wait")
s_dialogPick = value;
}
return;
}
if (video::GameMode())
{
// Nothing of the desktop shows under gamescope: a portal's
// window would open where the player can't see or reach it.
DropDialog();
pick = Pick::Unavailable;
pickHint = FolderPickerHint(true, sourcePath);
fprintf(stderr, "[installer] Game Mode: the folder picker isn't tried (%s)\n", pickHint.c_str());
return;
}
pick = Pick::Waiting;
pickShown = true;
pickStart = std::chrono::steady_clock::now();
Browse(true, DialogFor::Destination);
#endif
}
// Progress, written by the install thread.
std::mutex progressMutex;
install::Progress progress;
@@ -424,6 +666,25 @@ namespace ui
void Go(Page next)
{
// Off the Confirm page: a folder picker still open answers nothing.
// On the Mac it closes (it is a sheet on the game's window, and
// would stay over the next page); elsewhere it is a window of its
// own that SDL can't close, so the Choose page says so.
if (page == Page::Confirm && next != Page::Confirm && pick != Pick::Idle)
{
const bool shown = pick == Pick::Waiting && pickShown;
DropDialog();
pick = Pick::Idle;
pickShown = false;
#if defined(__APPLE__) && !TARGET_OS_IOS
if (shown)
fprintf(stderr, "[installer] closed %d folder picker(s) on leaving the page\n",
platform::file_dialog::CancelOpenPanels());
#else
if (shown && next == Page::Choose)
notice = "The folder picker is still open: close it. A folder chosen in it now isn't used.";
#endif
}
page = next;
focusPage = true;
if (next == Page::Choose || next == Page::Done || (next == Page::Failed && !source))
@@ -531,10 +792,19 @@ namespace ui
InstallerScreen::InstallerScreen() : state(std::make_unique<State>())
{
state->StartScan();
// Installed on a drive that isn't there now (FindGameInstall passed it over).
if (std::optional<std::filesystem::path> recorded = install::RecordedInstallPath())
state->notice = "The game is installed in " + HomeRelative(*recorded) +
", which isn't available: insert that drive and start the game again, or install it again here.";
// Installed in a folder FindGameInstall passed over: a drive that
// isn't connected, a folder macOS won't let the game read, or an
// install that is incomplete or older. Said at the top of the page,
// not left for the player to guess from the installer appearing.
install::RecordedInstall recorded = install::CheckRecordedInstall();
#if defined(__APPLE__) && !TARGET_OS_IOS
constexpr bool macOS = true;
#else
constexpr bool macOS = false;
#endif
state->notice = install::DescribeRecordedInstall(recorded, HomeRelative(recorded.path), macOS);
if (!state->notice.empty())
fprintf(stderr, "[installer] %s\n", state->notice.c_str());
}
InstallerScreen::~InstallerScreen()
@@ -587,30 +857,81 @@ namespace ui
st.Go(Page::Failed);
}
{
std::optional<std::string> pick;
std::optional<std::string> pick, folder;
uint64_t access = 0;
{
std::lock_guard lock(s_dialogMutex);
if (st.page == Page::Choose)
if (s_dialogFor == DialogFor::Destination)
{
pick = std::move(s_dialogPick);
s_dialogPick.reset();
access = std::exchange(s_pickAccess, 0);
// "Choose a folder..." on the Confirm page (Go drops the
// dialog when the player leaves it).
if (st.page == Page::Confirm)
{
folder = std::move(s_dialogPick);
s_dialogPick.reset();
if (s_dialogFailed)
{
// SDL's word when it found no portal and no zenity
// (SDL_unixdialog.c); any other failure is logged.
st.pick = State::Pick::Unavailable;
st.pickShown = false;
st.pickHint = FolderPickerHint(false, st.sourcePath,
s_dialogError.starts_with("File dialog driver unsupported"));
}
else if (s_dialogCancelled && st.pick == State::Pick::Waiting)
{
st.pick = State::Pick::Idle;
st.pickShown = false;
}
}
s_dialogFailed = s_dialogCancelled = false;
s_dialogError.clear();
}
if (s_dialogFailed)
else
{
s_dialogFailed = false;
st.notice = kNoDialog;
}
if (!s_dialogNotice.empty())
{
st.notice = std::move(s_dialogNotice);
s_dialogNotice.clear();
if (st.page == Page::Choose)
{
pick = std::move(s_dialogPick);
s_dialogPick.reset();
access = std::exchange(s_pickAccess, 0);
}
if (s_dialogFailed)
{
s_dialogFailed = false;
st.notice = kNoDialog;
}
s_dialogCancelled = false;
if (!s_dialogNotice.empty())
{
st.notice = std::move(s_dialogNotice);
s_dialogNotice.clear();
}
}
}
if (pick)
st.StartCheck(*pick, access);
if (folder)
st.PickFolder(*folder);
}
#if defined(__APPLE__) && !TARGET_OS_IOS
// NFSMW_TEST_DIALOG_CANCEL=<seconds> (automated runs): the system's
// folder picker, open that long, is cancelled as its Cancel button
// would (its answer then comes as the player's would).
if (st.page == Page::Confirm && st.pick == State::Pick::Waiting && st.pickShown)
{
static const double cancelAfter = [] {
const char* v = std::getenv("NFSMW_TEST_DIALOG_CANCEL");
return v ? std::atof(v) : 0.0;
}();
double open = std::chrono::duration<double>(std::chrono::steady_clock::now() - st.pickStart).count();
if (cancelAfter > 0 && open >= cancelAfter)
{
int n = platform::file_dialog::CancelOpenPanels();
fprintf(stderr, "[installer] NFSMW_TEST_DIALOG_CANCEL: cancelled %d folder picker(s) open for %.1f s\n", n, open);
st.pickShown = false;
}
}
#endif
ImGuiViewport* viewport = ImGui::GetMainViewport();
ImVec2 size(std::min(viewport->WorkSize.x - 2 * kSpaceL * s, 980.0f * s), std::min(viewport->WorkSize.y - 2 * kSpaceL * s, 640.0f * s));
@@ -817,13 +1138,19 @@ namespace ui
};
if (st.destinations.size() > 1)
{
// Internal storage or a removable drive: left/right (or the arrows).
// Internal storage, a removable drive or a folder of the
// player's: left/right (or the arrows).
int delta = 0;
std::string label = describe(dest);
bool focusRow = st.focusPage && !dest.check.Ok();
if (focusRow)
ImGui::SetKeyboardFocusHere();
ImGui::Selectable(("##dest"), false, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, ImGui::GetFrameHeight()));
// A (or Return, or a click) on "Choose a folder..." opens the
// folder picker; moving onto it doesn't (a controller passes
// through it on the way round).
bool activated = ImGui::Selectable(("##dest"), false, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, ImGui::GetFrameHeight()));
if (activated && dest.custom)
st.RequestFolder();
if (focusRow)
st.focusPage = false;
bool focused = ImGui::IsItemFocused();
@@ -865,9 +1192,30 @@ namespace ui
else
ImGui::TextUnformatted(describe(dest).c_str());
const State::Destination& d = st.destinations[st.chosen];
ImGui::TextDisabled("%s", HomeRelative(d.where.path).c_str());
// "Choose a folder..." before a folder is picked (or after one
// was, the picker open again): its own lines, not an error.
const bool unpicked = d.custom && !st.customFolder;
// The exact folder the game goes to, wrapped (not cut off) when long.
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
if (unpicked)
ImGui::TextWrapped("Any folder: the game goes in it, or in a new folder inside it if it holds other files.");
else
ImGui::TextWrapped("%s", HomeRelative(d.where.path).c_str());
ImGui::PopStyleColor();
ImGui::Spacing();
if (!d.check.Ok())
if (d.custom && st.pick == State::Pick::Waiting)
ImGui::TextDisabled("Waiting for the folder picker...");
else if (d.custom && st.pick == State::Pick::Unavailable)
{
PushAccentText();
ImGui::TextWrapped("%s", st.pickHint.c_str());
PopAccentText();
}
else if (unpicked)
{
// Nothing chosen yet: the line above says what happens.
}
else if (!d.check.Ok())
{
PushAccentText();
ImGui::TextWrapped("%s", d.check.message.c_str());
@@ -875,19 +1223,34 @@ namespace ui
}
else
{
if (d.custom)
{
if (std::string why = install::SubfolderExplanation(d.choice); !why.empty())
ImGui::TextWrapped("%s", why.c_str());
for (const std::string& note : d.choice.notes)
ImGui::TextWrapped("%s", note.c_str());
}
ImGui::TextDisabled("%s needed", install::FormatSize(d.info.required).c_str());
if (d.info.replacing)
ImGui::TextWrapped("Replaces the game already there. DLC and other files you added to it are kept.");
ImGui::TextWrapped("This takes a few minutes. Every file is checked against the known-good disc as it is copied.");
}
ButtonRowAtBottom();
// The cursor: Install when it can, else "Choose a folder..." on
// that entry, else Back (nowhere to install).
ImGui::BeginDisabled(!d.check.Ok());
if (d.check.Ok() ? mainButton("Install") : ImGui::Button("Install"))
st.StartInstall();
ImGui::EndDisabled();
if (d.custom)
{
ImGui::SameLine();
const char* label = st.customFolder ? "Choose another folder..." : "Choose a folder...";
if (d.check.Ok() ? ImGui::Button(label) : mainButton(label))
st.RequestFolder();
}
ImGui::SameLine();
// Nowhere to install: the cursor goes to Back.
if (d.check.Ok() ? ImGui::Button("Back") : mainButton("Back"))
if (d.check.Ok() || d.custom ? ImGui::Button("Back") : mainButton("Back"))
st.Go(Page::Choose);
break;
}
@@ -936,6 +1299,22 @@ namespace ui
ImGui::BeginDisabled(st.cancel.load());
if (ImGui::Button(st.cancel ? "Cancelling..." : "Cancel"))
st.cancel = true;
// But any direction lands on it. (ImGui's own navigation starts
// from where the Confirm page's Install was, so only Right
// reached it: a controller pressing down, the natural way to a
// button at the bottom, couldn't cancel at all.)
if (!st.cancel && !ImGui::IsItemFocused())
{
static constexpr ImGuiKey kDirections[] = { ImGuiKey_GamepadDpadUp, ImGuiKey_GamepadDpadDown,
ImGuiKey_GamepadDpadLeft, ImGuiKey_GamepadDpadRight, ImGuiKey_GamepadLStickUp, ImGuiKey_GamepadLStickDown,
ImGuiKey_GamepadLStickLeft, ImGuiKey_GamepadLStickRight, ImGuiKey_UpArrow, ImGuiKey_DownArrow,
ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_Tab };
if (std::any_of(std::begin(kDirections), std::end(kDirections), [](ImGuiKey key) { return ImGui::IsKeyPressed(key, false); }))
{
ImGui::SetFocusID(ImGui::GetItemID(), ImGui::GetCurrentWindow());
ImGui::SetNavCursorVisible(true);
}
}
ImGui::EndDisabled();
break;
}
+154
View File
@@ -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;
};
}
+502 -26
View File
@@ -5,17 +5,28 @@
// left/right changes it, A toggles, LB/RB turn pages, B closes. The mouse
// works too (a click selects a row, its arrows change it, the tabs turn
// 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), the build's
// identity (version and commit) and the legal notice.
// 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 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
{
@@ -29,10 +40,32 @@ namespace ui
bool s_focusFirstRow = true; // put the controller's cursor on the page's first row
Id s_described = Id::Count; // the row the help line describes
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);
@@ -48,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)
{
@@ -63,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);
@@ -173,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;
@@ -232,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())
@@ -243,11 +461,12 @@ namespace ui
}
}
// An action, not an option: A (or a click) saves the report.
void BugReportRow(float valueWidth)
// An action, not an option: a label and what it shows now (no
// arrows). True when A (or a click) pressed it.
bool ActionRow(const char* id, const char* label, const char* value, float valueWidth, bool& described)
{
const float rowHeight = RowHeight();
ImGui::PushID("bug-report");
ImGui::PushID(id);
ImGui::TableNextRow(ImGuiTableRowFlags_None, rowHeight);
ImGui::TableNextColumn();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + LabelInset());
@@ -256,24 +475,119 @@ namespace ui
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap, ImVec2(0, rowHeight));
bool focused = ImGui::IsItemFocused();
if (focused || (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenOverlappedByItem) && s_described == Id::Count))
s_describedBugReport = true;
const float mid = RowLabel(labelPos, "Save Bug Report");
described = true;
const float mid = RowLabel(labelPos, label);
ImGui::TableNextColumn();
float arrow = ImGui::GetFrameHeight();
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (rowHeight - arrow) * 0.5f);
ImVec2 min = ImGui::GetCursorScreenPos();
ImGui::Dummy(ImVec2(valueWidth + arrow * 2, arrow));
const char* text = BugReportValue();
ImVec2 size = ImGui::CalcTextSize(text);
ImVec2 size = ImGui::CalcTextSize(value);
ImGui::GetWindowDrawList()->AddText(ImVec2(min.x + arrow + (valueWidth - size.x) * 0.5f, CentredTextTop(mid)),
ImGui::GetColorU32(focused ? ImGuiCol_PlotLines : ImGuiCol_Text), text);
if (activated)
report::SaveBugReport();
ImGui::GetColorU32(focused ? ImGuiCol_PlotLines : ImGuiCol_Text), value);
ImGui::PopID();
return activated;
}
// A (or a click) saves the report.
void BugReportRow(float valueWidth)
{
if (ActionRow("bug-report", "Save Bug Report", BugReportValue(), valueWidth, s_describedBugReport))
report::SaveBugReport();
}
// A (or a click) asks GitHub, and the dialog shows the answer.
void UpdateRow(float valueWidth)
{
if (ActionRow("check-updates", "Check for Updates", UpdateRowValue(), valueWidth, s_describedUpdate))
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)
{
UpdateRowHelp();
return;
}
if (s_describedBugReport)
{
#if defined(__APPLE__) && TARGET_OS_IOS
@@ -313,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));
@@ -374,20 +719,47 @@ namespace ui
TitleText("Settings", "F1 / Back + Start");
#endif
// Pages: LB/RB (Q/E) or the tabs.
if (int delta = PageDelta())
// Pages: LB/RB (Q/E) or the tabs; not under a dialog, which keeps them.
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);
@@ -423,11 +795,15 @@ 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)
valueWidth = std::max(valueWidth, ImGui::CalcTextSize("Saving...").x);
for (const char* text : { "Saving...", "Checking...", UpdateRowValue() })
valueWidth = std::max(valueWidth, ImGui::CalcTextSize(text).x);
valueWidth += 2 * kSpaceM * s;
float arrow = ImGui::GetFrameHeight();
ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, cellPadding);
@@ -435,14 +811,24 @@ 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);
UpdateRow(valueWidth);
}
ImGui::EndTable();
}
ImGui::PopStyleVar();
@@ -487,16 +873,31 @@ 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);
}
bool popup = false; // a dialog has the input: B is its, not the menu's
if (ImGui::BeginPopupModal("reset", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar))
{
popup = true;
ImGui::Text("Reset every %s setting to its default?", settings::CategoryName(s_category));
ImGui::Spacing();
if (ImGui::Button("Reset"))
@@ -515,14 +916,89 @@ namespace ui
ImGui::SetItemDefaultFocus();
ImGui::EndPopup();
}
else if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && !ImGui::IsAnyItemActive() &&
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
}
}
+93
View File
@@ -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());
}
}
+203 -13
View File
@@ -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,11 +885,17 @@ 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);
}
}
}
void RequestShot()
{
s_shots++;
}
bool TakeShot()
{
if (s_shots == 0)
@@ -799,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,
@@ -821,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();
@@ -916,6 +1104,8 @@ namespace ui
return;
s_settingsOpen = false;
UpdateCapture();
ForgetUpdateDialog();
ForgetQuitPrompt();
report::NoteMenuClosed();
XamNotifySystemUi(false);
if (settings::Dirty() && !settings::Save())
+41 -4
View File
@@ -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();
@@ -62,6 +65,8 @@ namespace ui
// Main thread: NFSMW_UI_KEYS asked for a capture of the next presented
// frame ("Shot"); true once per request.
bool TakeShot();
// Main thread: the same request from elsewhere (NFSMW_VIRTUAL_PAD's SHOT).
void RequestShot();
// Main thread: every SDL event, before the game's input sees it. Opens
// and closes the settings menu (keys, controller chord, three-finger
@@ -111,15 +116,47 @@ 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();
// Screens (settings_menu.cpp, perf_overlay.cpp, installer_screen.cpp).
void DrawSettings();
void ResetSettingsMenu(); // on opening: focus the first row
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
// itself is update/updater.h): the row's value and help lines, and its
// dialog, drawn inside the settings window.
const char* UpdateRowValue(); // "Check", "Checking...", "Up to date", "v0.1.2 is out", "Failed"
void UpdateRowHelp();
// Starts a check (unless one is running) and opens the dialog next frame.
void OpenUpdateDialog();
// Every frame inside the settings window, at its top level (not in a
// child or a table: the popup's ID). True while the dialog is open: B
// closes it, not the menu.
bool DrawUpdateDialog();
bool UpdateDialogOpen(); // at the same level
// 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();
float Scale(); // 1 at 720 points of window height
+388
View File
@@ -0,0 +1,388 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ui.h.
//
// Settings > Advanced > Check for Updates: the row's value and help, and the
// dialog over the menu with the result (update/updater.h runs the check).
// - Checking: says so; Close leaves the check running (the row, the log
// and a toast get the result).
// - Up to date, or a one-line reason it couldn't check (and Try again).
// - A newer version: its notes (plain text, scrolled by the D-pad), "Open
// download page" (the system's browser) and the page as a QR code with
// its address, for when no browser opens (Steam Deck Game Mode, the
// Steam Frame's headset): a phone's camera reads it off the screen.
// Controller first, like the menu: left/right picks a button, A presses it,
// up/down scrolls the notes, B goes back to the menu.
#include <stdafx.h>
#include "ui.h"
#include <update/updater.h>
#include <thirdparty/qrcodegen/qrcodegen.h>
#include <imgui.h>
#include <SDL3/SDL_error.h>
#include <SDL3/SDL_misc.h>
namespace ui
{
namespace
{
constexpr const char* kPopup = "Check for Updates";
bool s_openRequested = false; // OpenUpdateDialog: open it inside the settings window, next frame
bool s_showing = false; // drawn last frame
std::string s_focusKey; // what the dialog showed when its button was last focused
// The download page's QR code, made once per address.
struct Qr
{
std::string text;
int size = 0; // modules per side; 0: none
std::vector<uint8_t> dark; // size * size, row by row
};
Qr s_qr;
const Qr& QrFor(const std::string& text)
{
if (s_qr.text == text)
return s_qr;
s_qr = {};
s_qr.text = text;
std::vector<uint8_t> code(qrcodegen_BUFFER_LEN_MAX), temp(qrcodegen_BUFFER_LEN_MAX);
// Medium error correction (15%) for a picture of a screen,
// raised where the version has room; version 10 at most (57
// modules), which a release page's address never comes near.
if (qrcodegen_encodeText(text.c_str(), temp.data(), code.data(), qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN, 10,
qrcodegen_Mask_AUTO, true))
{
s_qr.size = qrcodegen_getSize(code.data());
s_qr.dark.resize(size_t(s_qr.size) * size_t(s_qr.size));
for (int y = 0; y < s_qr.size; y++)
for (int x = 0; x < s_qr.size; x++)
s_qr.dark[size_t(y) * size_t(s_qr.size) + size_t(x)] = qrcodegen_getModule(code.data(), x, y) ? 1 : 0;
fprintf(stderr, "[update] QR code of %s: version %d, %d modules a side\n", text.c_str(), (s_qr.size - 17) / 4, s_qr.size);
}
else
fprintf(stderr, "[update] QR code of %s: too long to encode\n", text.c_str());
return s_qr;
}
// Dark modules on white with the four-module quiet zone the standard
// asks for; every module the same whole number of points, at most
// `side` points in all (at least 2 a module).
void DrawQr(const Qr& qr, float side)
{
constexpr int kQuiet = 4;
const int total = qr.size + 2 * kQuiet;
const float m = std::max(2.0f, std::floor(side / float(total)));
const ImVec2 at = ImGui::GetCursorScreenPos();
const ImVec2 origin(std::floor(at.x), std::floor(at.y));
ImDrawList* draw = ImGui::GetWindowDrawList();
draw->AddRectFilled(origin, ImVec2(origin.x + total * m, origin.y + total * m), IM_COL32_WHITE);
for (int y = 0; y < qr.size; y++)
for (int x = 0; x < qr.size;)
{
if (!qr.dark[size_t(y) * size_t(qr.size) + size_t(x)])
{
x++;
continue;
}
int run = 1; // a row's dark modules side by side are one rectangle
while (x + run < qr.size && qr.dark[size_t(y) * size_t(qr.size) + size_t(x + run)])
run++;
const ImVec2 a(origin.x + (kQuiet + x) * m, origin.y + (kQuiet + y) * m);
draw->AddRectFilled(a, ImVec2(a.x + run * m, a.y + m), IM_COL32_BLACK);
x += run;
}
ImGui::Dummy(ImVec2(total * m, total * m));
}
void OpenPage(const std::string& url)
{
if (const char* v = std::getenv("NFSMW_TEST_NO_BROWSER"); v && *v && *v != '0')
{
fprintf(stderr, "[update] Open download page: %s (NFSMW_TEST_NO_BROWSER: not opened)\n", url.c_str());
return;
}
if (SDL_OpenURL(url.c_str()))
fprintf(stderr, "[update] Open download page: %s (handed to the system)\n", url.c_str());
else
{
fprintf(stderr, "[update] Open download page: %s failed: %s\n", url.c_str(), SDL_GetError());
Toast("No browser opened. Scan the code with a phone, or go to " + url, 12.0);
}
}
void Legend(const char* text)
{
ImGui::SameLine();
float w = ImGui::CalcTextSize(text).x;
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - w));
ImGui::TextDisabled("%s", text);
}
// The focused button, once per thing shown (the result replaces
// "Checking..." under the player's thumb).
void FocusOnce(const std::string& key)
{
if (s_focusKey != key)
{
s_focusKey = key;
ImGui::SetKeyboardFocusHere();
}
}
void DrawNotes(const update::Result& r, ImVec2 size)
{
const float lineHeight = ImGui::GetTextLineHeightWithSpacing();
if (ImGui::BeginChild("notes", size, ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened))
{
ImGui::PushTextWrapPos(0.0f);
if (!r.name.empty() && r.name != "v" + r.latest && r.name != r.latest)
{
PushAccentText();
ImGui::TextWrapped("%s", r.name.c_str());
PopAccentText();
ImGui::Spacing();
}
for (const version::NoteLine& line : r.notes.lines)
switch (line.kind)
{
case version::NoteLine::Kind::Gap:
ImGui::Spacing();
break;
case version::NoteLine::Kind::Heading:
PushAccentText();
ImGui::TextWrapped("%s", line.text.c_str());
PopAccentText();
break;
case version::NoteLine::Kind::Bullet:
ImGui::Bullet();
ImGui::TextWrapped("%s", line.text.c_str());
break;
case version::NoteLine::Kind::Text:
ImGui::TextWrapped("%s", line.text.c_str());
break;
}
if (r.notes.lines.empty())
ImGui::TextDisabled("This release has no notes.");
else if (r.notes.truncated)
{
ImGui::Spacing();
ImGui::TextDisabled("The rest is on the download page.");
}
ImGui::PopTextWrapPos();
// The D-pad, the stick or the arrow keys scroll (the buttons keep the focus).
const float step = lineHeight * 0.5f;
if (ImGui::IsKeyDown(ImGuiKey_GamepadDpadDown) || ImGui::IsKeyDown(ImGuiKey_GamepadLStickDown) || ImGui::IsKeyDown(ImGuiKey_DownArrow))
ImGui::SetScrollY(ImGui::GetScrollY() + step);
if (ImGui::IsKeyDown(ImGuiKey_GamepadDpadUp) || ImGui::IsKeyDown(ImGuiKey_GamepadLStickUp) || ImGui::IsKeyDown(ImGuiKey_UpArrow))
ImGui::SetScrollY(ImGui::GetScrollY() - step);
}
ImGui::EndChild();
}
// True: close the dialog.
bool DrawAvailable(const update::Result& r)
{
const float s = Scale();
TitleText(r.headline.c_str());
ImGui::TextDisabled("%s", r.message.c_str());
ImGui::Spacing();
// The notes on the left, the code on the right, the address under both.
const ImGuiStyle& style = ImGui::GetStyle();
const float footer = ImGui::GetFrameHeight() + ImGui::GetTextLineHeightWithSpacing() * 2 + style.ItemSpacing.y * 2;
const float body = std::max(ImGui::GetContentRegionAvail().y - footer, ImGui::GetTextLineHeightWithSpacing() * 4);
const Qr& qr = QrFor(r.url);
const float caption = ImGui::GetTextLineHeightWithSpacing() * 2;
const float side = qr.size ? std::min(body - caption - style.ItemSpacing.y, 300.0f * s) : 0.0f;
const float gutter = kSpaceL * s;
const float notesWidth = ImGui::GetContentRegionAvail().x - (qr.size ? side + gutter : 0.0f);
DrawNotes(r, ImVec2(notesWidth, body));
if (qr.size)
{
ImGui::SameLine(0.0f, gutter);
ImGui::BeginGroup();
DrawQr(qr, side);
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + side);
ImGui::TextDisabled("No browser? Point a phone's camera at the code.");
ImGui::PopTextWrapPos();
ImGui::EndGroup();
}
ImGui::TextDisabled("Download page: %s", r.url.c_str());
ButtonRowAtBottom();
FocusOnce("available " + r.latest);
bool open = ImGui::Button("Open download page");
ImGui::SameLine();
bool close = ImGui::Button("Close");
Legend("D-pad: scroll, choose A: select B: back");
if (open)
OpenPage(r.url);
return close;
}
// Up to date, or why not: one line, and OK (and Try again).
bool DrawShort(const update::Result& r, uint64_t checks)
{
TitleText(r.headline.c_str());
ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(r.message.c_str());
if (r.outcome == update::Outcome::Unavailable)
ImGui::TextDisabled("Releases: %s", update::kReleasesPage);
ImGui::PopTextWrapPos();
ImGui::Spacing();
ImGui::Spacing();
const bool failed = r.outcome != update::Outcome::UpToDate && r.outcome != update::Outcome::Unavailable;
FocusOnce(std::format("result {}", checks));
bool close = ImGui::Button("OK");
bool retry = false;
if (failed)
{
ImGui::SameLine();
retry = ImGui::Button("Try again");
}
Legend(failed ? "D-pad: choose A: select B: back" : "A or B: back");
if (retry)
update::StartCheck();
return close;
}
bool DrawChecking()
{
TitleText("Checking for updates");
ImGui::Spacing();
const int dots = int(ImGui::GetTime() * 2.0) % 4;
ImGui::Text("Asking GitHub for the newest release%.*s", dots, "...");
ImGui::PushTextWrapPos(0.0f);
ImGui::TextDisabled("It takes a few seconds at most. Only the question goes out: nothing about you or this device.");
ImGui::PopTextWrapPos();
ImGui::Spacing();
ImGui::Spacing();
FocusOnce("checking");
bool close = ImGui::Button("Close");
Legend("A or B: back (the check carries on)");
return close;
}
}
const char* UpdateRowValue()
{
static std::string text;
update::Status status = update::GetStatus();
if (status.phase == update::Phase::Checking)
return "Checking...";
if (!status.result)
return "Check";
switch (status.result->outcome)
{
case update::Outcome::UpToDate: return "Up to date";
case update::Outcome::Available:
text = "v" + status.result->latest + " is out";
return text.c_str();
default: return "Failed";
}
}
void UpdateRowHelp()
{
ImGui::TextWrapped("Asks GitHub whether a newer SpeedBreaker is out. The game goes online only when you press this, "
"and sends nothing but the question.");
update::Status status = update::GetStatus();
if (status.phase == update::Phase::Checking)
ImGui::TextDisabled("Checking...");
else if (!status.result)
ImGui::TextDisabled("This version: v%s", update::RunningVersion().c_str());
else if (status.result->outcome == update::Outcome::Available)
{
PushAccentText();
ImGui::Text("%s (you have v%s)", status.result->headline.c_str(), status.result->current.c_str());
PopAccentText();
}
else if (status.result->outcome == update::Outcome::UpToDate)
ImGui::TextDisabled("%s", status.result->headline.c_str());
else
ImGui::TextDisabled("%s", status.result->message.c_str());
}
void OpenUpdateDialog()
{
fprintf(stderr, "[update] Check for Updates pressed\n");
update::StartCheck();
s_openRequested = true;
}
bool DrawUpdateDialog()
{
if (s_openRequested)
{
s_openRequested = false;
s_focusKey.clear();
ImGui::OpenPopup(kPopup);
}
if (!ImGui::IsPopupOpen(kPopup))
{
if (s_showing)
ForgetUpdateDialog();
return false;
}
const float s = Scale();
update::Status status = update::GetStatus();
const update::Result* result = status.phase == update::Phase::Done ? status.result.get() : nullptr;
const bool available = result && result->outcome == update::Outcome::Available;
ImGuiViewport* viewport = ImGui::GetMainViewport();
const float width = std::min(viewport->WorkSize.x - 2 * kSpaceL * s, (available ? 900.0f : 760.0f) * s);
const float height = available ? std::min(viewport->WorkSize.y - 2 * kSpaceL * s, 600.0f * s) : 0.0f; // 0: fit
ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always);
constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse;
// Opaque: the menu's text mustn't show through the notes.
ImVec4 background = ImGui::GetStyleColorVec4(ImGuiCol_PopupBg);
background.w = 1.0f;
ImGui::PushStyleColor(ImGuiCol_PopupBg, background);
const bool open = ImGui::BeginPopupModal(kPopup, nullptr, flags);
ImGui::PopStyleColor();
if (!open)
{
if (s_showing)
ForgetUpdateDialog();
return false;
}
if (!s_showing)
{
s_showing = true;
update::SetDialogShowing(true);
}
bool close;
if (!result)
close = DrawChecking();
else if (available)
close = DrawAvailable(*result);
else
close = DrawShort(*result, status.checks);
if (!close && (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)))
close = true;
if (close)
{
ImGui::CloseCurrentPopup();
ForgetUpdateDialog();
}
ImGui::EndPopup();
return true;
}
bool UpdateDialogOpen()
{
return ImGui::IsPopupOpen(kPopup);
}
void ForgetUpdateDialog()
{
s_openRequested = false;
s_showing = false;
update::SetDialogShowing(false);
}
}
+4 -3
View File
@@ -3,9 +3,10 @@
// The one-time "What's new" note: the first launch of a newer version shows
// this version's CHANGELOG.md entry, compiled in at build time
// (generated/whats_new.inc, runtime/CMakeLists.txt), over the game's first
// frames until the player closes it. Offline only: no update check, nothing
// sent. main.cpp decides when (user/version_check.h); the version that ran
// last lives in settings.toml, so it shows once.
// frames until the player closes it. Offline: the note is built in and
// nothing is sent (Check for Updates, update/updater.h, goes online only when
// pressed). main.cpp decides when (user/version_check.h); the version that
// ran last lives in settings.toml, so it shows once.
#include <stdafx.h>
#include "ui.h"
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Check for Updates (Settings > Advanced): what the game makes of GitHub's
// answer. The only time the game goes online, and only when the player
// presses it: one GET of the releases API's "latest" release, with a
// User-Agent and an Accept header, nothing else (update/updater.h runs it,
// update/fetch.h is each platform's transport).
//
// Here only the logic, no I/O, so tests/update_check_test.cpp covers all of
// it: Semantic Versioning, a strict reader for the release's JSON, the raw
// HTTP answer curl prints (and the test hook's canned files), the release
// notes as plain lines, and the one-line result for every outcome.
#pragma once
#include <user/version_check.h> // version::NoteLine, as the "What's new" note draws them
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace update
{
// GitHub's newest release that is neither a draft nor a pre-release.
inline constexpr const char kApiUrl[] = "https://api.github.com/repos/SpeedBreakerProject/speedbreaker/releases/latest";
// Where "Open download page" goes when the answer names no page of this
// project's releases.
inline constexpr const char kReleasesPage[] = "https://github.com/SpeedBreakerProject/speedbreaker/releases/latest";
inline constexpr const char kPagePrefix[] = "https://github.com/SpeedBreakerProject/speedbreaker/releases/";
// The whole exchange, connecting included.
inline constexpr double kTimeoutSeconds = 10.0;
// A release's JSON is a few KB (its notes are at most 125,000 characters).
inline constexpr size_t kMaxResponseBytes = 1 << 20;
// --- Versions: Semantic Versioning 2.0.0 ---
struct Semver
{
uint64_t major = 0, minor = 0, patch = 0;
std::vector<std::string> pre; // "rc.1" -> { "rc", "1" }; empty for a release
std::string build; // after '+'; ignored when comparing
};
// "0.1.2", "v0.1.2", "1.0.0-rc.1+build.5". Strict: three numbers without
// leading zeros, identifiers of [0-9A-Za-z-] (a numeric pre-release one
// without leading zeros), nothing else.
bool ParseSemver(std::string_view text, Semver& out);
// -1, 0 or 1 by precedence (a pre-release comes before its release;
// build metadata doesn't count).
int CompareSemver(const Semver& a, const Semver& b);
// "0.1.2" or "1.0.0-rc.1" (no build metadata).
std::string FormatSemver(const Semver& v);
// --- The release, from GitHub's JSON ---
struct Release
{
bool hasTag = false;
std::string tag; // tag_name: "v0.1.2"
std::string name; // "" when null
std::string url; // html_url: the release's page
std::string body; // the notes (Markdown); "" when null
std::string message; // an error answer's "message"
bool draft = false, prerelease = false;
};
// Reads the top-level members above out of a JSON object. Strict: the
// whole document must be valid JSON (RFC 8259, UTF-8, at most 64 levels
// deep) with nothing after it, and each member read must have the right
// type, once. False, with what was wrong and where, otherwise.
bool ParseRelease(std::string_view json, Release& out, std::string& error);
// --- The HTTP answer ---
struct HttpResponse
{
int status = 0;
std::vector<std::pair<std::string, std::string>> headers; // names in lower case
std::string body;
};
// Header blocks then the body, as `curl --dump-header -` prints them (a
// proxy's or an interim block first, the last one counts), or a canned
// file of the test hook. False without a whole status line and header block.
bool ParseHttpResponse(std::string_view raw, HttpResponse& out);
// A header's value (any case), or "".
std::string_view Header(const HttpResponse& response, std::string_view name);
// --- Release notes as plain lines ---
struct Notes
{
std::vector<version::NoteLine> lines;
bool truncated = false; // cut at maxChars or maxLines
};
// GitHub Markdown to the lines the dialog draws: headings, bullets,
// paragraphs joined, links as their text, code and emphasis marks,
// HTML tags and comments, images and tables' rules dropped, entities
// decoded, control characters gone. At most maxChars of text in
// maxLines lines (whole UTF-8 characters).
Notes CleanNotes(std::string_view markdown, size_t maxChars = 6000, size_t maxLines = 150);
// One plain line (a release's name): as a notes line, at most maxChars.
std::string CleanLine(std::string_view text, size_t maxChars);
// --- The outcome ---
// How the transport (update/fetch.h) ended.
enum class Transport : uint8_t
{
Ok, // an HTTP answer, any status: `response`
Offline, // no connection (no network, no DNS, refused)
Timeout, // nothing whole within kTimeoutSeconds
Secure, // TLS failed (a certificate, the clock)
TooLarge, // more than kMaxResponseBytes
Garbled, // an answer that isn't HTTP
Unavailable, // no transport here (no curl; Windows for now)
Failed, // anything else
};
struct Fetched
{
Transport transport = Transport::Failed;
HttpResponse response;
std::string detail; // for the log, and for Unavailable the player's message
};
enum class Outcome : uint8_t { UpToDate, Available, Offline, Timeout, RateLimited, Unreadable, ServerError, Unavailable };
struct Result
{
Outcome outcome = Outcome::Unreadable;
std::string current; // the running version: "0.1.1"
std::string latest; // the newest release's: "0.1.2" ("" if unknown)
std::string headline; // "v0.1.2 is available", "You're up to date (v0.1.1)", "Couldn't check for updates"
std::string message; // one line: what happened, what to do
std::string name; // the release's name, one plain line
std::string url; // the download page (SafePageUrl)
Notes notes;
std::string log; // the log line after "[update] " (the caller adds the time)
};
// `current`: the running version; `now`: Unix seconds (a rate limit's reset).
Result Evaluate(const Fetched& fetched, std::string_view current, int64_t now);
// The page to open and show as a QR code: `url` if it is a page of this
// project's releases (plain characters, a sane length), else kReleasesPage.
std::string SafePageUrl(std::string_view url);
}
+38
View File
@@ -0,0 +1,38 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The update check's transport: one HTTP GET, each platform its own way,
// with no library of ours linked for it.
// - macOS and iOS: NSURLSession, an ephemeral session (no cookies, no
// cache, no stored credentials): platform/apple/update_fetch.mm.
// - Linux and SteamOS (x86-64 and the Steam Frame's arm64): the system's
// curl, run with a fixed argument list (no shell, no ~/.curlrc) and a
// small environment, its output capped: update/fetch_curl.cpp. Without
// curl the answer says so (Transport::Unavailable).
// - Windows: not yet (Transport::Unavailable): update/fetch_windows.cpp,
// which says how a WinHTTP port fills this in.
//
// The request carries the URL, a User-Agent, the headers below and nothing
// else: no cookies, no credentials, no body.
#pragma once
#include "check.h"
#include <string>
#include <utility>
#include <vector>
namespace update
{
struct Request
{
std::string url;
std::string userAgent;
std::vector<std::pair<std::string, std::string>> headers;
double timeoutSeconds = kTimeoutSeconds; // the whole exchange
size_t maxBytes = kMaxResponseBytes; // the body (curl's headers get a little more)
bool allowHttp = false; // the test hook's local server; the real check is https only
};
// Blocking, on the update thread (never the main thread): returns within
// timeoutSeconds plus about two seconds, whatever the network does.
Fetched Fetch(const Request& request);
}
+294
View File
@@ -0,0 +1,294 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See fetch.h.
//
// Linux and SteamOS: the system's curl (on SteamOS and nearly every
// distribution), so the game links no HTTP or TLS library of its own and
// uses the system's certificates. It runs with:
// - a fixed argument list, through posix_spawn (no shell), -q first so no
// ~/.curlrc adds anything;
// - a small environment: PATH, the proxy variables and the CA bundle ones
// (not LD_LIBRARY_PATH or LD_PRELOAD: the SteamOS download's bundled
// libraries and Steam's overlay are the game's, not curl's);
// - stdin and stderr on /dev/null, stdout a pipe read with a deadline and
// a size cap; no other descriptor of the game's;
// - default signal handling and an empty signal mask.
// curl stops itself at --max-time; the game kills it two seconds later if
// it hasn't, and always reaps it.
//
// NFSMW_UPDATE_CURL=<path>: that curl instead (tests: a missing one).
#if !defined(__APPLE__) && !defined(_WIN32)
#include "fetch.h"
#include <cerrno>
#include <chrono>
#include <cmath>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <format>
#include <fcntl.h>
#include <poll.h>
#include <spawn.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
extern char** environ;
namespace update
{
namespace
{
bool Executable(const std::string& path)
{
struct stat st;
return !path.empty() && path[0] == '/' && stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode) && access(path.c_str(), X_OK) == 0;
}
std::string FindCurl()
{
if (const char* v = std::getenv("NFSMW_UPDATE_CURL"); v && *v)
return Executable(v) ? v : "";
std::vector<std::string> dirs;
if (const char* path = std::getenv("PATH"))
for (std::string_view rest = path; !rest.empty();)
{
size_t colon = rest.find(':');
std::string_view dir = rest.substr(0, colon);
rest = colon == std::string_view::npos ? std::string_view() : rest.substr(colon + 1);
if (dir.starts_with('/')) // never the current folder
dirs.emplace_back(dir);
}
for (const char* dir : { "/usr/bin", "/bin", "/usr/local/bin" })
dirs.emplace_back(dir);
for (const std::string& dir : dirs)
if (std::string curl = dir + "/curl"; Executable(curl))
return curl;
return {};
}
// curl(1), "EXIT CODES".
Transport FromExitCode(int code)
{
switch (code)
{
case 0:
return Transport::Ok;
case 5: // couldn't resolve the proxy
case 6: // couldn't resolve the host
case 7: // couldn't connect
case 45: // interface error
case 52: // nothing came back
case 55: // send failed
case 56: // receive failed (a reset connection)
return Transport::Offline;
case 28:
return Transport::Timeout;
case 35: // the TLS handshake failed
case 53: case 54: case 58: case 59: case 60: case 64: case 66: case 77: case 80: case 82: case 83: case 90: case 91:
return Transport::Secure;
case 63: // over --max-filesize
return Transport::TooLarge;
case 1: // "unsupported protocol": with the protocols fixed, a reply that isn't HTTP (HTTP/0.9 refused)
case 8: // a reply curl couldn't parse
return Transport::Garbled;
default:
return Transport::Failed;
}
}
// waitpid, through EINTR. 0: still running; -1: not ours to reap.
pid_t Reap(pid_t pid, int& status, int flags)
{
pid_t r;
do
r = waitpid(pid, &status, flags);
while (r < 0 && errno == EINTR);
return r;
}
}
Fetched Fetch(const Request& request)
{
Fetched result;
const std::string curl = FindCurl();
if (curl.empty())
{
result.transport = Transport::Unavailable;
result.detail = "Checking needs curl, which this system doesn't have. Install curl, or see the releases page.";
return result;
}
const std::string seconds = std::format("{}", std::ceil(request.timeoutSeconds));
std::vector<std::string> args = {
curl, "-q", "--silent", "--dump-header", "-",
"--max-time", seconds, "--connect-timeout", seconds,
"--max-filesize", std::to_string(request.maxBytes),
"--proto", request.allowHttp ? "=http,https" : "=https",
"--user-agent", request.userAgent,
};
for (const auto& [name, value] : request.headers)
{
args.push_back("--header");
args.push_back(name + ": " + value);
}
args.push_back("--url");
args.push_back(request.url);
std::vector<char*> argv;
for (std::string& a : args)
argv.push_back(a.data());
argv.push_back(nullptr);
std::vector<std::string> env;
for (const char* name : { "PATH", "https_proxy", "HTTPS_PROXY", "http_proxy", "all_proxy", "ALL_PROXY", "no_proxy", "NO_PROXY",
"SSL_CERT_FILE", "SSL_CERT_DIR", "CURL_CA_BUNDLE" })
if (const char* value = std::getenv(name))
env.push_back(std::string(name) + "=" + value);
std::vector<char*> envp;
for (std::string& e : env)
envp.push_back(e.data());
envp.push_back(nullptr);
int fds[2];
if (pipe2(fds, O_CLOEXEC) != 0)
{
result.detail = std::format("pipe: {}", strerror(errno));
return result;
}
posix_spawn_file_actions_t actions;
posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0);
posix_spawn_file_actions_adddup2(&actions, fds[1], 1);
posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0);
#if defined(__GLIBC__) && (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 34))
posix_spawn_file_actions_addclosefrom_np(&actions, 3); // the game's descriptors without O_CLOEXEC too
#endif
posix_spawnattr_t attr;
posix_spawnattr_init(&attr);
sigset_t none, all;
sigemptyset(&none);
sigfillset(&all);
posix_spawnattr_setsigmask(&attr, &none);
posix_spawnattr_setsigdefault(&attr, &all);
posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF);
pid_t pid = -1;
const int spawned = posix_spawn(&pid, curl.c_str(), &actions, &attr, argv.data(), envp.data());
posix_spawn_file_actions_destroy(&actions);
posix_spawnattr_destroy(&attr);
close(fds[1]);
if (spawned != 0)
{
close(fds[0]);
result.transport = spawned == ENOENT ? Transport::Unavailable : Transport::Failed;
result.detail = spawned == ENOENT ? "Checking needs curl, which this system doesn't have. Install curl, or see the releases page."
: std::format("couldn't run curl: {}", strerror(spawned));
return result;
}
// Its output, by the deadline and within the cap.
using Clock = std::chrono::steady_clock;
const auto deadline = Clock::now() + std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(request.timeoutSeconds + 2.0));
const size_t cap = request.maxBytes + 64 * 1024; // the body and its headers
std::string out;
bool killed = false;
Transport killedAs = Transport::Failed;
char buffer[16384];
while (true)
{
auto left = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - Clock::now()).count();
if (left <= 0)
{
killed = true, killedAs = Transport::Timeout;
result.detail = "curl was still running after the deadline";
break;
}
pollfd p{ fds[0], POLLIN, 0 };
int ready = poll(&p, 1, int(std::min<int64_t>(left, 1000)));
if (ready < 0 && errno == EINTR)
continue;
if (ready < 0)
{
killed = true;
result.detail = std::format("poll: {}", strerror(errno));
break;
}
if (ready == 0)
continue;
ssize_t n = read(fds[0], buffer, sizeof(buffer));
if (n < 0 && (errno == EINTR || errno == EAGAIN))
continue;
if (n < 0)
{
killed = true;
result.detail = std::format("read: {}", strerror(errno));
break;
}
if (n == 0)
break; // curl closed its output: done
if (out.size() + size_t(n) > cap)
{
killed = true, killedAs = Transport::TooLarge;
result.detail = std::format("over {} bytes", cap);
break;
}
out.append(buffer, size_t(n));
}
close(fds[0]);
if (killed)
kill(pid, SIGKILL); // still unreaped, so still ours
// Reaped in any case: at its end of output curl is exiting; give it
// two seconds, then the kill.
int status = 0;
pid_t reaped = Reap(pid, status, WNOHANG);
for (auto until = Clock::now() + std::chrono::seconds(2); reaped == 0 && Clock::now() < until;)
{
usleep(10'000);
reaped = Reap(pid, status, WNOHANG);
}
if (reaped == 0)
{
kill(pid, SIGKILL);
reaped = Reap(pid, status, 0);
if (!killed)
{
killed = true, killedAs = Transport::Timeout;
result.detail = "curl didn't exit";
}
}
if (killed)
{
result.transport = killedAs;
return result;
}
if (reaped < 0 || !WIFEXITED(status))
{
result.transport = Transport::Failed;
result.detail = reaped < 0 ? "curl's exit status was lost" : std::format("curl ended by signal {}", WTERMSIG(status));
return result;
}
const int code = WEXITSTATUS(status);
if (Transport t = FromExitCode(code); t != Transport::Ok)
{
result.transport = t;
result.detail = std::format("curl exit {}", code);
return result;
}
if (!ParseHttpResponse(out, result.response))
{
result.transport = Transport::Garbled;
result.detail = std::format("curl's output isn't an HTTP answer ({} bytes)", out.size());
return result;
}
if (result.response.body.size() > request.maxBytes)
{
result.transport = Transport::TooLarge;
result.detail = std::format("{} bytes", result.response.body.size());
return result;
}
result.transport = Transport::Ok;
return result;
}
}
#endif
+43
View File
@@ -0,0 +1,43 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See fetch.h.
//
// Windows: no transport yet, so Check for Updates says it isn't available
// (the rest of the feature, the dialog, the QR code and the log line, is
// shared and works as it is).
//
// The port: WinHTTP (winhttp.dll is part of every Windows, so nothing to
// bundle; link winhttp under WIN32 in runtime/CMakeLists.txt):
// 1. WinHttpOpen(userAgent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, ...) so
// the system's proxy settings apply, as they do on macOS.
// 2. WinHttpCrackUrl; WinHttpConnect(host, port); WinHttpOpenRequest(L"GET",
// path, ..., WINHTTP_FLAG_SECURE) (plain http only for request.allowHttp);
// WinHttpSetOption: WINHTTP_OPTION_DISABLE_FEATURE with
// WINHTTP_DISABLE_COOKIES, WINHTTP_OPTION_REDIRECT_POLICY never, and
// WINHTTP_OPTION_AUTOLOGON_POLICY high (no Windows credentials sent).
// 3. WinHttpSetTimeouts for resolve, connect, send and receive. Those are
// per phase, so also keep the whole exchange's deadline: an async
// session (WINHTTP_FLAG_ASYNC with a callback and an event), waited on
// for timeoutSeconds, then WinHttpCloseHandle to cancel it.
// 4. WinHttpAddRequestHeaders (request.headers), WinHttpSendRequest,
// WinHttpReceiveResponse; the status from WinHttpQueryHeaders
// (WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER) and the
// headers from WINHTTP_QUERY_RAW_HEADERS_CRLF (ParseHttpResponse reads
// them, for x-ratelimit-*); the body with WinHttpReadData, stopping past
// request.maxBytes (Transport::TooLarge).
// 5. Errors: ERROR_WINHTTP_NAME_NOT_RESOLVED, _CANNOT_CONNECT and
// _CONNECTION_ERROR are Transport::Offline, ERROR_WINHTTP_TIMEOUT
// Timeout, ERROR_WINHTTP_SECURE_FAILURE Secure.
// Test it as the other platforms are (docs: update/updater.h's hooks).
#ifdef _WIN32
#include "fetch.h"
namespace update
{
Fetched Fetch(const Request&)
{
Fetched result;
result.transport = Transport::Unavailable;
result.detail = "Checking for updates isn't available on Windows yet. The releases page has every version.";
return result;
}
}
#endif
+222
View File
@@ -0,0 +1,222 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See updater.h.
#include <stdafx.h>
#include "updater.h"
#include "fetch.h"
#include <cpu/guest_thread.h>
#include <report/report.h>
#include <ui/ui.h>
#include <ctime>
#include <fstream>
namespace update
{
namespace
{
// Never destroyed: the check's thread is detached and may still be
// finishing when the game exits.
struct Shared
{
std::mutex mutex;
Phase phase = Phase::Idle;
uint64_t checks = 0;
std::shared_ptr<const Result> result;
};
Shared& State()
{
static Shared* shared = new Shared;
return *shared;
}
std::atomic<bool> s_dialogShowing{ false };
// The game can exit while a check is still waiting on the network
// (up to the timeout): from then on its thread must not touch the UI,
// whose objects are being destroyed. An exit handler, registered at
// the first check (so it runs before those objects' destructors),
// takes this lock and sets the flag; the thread toasts only under it.
std::mutex& ExitMutex()
{
static std::mutex* mutex = new std::mutex;
return *mutex;
}
bool s_exiting = false; // under ExitMutex()
void NoteExit()
{
std::lock_guard lock(ExitMutex());
s_exiting = true;
}
std::string UserAgent()
{
return std::format("SpeedBreaker/{}", report::Version());
}
// NFSMW_UPDATE_URL=<file>: the canned answer.
Fetched ReadCanned(const std::string& path)
{
Fetched fetched;
std::ifstream file(path, std::ios::binary);
if (!file)
{
fetched.detail = "NFSMW_UPDATE_URL: can't read " + path;
return fetched;
}
std::string raw(kMaxResponseBytes + 1, '\0');
file.read(raw.data(), std::streamsize(raw.size()));
raw.resize(size_t(file.gcount()));
std::string_view first(raw);
first = first.substr(0, first.find_first_of("\r\n"));
if (first == "offline" || first == "timeout")
{
fetched.transport = first == "offline" ? Transport::Offline : Transport::Timeout;
fetched.detail = "canned";
return fetched;
}
if (raw.size() > kMaxResponseBytes)
{
fetched.transport = Transport::TooLarge;
fetched.detail = "canned";
return fetched;
}
if (raw.starts_with("HTTP/"))
{
if (ParseHttpResponse(raw, fetched.response))
fetched.transport = Transport::Ok;
else
{
fetched.transport = Transport::Garbled;
fetched.detail = "canned";
}
return fetched;
}
fetched.transport = Transport::Ok;
fetched.response.status = 200;
fetched.response.body = std::move(raw);
return fetched;
}
std::string ToastText(const Result& r)
{
switch (r.outcome)
{
case Outcome::UpToDate: return r.headline + ".";
case Outcome::Available: return std::format("SpeedBreaker {}: Settings > Advanced > Check for Updates.", r.headline);
default: return r.headline + ": " + r.message;
}
}
void Check()
{
SetHostThreadName("nfsmw-update");
const auto started = std::chrono::steady_clock::now();
const std::string running = RunningVersion();
std::string source;
Fetched fetched;
const char* override = std::getenv("NFSMW_UPDATE_URL");
if (override && *override)
{
std::string target = override;
if (target.starts_with("http://") || target.starts_with("https://"))
{
source = " from NFSMW_UPDATE_URL " + target;
fetched = Fetch({ .url = target, .userAgent = UserAgent(),
.headers = { { "Accept", "application/vnd.github+json" }, { "X-GitHub-Api-Version", "2022-11-28" } },
.allowHttp = true });
}
else
{
if (target.starts_with("file://"))
target.erase(0, 7);
source = " from the file " + target;
fetched = ReadCanned(target);
}
}
else
fetched = Fetch({ .url = kApiUrl, .userAgent = UserAgent(),
.headers = { { "Accept", "application/vnd.github+json" }, { "X-GitHub-Api-Version", "2022-11-28" } } });
Result result = Evaluate(fetched, running, int64_t(std::time(nullptr)));
const double seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count();
const bool tested = running != report::Version();
fprintf(stderr, "[update] %s (%.2f s%s%s)\n", result.log.c_str(), seconds, source.c_str(),
tested ? ", as NFSMW_TEST_VERSION" : "");
const bool showing = s_dialogShowing.load();
std::string toast = showing ? std::string() : ToastText(result);
{
Shared& state = State();
std::lock_guard lock(state.mutex);
state.result = std::make_shared<const Result>(std::move(result));
state.phase = Phase::Done;
}
// The dialog was closed meanwhile: say how it went.
if (!toast.empty())
{
std::lock_guard lock(ExitMutex());
if (!s_exiting)
ui::Toast(toast, 10.0);
}
}
}
std::string RunningVersion()
{
static const std::string version = [] {
std::string v = report::Version();
if (const char* test = std::getenv("NFSMW_TEST_VERSION"); test && *test)
{
Semver parsed;
if (ParseSemver(test, parsed))
v = FormatSemver(parsed);
else
fprintf(stderr, "[update] NFSMW_TEST_VERSION=%s isn't a version: ignored\n", test);
}
return v;
}();
return version;
}
void StartCheck()
{
static const bool exitHandler = (std::atexit(NoteExit), true);
(void)exitHandler;
Shared& state = State();
{
std::lock_guard lock(state.mutex);
if (state.phase == Phase::Checking)
return;
state.phase = Phase::Checking;
state.checks++;
}
try
{
std::thread(Check).detach();
}
catch (const std::system_error& e)
{
fprintf(stderr, "[update] check failed: no thread for it (%s)\n", e.what());
auto result = std::make_shared<Result>();
result->outcome = Outcome::Unavailable;
result->current = RunningVersion();
result->headline = "Couldn't check for updates";
result->message = "The game couldn't start the check. Try again later.";
result->url = kReleasesPage;
std::lock_guard lock(state.mutex);
state.result = std::move(result);
state.phase = Phase::Done;
}
}
Status GetStatus()
{
Shared& state = State();
std::lock_guard lock(state.mutex);
return { state.phase, state.checks, state.result };
}
void SetDialogShowing(bool showing)
{
s_dialogShowing.store(showing);
}
}
+53
View File
@@ -0,0 +1,53 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Check for Updates: Settings > Advanced (ui/update_dialog.cpp) starts a
// check, which runs on a thread of its own (the transport, update/fetch.h,
// with its timeout) and never holds up the game or the menu; the menu
// reads the result when it's there. Nothing goes online until the player
// presses it. Each check writes one log line ("[update] ...") with its result.
//
// Test hooks (environment variables):
// NFSMW_TEST_VERSION=0.0.9 compare as this version instead of the
// build's (the real v0.1.0 then shows as
// available); the [build] line keeps the real one
// NFSMW_UPDATE_URL=<url> ask this http(s) address instead of GitHub's
// API (a local server: 403s, slow answers,
// refused connections)
// NFSMW_UPDATE_URL=<file> answer from this file, read whole, no network:
// a raw HTTP answer ("HTTP/1.1 403 rate limit
// exceeded", headers, a blank line, the body, as
// `curl --dump-header -` prints one), or else a
// body for an HTTP 200; "offline" and "timeout"
// on its first line stand for those failures
// NFSMW_UPDATE_CURL=<path> Linux: run this curl (a missing one: the
// "needs curl" message)
// NFSMW_TEST_NO_BROWSER=1 "Open download page" logs the address
// instead of opening it (automated runs)
#pragma once
#include "check.h"
#include <cstdint>
#include <memory>
namespace update
{
enum class Phase : uint8_t { Idle, Checking, Done };
struct Status
{
Phase phase = Phase::Idle;
uint64_t checks = 0; // checks started this session
std::shared_ptr<const Result> result; // the last check's (Done)
};
// Main thread: starts a check unless one is running (then nothing).
void StartCheck();
// Any thread.
Status GetStatus();
// The update dialog shows the result as it comes (true), or isn't open,
// so a check that finishes then tells the player with a toast (false).
void SetDialogShowing(bool showing);
// The version a check compares: the build's, or NFSMW_TEST_VERSION's.
std::string RunningVersion();
}
+109
View File
@@ -0,0 +1,109 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The keys a Controls row can hold (Settings > Controls), named for
// settings.toml: "w", "left_shift", "keypad_1", or "none" for no key. The
// code is the key's USB HID usage, which is what SDL's scancodes are (A is
// 4), so a binding is the physical key whatever the layout; SDL itself
// stays out of this header and of settings.cpp, and tests/key_names_test.cpp
// checks every row against SDL's name for the code (hid/input.cpp pins a
// few at compile time). SDL's own names won't do for the file: some change
// once video starts (on a Mac Left Alt becomes "Left Option"), while the
// settings load before that, and some keys share one. The table is also
// the list of what can be bound: F1 (it opens the menu), Cmd / Windows and
// the media keys are left out on purpose. Header only.
#pragma once
#include <cstddef>
#include <cstdint>
#include <iterator>
#include <string_view>
namespace settings::keynames
{
constexpr uint16_t kNone = 0; // no key; "none" in the file
constexpr uint16_t kUnknown = 0xFFFF; // Code(): not a name in the table
struct Key
{
uint16_t code;
const char* token;
};
// Ascending by code. The tokens are SDL's scancode constants in lower
// case with underscores: SDL_SCANCODE_LEFTBRACKET is "left_bracket",
// SDL_SCANCODE_KP_1 "keypad_1", SDL_SCANCODE_NUMLOCKCLEAR "num_lock".
constexpr Key kKeys[] = {
{ 4, "a" }, { 5, "b" }, { 6, "c" }, { 7, "d" }, { 8, "e" }, { 9, "f" }, { 10, "g" }, { 11, "h" }, { 12, "i" },
{ 13, "j" }, { 14, "k" }, { 15, "l" }, { 16, "m" }, { 17, "n" }, { 18, "o" }, { 19, "p" }, { 20, "q" }, { 21, "r" },
{ 22, "s" }, { 23, "t" }, { 24, "u" }, { 25, "v" }, { 26, "w" }, { 27, "x" }, { 28, "y" }, { 29, "z" },
{ 30, "1" }, { 31, "2" }, { 32, "3" }, { 33, "4" }, { 34, "5" }, { 35, "6" }, { 36, "7" }, { 37, "8" }, { 38, "9" }, { 39, "0" },
{ 40, "return" }, { 41, "escape" }, { 42, "backspace" }, { 43, "tab" }, { 44, "space" },
{ 45, "minus" }, { 46, "equals" }, { 47, "left_bracket" }, { 48, "right_bracket" }, { 49, "backslash" },
{ 50, "non_us_hash" }, { 51, "semicolon" }, { 52, "apostrophe" }, { 53, "grave" }, { 54, "comma" },
{ 55, "period" }, { 56, "slash" }, { 57, "caps_lock" },
{ 59, "f2" }, { 60, "f3" }, { 61, "f4" }, { 62, "f5" }, { 63, "f6" }, { 64, "f7" }, { 65, "f8" }, { 66, "f9" },
{ 67, "f10" }, { 68, "f11" }, { 69, "f12" },
{ 70, "print_screen" }, { 71, "scroll_lock" }, { 72, "pause" }, { 73, "insert" }, { 74, "home" },
{ 75, "page_up" }, { 76, "delete" }, { 77, "end" }, { 78, "page_down" },
{ 79, "right" }, { 80, "left" }, { 81, "down" }, { 82, "up" },
{ 83, "num_lock" }, { 84, "keypad_divide" }, { 85, "keypad_multiply" }, { 86, "keypad_minus" },
{ 87, "keypad_plus" }, { 88, "keypad_enter" },
{ 89, "keypad_1" }, { 90, "keypad_2" }, { 91, "keypad_3" }, { 92, "keypad_4" }, { 93, "keypad_5" },
{ 94, "keypad_6" }, { 95, "keypad_7" }, { 96, "keypad_8" }, { 97, "keypad_9" }, { 98, "keypad_0" },
{ 99, "keypad_period" }, { 100, "non_us_backslash" }, { 101, "application" }, { 103, "keypad_equals" },
{ 104, "f13" }, { 105, "f14" }, { 106, "f15" }, { 107, "f16" }, { 108, "f17" }, { 109, "f18" },
{ 110, "f19" }, { 111, "f20" }, { 112, "f21" }, { 113, "f22" }, { 114, "f23" }, { 115, "f24" },
{ 133, "keypad_comma" },
{ 135, "international_1" }, { 136, "international_2" }, { 137, "international_3" },
{ 138, "international_4" }, { 139, "international_5" }, { 140, "international_6" },
{ 224, "left_ctrl" }, { 225, "left_shift" }, { 226, "left_alt" },
{ 228, "right_ctrl" }, { 229, "right_shift" }, { 230, "right_alt" },
};
constexpr size_t kKeyCount = std::size(kKeys);
namespace detail
{
constexpr char Lower(char c)
{
return c >= 'A' && c <= 'Z' ? char(c - 'A' + 'a') : c;
}
// `text` in any case against a lower-case token.
constexpr bool Same(std::string_view text, const char* token)
{
size_t i = 0;
for (; i < text.size() && token[i]; i++)
if (Lower(text[i]) != token[i])
return false;
return i == text.size() && token[i] == '\0';
}
}
// The file's name for a code: "none" for kNone; nullptr for a code that
// isn't a key here.
constexpr const char* Token(uint16_t code)
{
if (code == kNone)
return "none";
for (const Key& k : kKeys)
if (k.code == code)
return k.token;
return nullptr;
}
// The code for a name in the file, in any case; kUnknown when it isn't one.
constexpr uint16_t Code(std::string_view token)
{
if (detail::Same(token, "none"))
return kNone;
for (const Key& k : kKeys)
if (detail::Same(token, k.token))
return k.code;
return kUnknown;
}
// A value a Key option can hold: no key, or a key of the table.
constexpr bool Known(uint16_t code)
{
return code == kNone || Token(code) != nullptr;
}
}
+2
View File
@@ -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());
}
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more