5 Commits
Author SHA1 Message Date
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
132 changed files with 23653 additions and 787 deletions

No files matched your search

+132
View File
@@ -4,6 +4,138 @@ 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.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.3 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.
+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.
+160 -61
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,17 +60,17 @@ 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
- **The original look:** sunlit roads and shadows as on the console, correct colours on Apple
devices, proper car paint and mirror reflections, and distant textures that don't shimmer. Anti-aliasing, sharpening and up to 16x
anisotropic filtering are also available.
- **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.
@@ -81,6 +82,9 @@ and a settings menu you can open mid-race.
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 +101,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 +112,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 +132,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 +148,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 +157,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 +180,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 +193,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 +215,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 +237,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 +249,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 +262,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. 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 +291,32 @@ 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. 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:
@@ -333,7 +387,7 @@ Each page has a *Reset page* button.
| 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. |
**Graphics**
@@ -344,7 +398,7 @@ Each page has a *Reset page* button.
| Sharpening | 0.00 to 1.00 | 0.30 (0 turns it off) |
| Anisotropic Filtering | Auto / Off / 2x / 4x / 8x / 16x / Original | Auto: 16x on dedicated graphics cards and M-series iPads, 4x elsewhere. Original uses each texture's own setting, as on the 360. |
| Mipmaps | On / Off | On, so distant textures don't shimmer |
| Internal Resolution | Auto / 1x (720p) / 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.
@@ -354,7 +408,8 @@ Vibration (On).
**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>
@@ -366,8 +421,12 @@ and GPU time per frame), and **Save Bug Report**. The page also shows which buil
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,20 +467,36 @@ 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 game ignores my touches on iPhone or iPad.** The game needs a controller or a keyboard.
@@ -432,10 +507,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 +521,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.3-<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,7 +555,7 @@ 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.
@@ -488,16 +567,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 +594,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 +641,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 +661,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,
@@ -633,9 +731,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 +755,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.
+11 -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,26 @@ 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) |
| **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
+2 -1
View File
@@ -57,7 +57,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
+33 -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
@@ -138,9 +142,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 +160,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()
+32 -14
View File
@@ -18,20 +18,25 @@
// 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 (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.
#include <stdafx.h>
#include "frame_time.h"
#include "whole_vblanks.h"
@@ -50,6 +55,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 +93,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();
}
+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;
}
}
+4 -2
View File
@@ -41,6 +41,7 @@
// of the build failing on an undefined symbol.
#include <stdafx.h>
#include "ultrawide.h"
#include "glare.h"
#include <kernel/function.h>
@@ -88,11 +89,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;
}
+38 -20
View File
@@ -10,19 +10,32 @@
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.
// it adds up to them, to within kSlack vblanks, and each frame that saw
// any gets whole ones (1 to 6), so the fixed 1/60 s world step runs a
// whole number of times a frame.
// - 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.
// 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,11 +45,14 @@ 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).
double ahead = 0.0;
static constexpr double kMaxAhead = 6.0;
// 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;
// A frame the main loop measured at `measuredMs`, during which
// `vblanks` guest vblanks fired (from its start to the next frame's),
@@ -49,14 +65,16 @@ namespace game
ahead = 0.0;
return 0;
}
if (vblanks == 0)
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)
give = std::clamp<int64_t>(std::llround(-kSlack - ahead), 1, 6);
ahead = std::max(ahead + double(give), -kSlack);
return uint64_t(give);
}
+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";
}
+573
View File
@@ -0,0 +1,573 @@
// 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 fix leaves those draws as they are and, right after each side bar or
// black frame, draws it once or twice more, stretched over the whole frame
// and scissored to (its half of) what lies outside the 16:9 band: the rows
// above and below it (Vert+), or the columns beyond its side (Hor+; for a
// bar only when it ends short of the screen edge). Nothing inside the band
// changes, and the strips get the quad's own colour, alpha and blend.
//
// 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). 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 the band, the rows above and below it across the whole width
// (Vert+) or the columns beside it down the whole height (Hor+; at most
// one of the view scales is below 1). The far edges reach kFillEdge.
inline uint32_t BlackoutRects(const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
Rect out[2])
{
uint32_t n = 0;
auto add = [&](const Rect& r) {
const Rect c = Intersect(r, scissor);
if (!Empty(c))
out[n++] = c;
};
const float e = kFillEdge;
if (scaleY < 1.0f)
{
add(ClipToHost(-e, e, scaleY, e, ps, hostW, hostH));
add(ClipToHost(-e, e, -e, -scaleY, ps, hostW, hostH));
}
else if (scaleX < 1.0f)
{
add(ClipToHost(scaleX, e, -e, e, ps, hostW, hostH));
add(ClipToHost(-e, -scaleX, -e, e, ps, hostW, hostH));
}
return n;
}
// 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 (a thin border of it shows at the top and
// bottom, as on the console, where a TV's overscan hid it); past the band
// (Hor+ beside it, Vert+ above and below it) the world rendered behind
// it showed: the race's road and cars at the sides of the results on an
// iPhone (2868x1320). What a 16:9 screen shows of it is kept, the art's
// own pixels too; everything else past the band (what no 16:9 screen
// shows) is filled black, as a 16:9 picture's letterbox. Thresholds in
// band units. The nearest draws it must not take: the fades and dims
// (+-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, inside the draw's scissor: what lies past the
// band (the columns beside it under Hor+, the rows above and below it
// under Vert+; at most one of the view scales is below 1) and outside
// the backdrop's own box, up to kFillEdge: per side, the part beyond the
// box and, where the box reaches into that side, the parts above and
// below it (Hor+; left and right of it under Vert+). At most 6.
inline uint32_t BackdropRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
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 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
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 };
// 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).
struct Plan
{
Fill fill = Fill::None;
Box box{}; // the quad's, clip space
uint32_t argb = 0; // its colour
uint32_t rectCount = 0;
Rect rects[6]{};
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).
inline bool BackdropCandidate(const Draw& d, const BackdropRule& rule = {})
{
const float* c = d.c;
const bool yScale = d.frontEndY > 0.0f ? std::fabs(std::fabs(c[5]) - d.frontEndY) <= d.frontEndY * 1e-5f
: d.frontEndY == 0.0f && d.scaleY >= 1.0f;
return (d.scaleX < 1.0f || d.scaleY < 1.0f) && !d.occlusion && d.prim == 13 && d.count == 4 && d.frontEndShader && d.leftAlone &&
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 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(d.posScale, d.hostW, d.hostH, d.scissor, d.scaleX, d.scaleY, 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;
}
}
+1236 -292
View File
File diff suppressed because it is too large. Load diff
+65 -4
View File
@@ -231,12 +231,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 +275,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 +350,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)
+2 -1
View File
@@ -19,7 +19,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
+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"
}
+62 -4
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,6 +22,7 @@
#include <cpu/host_cpu.h>
#include <install/install_cli.h>
#include <install/locate.h>
#include <install/placement.h>
#include <install/sha256.h>
#include <report/report.h>
#include <ui/installer_screen.h>
@@ -276,9 +278,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 +311,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;
}
@@ -359,6 +407,16 @@ 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
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);
}
report::NoticePreviousCrash();
// NFSMW_LIFELINE_PID (scripts/steamos/speedbreaker.sh): the launcher's PID. The
+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
+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;
}
}
}
+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;
}
+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
+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;
}
+52 -16
View File
@@ -5,8 +5,9 @@
// 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.
#include <stdafx.h>
#include "ui.h"
@@ -16,6 +17,7 @@
#include <video/presenter.h>
#include <imgui.h>
#include <imgui_internal.h> // ClosePopupToLevel: a dialog left open when the menu closed
namespace ui
{
@@ -29,6 +31,7 @@ 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
constexpr int kCategories = int(Category::Count);
@@ -243,11 +246,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 +260,41 @@ 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();
}
void HelpLine()
{
if (s_describedUpdate)
{
UpdateRowHelp();
return;
}
if (s_describedBugReport)
{
#if defined(__APPLE__) && TARGET_OS_IOS
@@ -374,8 +395,9 @@ 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();
if (int delta = dialog ? 0 : PageDelta())
{
s_category = Category((int(s_category) + delta + kCategories) % kCategories);
s_tabPending = true;
@@ -383,6 +405,7 @@ namespace ui
}
s_described = Id::Count;
s_describedBugReport = false;
s_describedUpdate = false;
if (ImGui::BeginTabBar("pages", ImGuiTabBarFlags_NoTooltip))
{
for (int c = 0; c < kCategories; c++)
@@ -427,7 +450,8 @@ namespace ui
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);
@@ -442,7 +466,10 @@ namespace ui
focusFirst = false;
}
if (category == Category::Advanced)
{
BugReportRow(valueWidth);
UpdateRow(valueWidth);
}
ImGui::EndTable();
}
ImGui::PopStyleVar();
@@ -495,8 +522,10 @@ namespace ui
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,7 +544,9 @@ namespace ui
ImGui::SetItemDefaultFocus();
ImGui::EndPopup();
}
else if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && !ImGui::IsAnyItemActive() &&
if (DrawUpdateDialog())
popup = true;
if (!popup && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && !ImGui::IsAnyItemActive() &&
(ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)))
CloseSettings();
ImGui::End();
@@ -524,5 +555,10 @@ namespace ui
void ResetSettingsMenu()
{
s_opened = true;
// 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();
}
}
+97
View File
@@ -0,0 +1,97 @@
// 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 <imgui.h>
namespace ui
{
namespace
{
constexpr bool kCanQuit =
#if defined(__APPLE__) && TARGET_OS_IOS
false;
#else
true;
#endif
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());
}
}
+39 -7
View File
@@ -5,6 +5,7 @@
#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>
@@ -106,6 +107,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
@@ -739,6 +741,11 @@ namespace ui
}
}
void RequestShot()
{
s_shots++;
}
bool TakeShot()
{
if (s_shots == 0)
@@ -821,21 +828,45 @@ 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).
// (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));
const float x0 = std::clamp(float(safe.x), 0.0f, io.DisplaySize.x), y0 = std::clamp(float(safe.y), 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 +947,7 @@ namespace ui
return;
s_settingsOpen = false;
UpdateCapture();
ForgetUpdateDialog();
report::NoteMenuClosed();
XamNotifySystemUi(false);
if (settings::Dirty() && !settings::Save())
+32 -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,38 @@ 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
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();
// 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();
}
+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());
}
+28 -6
View File
@@ -7,6 +7,8 @@
#include <TargetConditionals.h>
#endif
#include "portable.h"
// Extracted disc contents (default.xex, NFS/, Movies/): where startup found
// or installed the game (install::FindGameInstall, SetGamePath); before
// that, NFSMW_GAME_DIR or the repo layout.
@@ -54,7 +56,7 @@ inline const char*& CacheFolderName()
return name;
}
// Where the per-user folders go.
// Where the per-user folders go (outside portable mode: user/portable.h).
// macOS: ~/Library/Application Support, ~/Library/Caches
// iOS: the same in the app's container; $XDG_DATA_HOME and
// $XDG_CACHE_HOME when set (test runs: iOS sets HOME itself)
@@ -93,21 +95,41 @@ inline std::filesystem::path CacheBase()
#endif
}
// Per-user data: saves, settings, logs and (by default) the installed game.
inline std::filesystem::path GetUserPath()
// This computer's own data and cache folders, portable mode or not
// (install/portable_switch.h copies between them and SpeedBreaker's folder).
inline std::filesystem::path HomeUserPath()
{
return UserDataBase() / DataFolderName();
}
// Caches that can be deleted any time (compiled shaders, pipelines).
inline std::filesystem::path GetCachePath()
inline std::filesystem::path HomeCachePath()
{
return CacheBase() / CacheFolderName();
}
// Per-user data: saves, settings, logs and (by default) the installed game.
// In portable mode, the data folder in SpeedBreaker's own folder.
inline std::filesystem::path GetUserPath()
{
if (const portable::Layout& layout = portable::Current(); layout.Active())
return layout.data;
return HomeUserPath();
}
// Caches that can be deleted any time (compiled shaders, pipelines). In
// portable mode, the cache folder in SpeedBreaker's own folder.
inline std::filesystem::path GetCachePath()
{
if (const portable::Layout& layout = portable::Current(); layout.Active())
return layout.cache;
return HomeCachePath();
}
// Renames nfsmw-recomp folders to the current name, leaving the old name as
// a link to the new folder (for older builds and scripts). Call first thing
// in main; returns what it did, for the log once that is open.
// in main (after portable::Detect: in portable mode it does nothing, since
// the home folder isn't SpeedBreaker's then); returns what it did, for the
// log once that is open.
std::string MigrateUserFolders();
inline std::filesystem::path GetSavePath(bool)
+116
View File
@@ -0,0 +1,116 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See portable.h.
#include "portable.h"
#include <cerrno>
#include <cstdio>
#include <format>
#include <system_error>
namespace portable
{
namespace
{
namespace fs = std::filesystem;
// Normalized, no trailing separator.
fs::path Normal(const fs::path& path)
{
fs::path p = path.lexically_normal();
while (p.has_relative_path() && p.filename().empty())
p = p.parent_path();
return p;
}
// `relative` stays inside the folder it's relative to (not "", ".",
// or "../...").
bool Inside(const fs::path& relative)
{
return !relative.empty() && relative != "." && *relative.begin() != "..";
}
}
Layout Resolve(const std::filesystem::path& root, bool marker, bool supported)
{
Layout layout;
if (!supported || !marker || root.empty() || !root.is_absolute())
return layout;
layout.root = Normal(root);
layout.data = layout.root / kDataFolder;
layout.cache = layout.root / kCacheFolder;
return layout;
}
bool MarkerIn(const std::filesystem::path& root)
{
std::error_code ec;
return !root.empty() && fs::is_regular_file(root / kMarker, ec);
}
const Layout& Detect(const std::filesystem::path& root)
{
CurrentLayout() = Resolve(root, kSupported && MarkerIn(root));
return Current();
}
std::string RecordText(const std::filesystem::path& folder, const Layout& layout)
{
fs::path path = Normal(folder);
if (layout.Active())
if (fs::path relative = path.lexically_relative(layout.root); Inside(relative))
return relative.generic_string();
return path.string();
}
std::optional<std::filesystem::path> ParseRecord(std::string_view text, const Layout& layout)
{
while (!text.empty() && (text.back() == '\n' || text.back() == '\r'))
text.remove_suffix(1);
if (text.empty())
return std::nullopt;
fs::path path{ std::string(text) };
// (Records before v0.1.1 kept `--dest dir/`'s trailing separator.)
if (path.is_absolute())
return Normal(path);
if (!layout.Active())
return std::nullopt;
fs::path relative = path.lexically_normal();
if (!Inside(relative) || relative.has_root_path())
return std::nullopt;
return Normal(layout.root / relative);
}
int CheckWritable(const Layout& layout)
{
if (!layout.Active())
return 0;
std::error_code ec;
fs::create_directories(layout.data, ec);
if (ec)
{
std::error_condition condition = ec.default_error_condition();
return condition.category() == std::generic_category() ? condition.value() : EIO;
}
// A real write: a read-only mount, a locked card or permissions all
// show here, whatever the file system.
fs::path probe = layout.data / ".write-check";
errno = 0;
FILE* f = fopen(probe.string().c_str(), "wb");
if (!f)
return errno ? errno : EACCES;
bool ok = fputc('1', f) != EOF;
int error = errno;
ok = fclose(f) == 0 && ok;
if (!ok && !error)
error = errno;
fs::remove(probe, ec);
return ok ? 0 : (error ? error : EIO);
}
std::string Describe(const Layout& layout)
{
if (!layout.Active())
return {};
return std::format("[paths] portable mode ({} in {}): data in {}, caches in {}\n", kMarker, layout.root.string(),
layout.data.string(), layout.cache.string());
}
}
+114
View File
@@ -0,0 +1,114 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Portable mode (GitHub issue #1): everything SpeedBreaker keeps (settings,
// saves, logs, caches and, by default, the installed game) in SpeedBreaker's
// own folder, so the whole folder can be moved to another machine and run
// there. Opt-in: a file named portable.txt in that folder (the one with
// speedbreaker.sh; a development build's program folder; on Windows, the
// one with SpeedBreaker.exe: install::BundleFolder()). Its contents are
// ignored.
//
// Why a file: it travels with the folder (an environment variable lives in
// a Steam shortcut or a shell profile, per machine, so the next machine
// would quietly use its own data folder), nothing makes it by accident (the
// download never holds one, and unpacking an update over the folder leaves
// it), and players know it from Xenia, Dolphin and DuckStation. Not a data/
// folder as the switch: deleting or renaming a folder would flip the mode
// without a word, and a leftover can't be told from an opt-in.
//
// The layout, in SpeedBreaker's folder (the "root"):
// data/ what GetUserPath() is otherwise (~/.local/share/speedbreaker),
// the same layout: settings.toml, profile_settings.bin, save/,
// logs/ (with crash and hang reports), game-location, and game/,
// the installer's default
// cache/ what GetCachePath() is otherwise (~/.cache/speedbreaker):
// SPIR-V (GPU-independent, keyed by shader source) and the Vulkan
// pipeline cache (its header names the GPU and driver: another
// GPU's driver ignores it and it is rebuilt). Beside data/, so it
// can be deleted without touching saves.
// Nothing outside the folder is read or written for SpeedBreaker's data:
// MigrateUserFolders does nothing, XDG_DATA_HOME and XDG_CACHE_HOME are
// ignored, and no symlink is made. The game's location is recorded relative
// to the folder when it is inside it (RecordText), so it moves with it.
//
// Not on Apple platforms: a signed Mac app can't hold data (macOS stops
// opening it), a folder beside it is App-Translocated or privacy-prompted in
// the common cases, and iOS apps write only their container. There the
// layout is never active.
//
// Switching an existing setup in or out with a copy: install/portable_switch.h
// (speedbreaker.sh --portable on|off). By hand, making or deleting the file
// switches with nothing copied.
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
namespace portable
{
inline constexpr const char* kMarker = "portable.txt";
inline constexpr const char* kDataFolder = "data";
inline constexpr const char* kCacheFolder = "cache";
#if defined(__APPLE__)
inline constexpr bool kSupported = false;
#else
inline constexpr bool kSupported = true;
#endif
// Where portable mode keeps things; all empty when it's off.
struct Layout
{
std::filesystem::path root; // SpeedBreaker's folder: absolute, normalized, no trailing separator
std::filesystem::path data; // root/data
std::filesystem::path cache; // root/cache
bool Active() const { return !root.empty(); }
};
// Pure: the layout for SpeedBreaker's folder `root` (absolute; empty if
// unknown), with the marker in it or not. Off unless supported, marked
// and absolute.
Layout Resolve(const std::filesystem::path& root, bool marker, bool supported = kSupported);
// The layout in effect: set by Detect at startup, before any other thread
// starts and before anything uses a data folder (paths.h reads it).
inline Layout& CurrentLayout()
{
static Layout layout;
return layout;
}
inline const Layout& Current()
{
return CurrentLayout();
}
// Is there a portable.txt in `root`: a file, or a link to one (one stat)?
bool MarkerIn(const std::filesystem::path& root);
// Startup: Resolve(root, MarkerIn(root)) made current, and returned. No
// look at the disk on Apple platforms.
const Layout& Detect(const std::filesystem::path& root);
// The text recorded for the game folder `folder` (absolute, links
// resolved): relative to the root, with '/' separators, when portable
// mode is on and the folder is inside the root; otherwise the absolute
// path.
std::string RecordText(const std::filesystem::path& folder, const Layout& layout);
// The folder a record names, or nullopt: an absolute path as it is (no
// trailing separator); a relative one only in portable mode, inside the
// root (resolved against where the root is now; "../" out of it is
// ignored). Trailing newlines are ignored.
std::optional<std::filesystem::path> ParseRecord(std::string_view text, const Layout& layout);
// Startup, portable mode: makes the data folder if needed and writes a
// small file there (then deletes it). 0 if that worked, else the errno
// (EROFS, EACCES...): settings and saves won't be kept. 0 when off.
int CheckWritable(const Layout& layout);
// One log line: where the data and caches are ("" when off).
std::string Describe(const Layout& layout);
}
+9 -5
View File
@@ -83,11 +83,15 @@ namespace settings
{ int32_t(AspectMode::Widescreen), "16:9", "16:9" },
};
// The scale in half steps (settings.h); the tokens are what
// settings.toml and NFSMW_RESOLUTION hold, so older files read as
// before.
constexpr EnumEntry kResolutionEntries[] = {
{ kResolutionAuto, "auto", "Auto" },
{ 1, "1x", "1x (720p)" },
{ 2, "2x", "2x (1440p)" },
{ 3, "3x", "3x (2160p)" },
{ 2, "1x", "1x (720p)" },
{ 3, "1.5x", "1.5x (1080p)" },
{ 4, "2x", "2x (1440p)" },
{ 6, "3x", "3x (2160p)" },
};
constexpr EnumEntry kAnisotropyEntries[] = {
@@ -130,7 +134,7 @@ namespace settings
.env = "NFSMW_SCALING", .defaultValue = double(ScalingMode::Bicubic), .entries = kScalingEntries },
{ .id = Id::AspectRatio, .category = Category::Display, .type = Type::Enum, .apply = Apply::Live,
.table = "video", .key = "aspect_ratio", .label = "Aspect Ratio",
.description = "Fill other screen shapes: a wider view on an ultrawide, a taller one on a 4:3 iPad, the HUD at 16:9 (the Steam Deck's 16:10 keeps thin bars). Or 16:9 with bars.",
.description = "Fill other screen shapes: a wider view on an ultrawide, a taller one on a Steam Deck or a 4:3 iPad, the HUD at 16:9. Or 16:9 with bars.",
.env = "NFSMW_ASPECT", .defaultValue = double(AspectMode::Auto), .entries = kAspectEntries },
{ .id = Id::Sharpening, .category = Category::Graphics, .type = Type::Float, .apply = Apply::Live,
.table = "video", .key = "sharpening", .label = "Sharpening",
@@ -146,7 +150,7 @@ namespace settings
.env = "NFSMW_TEXTURE_MIPS", .defaultValue = 1 },
{ .id = Id::InternalResolution, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
.table = "video", .key = "internal_resolution", .label = "Internal Resolution",
.description = "Render the 3D scene above the 360's 720p. Auto: from the screen (1440p: 2x, or 3x2 filling an ultrawide).",
.description = "Render the 3D scene above the 360's 720p. 1.5x is 1080p, a little over half the pixels of 2x. Auto: from the screen (1440p: 2x, or 3x2 filling an ultrawide).",
.env = "NFSMW_RESOLUTION", .defaultValue = kResolutionAuto, .entries = kResolutionEntries },
{ .id = Id::WindowWidth, .category = Category::Display, .type = Type::Int, .apply = Apply::Restart, .hidden = true,
.table = "video", .key = "window_width", .label = "Window Width",
+7 -5
View File
@@ -76,14 +76,16 @@ namespace settings
// Frame Rate: 60, 30, or Auto (video/frame_rate.h: 60, or 30 while the
// device is hot or can't hold 60).
constexpr int32_t kFrameRateAuto = 0;
// Internal resolution: 1-3 renders at that multiple of 720p; Auto picks
// it from the screen area the picture covers (see renderer).
// Internal resolution: the multiple of 720p it renders at, in half steps
// (2: 1x, 3: 1.5x = 1080p, 4: 2x, 6: 3x; only the tokens "1x", "1.5x"...
// are saved, so the numbers are free to change); Auto picks it from the
// screen area the picture covers (see renderer).
constexpr int32_t kResolutionAuto = 0;
// Auto: a screen of another shape is filled (video/picture_fit), the HUD
// kept in a centred 16:9 block: a wider one with a wider view (Hor+), a
// narrower one (4:3, 3:2) with a taller view (Vert+, down to 4:3). 16:10
// (the Steam Deck, 1280x800 Macs) is too close to 16:9 for that and keeps
// thin bars (video::kTallMinStretch). Widescreen: 16:9 with bars.
// narrower one (16:10: the Steam Deck, 1280x800 Macs; 3:2, 4:3) with a
// taller view (Vert+, down to 4:3). Only a screen within 1% of 16:9 keeps
// the 16:9 frame (video::kTallMinStretch). Widescreen: 16:9 with bars.
enum class AspectMode : int32_t { Auto = 0, Widescreen = 1 };
// Anisotropy: 1 (off), 2, 4, 8, 16, or one of these.
constexpr int32_t kAnisotropyAuto = 0; // 16x on a discrete GPU or an M-series iPad, 4x on other integrated ones
+59
View File
@@ -0,0 +1,59 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Post-processing that stays on its side of the 16:9 band's edge
// (video/presenter.cpp's anti-aliasing and scaling shaders do this in GLSL,
// line for line; tests/band_filter_test.cpp checks it here).
//
// Under Vert+ and Hor+ the frame holds the 16:9 band (video::BandOf) and the
// strips past it, and what the game draws often ends exactly at the band's
// edge: the movie player letterboxes (or pillarboxes) a movie into the band
// (game/ultrawide.cpp tells it the screen's aspect), and the edge-bar fills
// black out the strips (gpu/edge_bars.h). Both filters read across that
// edge: the anti-aliasing (an FXAA-style pass at the frame's resolution)
// blended the strip's last row towards the band's first, by up to half on a
// textured edge, and the scaling (Catmull-Rom from bilinear taps) a few per
// cent more; the career intro's movie (storyfmv_rac01) showed as a faint row
// just past each band edge (max 40-58 of 255 at 1280x800, the frame's own
// rows exactly black). With this each pixel's taps are kept to its own span
// of the three ([0, band start), the band, [band end, size)) along each
// axis, as if that span were the whole image (clamp to edge): a strip pixel
// reads only the strip, a band pixel only the band.
//
// Units: texels of the frame image, 0 at its edge, texel i covering
// [i, i + 1) with its centre at i + 0.5.
#pragma once
#include <algorithm>
#include <cstdint>
namespace video::band
{
// The span along one axis holding coordinate t: [0, lo), [lo, hi) or
// [hi, size); its first texel `a` and end `b`.
struct Span
{
int32_t a, b;
};
inline Span SpanOf(float t, int32_t lo, int32_t hi, int32_t size)
{
if (t < float(lo))
return { 0, lo };
if (t < float(hi))
return { lo, hi };
return { hi, size };
}
// A sample coordinate kept between the span's first and last texel
// centres: a bilinear fetch there weighs only that span's texels.
inline float Clamp(float t, Span s)
{
return std::clamp(t, float(s.a) + 0.5f, float(s.b) - 0.5f);
}
// A texel index kept in the span (a gather's 2x2: the row or column
// past the span's edge reads its edge one instead).
inline int32_t ClampTexel(int32_t i, Span s)
{
return std::clamp(i, s.a, s.b - 1);
}
}
+224
View File
@@ -0,0 +1,224 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Which Vulkan device the presenter draws with (video/presenter.cpp,
// ChooseDevice), and the log's one line about it. Up to v0.1.1 it was the
// loader's first that could present to the window. On a laptop with two GPUs
// (an Intel or AMD one in the processor beside an NVIDIA or AMD graphics
// card) the loader, and Mesa's device-select layer, usually list the
// integrated one first, so the game ran there (GitHub PR #6). Now, of the
// devices that can present to the window:
// 1. one with every device extension the game needs, before one without
// (the game can't start on that);
// 2. a discrete GPU, then an integrated one, a virtual one, any other kind,
// then a software renderer (llvmpipe); a layered one (Mesa's Dozen,
// Vulkan on Direct3D 12, which lists the same GPUs again) after every
// native driver's;
// 3. among equals, the one with the most device-local memory;
// 4. then the loader's order.
// A device that can't present to the window is never chosen. So where only
// one can present (a Steam Deck, a Steam Machine, a Steam Frame, a Mac, an
// iPhone or iPad: one GPU each), it is that one, as before.
// NFSMW_GPU=<index or part of a name> picks one by hand: an index from the
// log's list, or any part of a name, in any case (several matching: the best
// of them by the rules above). A number that is no device's index is tried as
// part of a name ("3050").
//
// Header only and Vulkan-free (`type` holds VkPhysicalDeviceType's values):
// tests/gpu_choice_test.cpp. The windows branch's first version of this file
// had rules 2 and 4 (and NFSMW_GPU); this one adds 1 and 3, and the log line.
#pragma once
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <format>
#include <string>
#include <vector>
namespace video
{
struct GpuCandidate
{
size_t index; // in the loader's list (vkEnumeratePhysicalDevices)
int type; // VkPhysicalDeviceType: 1 integrated, 2 discrete, 3 virtual, 4 CPU, else other
bool layered = false; // Mesa's Dozen: Vulkan on Direct3D 12
std::string name; // deviceName
uint64_t localBytes = 0; // its device-local memory heaps, added up
bool presents = true; // a graphics queue family can present to the window
std::string lacks; // a device extension the game needs that it doesn't have ("": none)
};
inline constexpr size_t kNoGpu = size_t(-1);
inline const char* GpuKind(int type)
{
return type == 2 ? "discrete" : type == 1 ? "integrated" : type == 3 ? "virtual" : type == 4 ? "software" : "other";
}
// "a discrete GPU", "an integrated GPU on Direct3D 12".
inline std::string GpuDescription(const GpuCandidate& c)
{
const char* text = c.type == 2 ? "a discrete GPU" : c.type == 1 ? "an integrated GPU" : c.type == 3 ? "a virtual GPU"
: c.type == 4 ? "a software renderer" : "a device of another kind";
return std::format("{}{}", text, c.layered ? " on Direct3D 12" : "");
}
// "7.6 GB" (binary gigabytes, as drivers size their heaps).
inline std::string GpuMemory(uint64_t bytes)
{
return std::format("{:.1f} GB", double(bytes) / double(1ull << 30));
}
// Rule 2's order: lower comes first.
inline int GpuKindRank(const GpuCandidate& c)
{
int kind = c.type == 2 ? 0 : c.type == 1 ? 1 : c.type == 3 ? 2 : c.type == 4 ? 4 : 3;
return kind + (c.layered ? 5 : 0);
}
// Rules 1-4: whether `a` comes before `b`.
inline bool GpuBefore(const GpuCandidate& a, const GpuCandidate& b)
{
if (a.lacks.empty() != b.lacks.empty())
return a.lacks.empty();
if (GpuKindRank(a) != GpuKindRank(b))
return GpuKindRank(a) < GpuKindRank(b);
if (a.localBytes != b.localBytes)
return a.localBytes > b.localBytes;
return a.index < b.index;
}
struct GpuChoice
{
size_t pick = kNoGpu; // into the list; kNoGpu: none can present to the window
std::string why; // why that one, for the log
std::string want; // NFSMW_GPU as given ("": not set)
std::string wantNote; // when NFSMW_GPU was set but not used: why not ("": used, or not set)
};
namespace detail
{
inline std::string Lower(std::string text)
{
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return char(std::tolower(c)); });
return text;
}
inline std::string Trim(const char* text)
{
std::string s(text ? text : "");
size_t b = s.find_first_not_of(" \t"), e = s.find_last_not_of(" \t");
return b == std::string::npos ? std::string() : s.substr(b, e - b + 1);
}
// The best of the presenting devices, by rules 1-4 (kNoGpu: none).
inline size_t Best(const std::vector<GpuCandidate>& devices, size_t skip = kNoGpu)
{
size_t best = kNoGpu;
for (size_t i = 0; i < devices.size(); i++)
if (i != skip && devices[i].presents && (best == kNoGpu || GpuBefore(devices[i], devices[best])))
best = i;
return best;
}
// Why `pick` came before `next` (the best of the rest), by the first
// rule that tells them apart.
inline std::string Why(const GpuCandidate& pick, const GpuCandidate& next)
{
if (pick.lacks.empty() != next.lacks.empty())
return std::format("{} {} lacks {}, which the game needs", next.index, next.name, next.lacks);
if (GpuKindRank(pick) != GpuKindRank(next))
return std::format("{} before {}", GpuDescription(pick), GpuDescription(next));
if (pick.localBytes != next.localBytes)
return std::format("{}, with more device-local memory than {}: {} against {}", GpuDescription(pick), next.index,
GpuMemory(pick.localBytes), GpuMemory(next.localBytes));
return std::format("{}, with as much device-local memory as {}: the loader's order", GpuDescription(pick), next.index);
}
}
// `devices`: every device the loader lists, in its order. `want`:
// NFSMW_GPU, or null.
inline GpuChoice ChooseGpu(const std::vector<GpuCandidate>& devices, const char* want)
{
GpuChoice choice;
choice.pick = detail::Best(devices);
if (choice.pick == kNoGpu)
return choice;
const size_t next = detail::Best(devices, choice.pick);
choice.why = next == kNoGpu ? std::string("the only one that can present to the window")
: detail::Why(devices[choice.pick], devices[next]);
choice.want = detail::Trim(want);
if (choice.want.empty())
return choice;
// An index from the log's list, else part of a name.
size_t named = kNoGpu;
char* end = nullptr;
unsigned long long index = std::strtoull(choice.want.c_str(), &end, 10);
if (end != choice.want.c_str() && *end == '\0' && std::isdigit((unsigned char)choice.want[0]))
for (size_t i = 0; i < devices.size() && named == kNoGpu; i++)
if (devices[i].index == index)
named = i;
bool anyName = false;
if (named == kNoGpu)
{
const std::string part = detail::Lower(choice.want);
for (size_t i = 0; i < devices.size(); i++)
if (detail::Lower(devices[i].name).find(part) != std::string::npos)
{
anyName = true;
if (devices[i].presents && (named == kNoGpu || GpuBefore(devices[i], devices[named])))
named = i;
}
}
if (named != kNoGpu && devices[named].presents)
{
choice.pick = named;
choice.why = std::format("NFSMW_GPU={}", choice.want);
return choice;
}
choice.wantNote = named != kNoGpu
? std::format("NFSMW_GPU={} names {} {}, which can't present to the window", choice.want, devices[named].index, devices[named].name)
: anyName ? std::format("NFSMW_GPU={} names only devices that can't present to the window", choice.want)
: std::format("NFSMW_GPU={} names none of them (an index from this list, or part of a name)", choice.want);
return choice;
}
// The log's line (after "[video] "): every device the loader listed, the
// one used, and why.
// Vulkan devices: 0 Intel(R) Iris(R) Xe Graphics (integrated, 7.6 GB),
// 1 NVIDIA GeForce RTX 3050 Laptop GPU (discrete, 4.0 GB); using 1: a
// discrete GPU before an integrated GPU (the loader listed 0 first);
// NFSMW_GPU=<index or part of a name> picks another
inline std::string GpuLogLine(const std::vector<GpuCandidate>& devices, const GpuChoice& choice)
{
std::string line = "Vulkan devices: ";
if (devices.empty())
line += "none";
size_t presenting = 0, first = kNoGpu;
for (size_t i = 0; i < devices.size(); i++)
{
const GpuCandidate& c = devices[i];
line += std::format("{}{} {} ({}{}{}{}{})", i ? ", " : "", c.index, c.name, GpuKind(c.type),
c.layered ? " on Direct3D 12" : "", c.localBytes ? ", " + GpuMemory(c.localBytes) : std::string(),
c.presents ? "" : ", can't present to the window", c.lacks.empty() ? std::string() : ", lacks " + c.lacks);
if (c.presents)
{
presenting++;
if (first == kNoGpu)
first = i;
}
}
if (choice.pick == kNoGpu)
return line + "; none can present to the window";
line += std::format("; using {}: {}", devices[choice.pick].index,
choice.wantNote.empty() ? choice.why : choice.wantNote + ", so the usual choice: " + choice.why);
if (presenting > 1)
{
line += choice.pick == first ? " (the loader listed it first)" : std::format(" (the loader listed {} first)", devices[first].index);
if (choice.want.empty())
line += "; NFSMW_GPU=<index or part of a name> picks another";
}
return line;
}
}
+18
View File
@@ -1,6 +1,8 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See picture_fit.h.
#include "picture_fit.h"
#include <gpu/edge_bars.h>
#include <algorithm>
#include <cmath>
@@ -58,6 +60,22 @@ namespace video
return fit;
}
FrameBand BandOf(uint32_t width, uint32_t height, float scaleX, float scaleY)
{
FrameBand band{ 0, int32_t(width), 0, int32_t(height) };
if (scaleX < 1.0f)
{
band.x0 = gpu::bars::Edge(-scaleX, float(width));
band.x1 = gpu::bars::Edge(scaleX, float(width));
}
if (scaleY < 1.0f)
{
band.y0 = gpu::bars::Edge(-scaleY, float(height));
band.y1 = gpu::bars::Edge(scaleY, float(height));
}
return band;
}
void AutoRenderScale(uint32_t regionW, uint32_t regionH, uint32_t budget, uint32_t& sx, uint32_t& sy)
{
// Enough host pixels to cover the screen area the frame fills
+24 -9
View File
@@ -9,10 +9,11 @@
// and the game's view is changed so the stretch doesn't distort it.
// - Wider than 16:9 by more than 1% (an ultrawide, a 19.5:9 iPhone): Hor+,
// scaleX = 16:9 / screen aspect.
// - Narrower than 16:9 by more than kTallMinStretch (a 4:3 iPad, a 3:2
// screen; not yet 16:10): Vert+, scaleY = screen aspect / 16:9, the same
// horizontal view with more above and below it. Down to 4:3: a screen
// narrower still (5:4, portrait) shows a 4:3 picture, letterboxed.
// - Narrower than 16:9 by more than kTallMinStretch, 1% (16:10: the Steam
// Deck, 16:10 Macs; a 3:2 screen, a 4:3 iPad): Vert+, scaleY = screen
// aspect / 16:9, the same horizontal view with more above and below it.
// Down to 4:3: a screen narrower still (5:4, portrait) shows a 4:3
// picture, letterboxed.
// Otherwise (16:9 within those margins, the 16:9 setting, or a ppc/ without
// the hooks) the 16:9 frame is letterboxed as it always was.
#pragma once
@@ -23,11 +24,13 @@ namespace video
// The narrowest picture Vert+ makes: 4:3, at most 4/3 the vertical
// view's tan of the half-angle.
constexpr float kTallMinAspect = 4.0f / 3.0f;
// Vert+ when 16:9 is wider than the screen by more than this factor:
// a 4:3 iPad (1.33) and 3:2 (1.19) fill. 16:10 (1.11: the Steam Deck,
// Macs) keeps its bars until a Deck run shows Vert+ costs it nothing and
// the 1.11 vertical stretch of its 720 rows reads well; 1.01 fills it.
constexpr float kTallMinStretch = 1.12f;
// Vert+ when 16:9 is wider than the screen by more than this factor,
// the same 1% margin as Hor+: 16:10 (1.11: the Steam Deck's 1280x800,
// 16:10 Macs), 3:2 (1.19) and a 4:3 iPad (1.33) fill. 16:9 itself, and
// a screen within 1% of it (1360x768), keeps the letterboxed 16:9 frame.
// On the Deck, AutoRenderScale keeps 1x1, so the frame's 720 rows are
// stretched to 800 (1.11) rather than drawn 1:1 in a 720-row box.
constexpr float kTallMinStretch = 1.01f;
struct PictureFit
{
@@ -50,6 +53,18 @@ namespace video
// for another one (a redraw after a resize) or clamped to 4:3.
PictureFit PlacePicture(uint32_t screenW, uint32_t screenH, float frameW, float frameH, float scaleX, float scaleY);
// The 16:9 band of a frame image of width x height texels (any internal
// resolution) rendered with the view scales (scaleX, scaleY): what a
// 16:9 screen shows, the columns [x0, x1) and rows [y0, y1) whose
// centres lie inside clip +-scaleX and +-scaleY, by gpu::bars::Edge's
// rule, so the presenter splits a frame where the renderer's band clip
// and edge-bar fills do. The whole image at 1, 1.
struct FrameBand
{
int32_t x0 = 0, x1 = 0, y0 = 0, y1 = 0;
};
FrameBand BandOf(uint32_t width, uint32_t height, float scaleX, float scaleY);
// Auto internal resolution: the render scale (sx x sy host pixels per
// guest pixel, 1-3 each) for a picture covering regionW x regionH of the
// screen (video::FrameRegion), within `budget` host pixels per guest
+424 -83
View File
@@ -9,8 +9,10 @@
#include <apu/audio.h>
#include <gpu/renderer.h>
#include <game/ultrawide.h>
#include "gpu_choice.h"
#include "picture_fit.h"
#include "frame_rate.h"
#include "swapchain_policy.h"
#include <cpu/guest_thread.h>
#include <cpu/guest_time.h>
#include "vblank_lock.h"
@@ -21,6 +23,7 @@
#include <user/settings.h>
#include <report/report.h>
#include <platform/thermal.h>
#include <kernel/memory_status.h>
#include <SDL3/SDL.h>
#include <SDL3/SDL_vulkan.h>
@@ -155,10 +158,18 @@ namespace video
std::condition_variable s_foreground;
bool SDLCALL OnAppEvent(void*, SDL_Event* e);
// Main thread: RunFrame stopped the controller's rumble for the
// suspension (hid::Suspend), and a window resize waits for the
// resume to rebuild the swapchain (no GPU work before it).
// suspension (hid::Suspend).
bool s_inputSuspended = false;
bool s_swapchainStale = false;
// When the swapchain is built again, and at what size (main thread;
// swapchain_policy.h): not while the app is inactive, the surface
// watched after a resume, a size event or a new swapchain,
// SUBOPTIMAL/OUT_OF_DATE followed, SDL's view fitted back to its
// window on iOS, and no portrait swapchain on a landscape iPhone.
SwapchainPolicy s_swapPolicy;
// What the next CreateSwapchain is for (its log line), and how many
// rebuilds at an unchanged size have been logged.
const char* s_swapReason = nullptr;
int s_sameSizeLogged = 0;
// Bug-report screenshots waiting for the next frame drawn (CaptureFrame).
std::mutex s_captureMutex;
std::vector<std::promise<CapturedFrame>> s_captureRequests;
@@ -349,6 +360,57 @@ namespace video
}
}
// The surface's extent: Vulkan's current extent, else (Wayland: the
// swapchain decides) the window's pixels.
Extent SurfaceExtent(const VkSurfaceCapabilitiesKHR& caps)
{
if (caps.currentExtent.width != UINT32_MAX)
return { caps.currentExtent.width, caps.currentExtent.height };
int w = 0, h = 0;
SDL_GetWindowSizeInPixels(s_window, &w, &h);
return { uint32_t(std::max(w, 0)), uint32_t(std::max(h, 0)) };
}
Extent SurfaceExtent()
{
VkSurfaceCapabilitiesKHR caps{};
if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(s_physical, s_surface, &caps) != VK_SUCCESS)
return {};
return SurfaceExtent(caps);
}
// The interface's orientation and whether the app is landscape-only
// there: iOS's (an iPhone is), unknown and no elsewhere.
Orientation InterfaceNow()
{
#if defined(__APPLE__) && TARGET_OS_IOS
return platform::ios::InterfaceOrientation(s_window);
#else
return Orientation::Unknown;
#endif
}
bool LandscapeOnlyHere()
{
#if defined(__APPLE__) && TARGET_OS_IOS
return platform::ios::LandscapeOnly();
#else
return false;
#endif
}
// iOS: the view, its window and the scene, for a log line ("" elsewhere).
std::string GeometryNote()
{
#if defined(__APPLE__) && TARGET_OS_IOS
return ": " + platform::ios::DescribeGeometry(s_window);
#else
return {};
#endif
}
void UpdateDisplayItem();
void DestroySwapchain()
{
{
@@ -380,16 +442,22 @@ namespace video
#endif
VkSurfaceCapabilitiesKHR caps;
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(s_physical, s_surface, &caps);
int w, h;
SDL_GetWindowSizeInPixels(s_window, &w, &h);
s_swapExtent = caps.currentExtent.width != UINT32_MAX ? caps.currentExtent
: VkExtent2D{ uint32_t(w), uint32_t(h) };
const VkExtent2D previous = s_swapExtent;
// A landscape-only iPhone takes no portrait swapchain under a
// landscape interface (swapchain_policy.h): the same size turned,
// while the view is fitted back to its window.
const Extent surface = SurfaceExtent(caps);
const ExtentChoice choice = ChooseExtent(surface, InterfaceNow(), LandscapeOnlyHere());
s_swapExtent = { choice.extent.width, choice.extent.height };
if (s_swapExtent.width == 0 || s_swapExtent.height == 0)
return false; // minimised
if (choice.refused)
fprintf(stderr, "[video] the surface is %ux%u, portrait, under a landscape interface: the swapchain is %ux%u instead\n",
surface.width, surface.height, s_swapExtent.width, s_swapExtent.height);
s_regionExtent.store(uint64_t(s_swapExtent.width) << 32 | s_swapExtent.height, std::memory_order_relaxed);
// The UI draws to the drawables' size, not the window's pixels
// (0, 0: they're the window's).
ui::SetFramebufferSize(s_presentScale < 1.0f ? s_swapExtent.width : 0, s_presentScale < 1.0f ? s_swapExtent.height : 0);
// The UI lays out at and draws to the swapchain's size (the
// drawables' with iOS's present scale), not the window's pixels.
ui::SetFramebufferSize(s_swapExtent.width, s_swapExtent.height);
uint32_t count = 0;
vkGetPhysicalDeviceSurfaceFormatsKHR(s_physical, s_surface, &count, nullptr);
@@ -479,13 +547,25 @@ namespace video
}
if (s_uiReady)
ui::SetFormat(s_swapFormat);
s_swapPolicy.Created({ s_swapExtent.width, s_swapExtent.height });
static VkExtent2D logged{};
const char* reason = std::exchange(s_swapReason, nullptr);
if (logged.width != s_swapExtent.width || logged.height != s_swapExtent.height)
{
if (logged.width) // the first one is logged with the device
fprintf(stderr, "[video] swapchain %ux%u\n", s_swapExtent.width, s_swapExtent.height);
{
fprintf(stderr, "[video] swapchain %ux%u (was %ux%u%s%s)%s\n", s_swapExtent.width, s_swapExtent.height, logged.width,
logged.height, reason ? "; " : "", reason ? reason : "", GeometryNote().c_str());
UpdateDisplayItem();
}
logged = s_swapExtent;
}
else if (reason && previous.width && s_sameSizeLogged < 10)
{
s_sameSizeLogged++;
fprintf(stderr, "[video] swapchain %ux%u again (%s)%s\n", s_swapExtent.width, s_swapExtent.height, reason,
s_sameSizeLogged == 10 ? "; no more same-size rebuilds logged" : "");
}
vkGetSwapchainImagesKHR(s_device, s_swapchain, &count, nullptr);
s_swapImages.resize(count);
@@ -683,13 +763,30 @@ void main()
layout(local_size_x = 8, local_size_y = 8) in;
layout(set = 0, binding = 0) uniform sampler2D src;
layout(set = 0, binding = 1, rgba8) uniform writeonly image2D dst;
layout(push_constant) uniform P { ivec2 size; float subpixel; float pad; } p;
// band: the 16:9 band's columns x..y and rows z..w (video::BandOf), used
// when bandOn is 1 (video/band_filter.h).
layout(push_constant) uniform P { ivec2 size; float subpixel; uint bandOn; ivec4 band; } p;
const float kEdgeMin = 0.0312; // no AA on darker contrast than this
const float kEdgeRelative = 0.125; // ... or than this fraction of the local maximum
// This pixel's span of the band's three along each axis, as uv: every fetch
// stays between its first and last texel centres (band_filter.h's Clamp).
vec2 spanLo = vec2(0.0), spanHi = vec2(1.0);
float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
vec3 at(vec2 uv) { return textureLod(src, uv, 0.0).rgb; }
vec3 at(vec2 uv)
{
if (p.bandOn != 0u)
uv = clamp(uv, spanLo, spanHi);
return textureLod(src, uv, 0.0).rgb;
}
// band_filter.h's SpanOf: [0, lo), [lo, hi) or [hi, size) holding t.
vec2 spanOf(float t, int lo, int hi, int size)
{
return t < float(lo) ? vec2(0.0, float(lo)) : t < float(hi) ? vec2(float(lo), float(hi)) : vec2(float(hi), float(size));
}
void main()
{
@@ -698,6 +795,12 @@ void main()
return;
vec2 texel = 1.0 / vec2(p.size);
vec2 uv = (vec2(pix) + 0.5) * texel;
if (p.bandOn != 0u)
{
vec2 sx = spanOf(float(pix.x) + 0.5, p.band.x, p.band.y, p.size.x), sy = spanOf(float(pix.y) + 0.5, p.band.z, p.band.w, p.size.y);
spanLo = (vec2(sx.x, sy.x) + 0.5) * texel;
spanHi = (vec2(sx.y, sy.y) - 0.5) * texel;
}
vec3 center = at(uv);
float lM = luma(center);
float lN = luma(at(uv + vec2(0.0, -texel.y))), lS = luma(at(uv + vec2(0.0, texel.y)));
@@ -767,9 +870,37 @@ void main()
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 color;
layout(set = 0, binding = 0) uniform sampler2D src;
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; } p;
// band: the 16:9 band's columns x..y and rows z..w in texels (video::BandOf),
// used when bandOn is 1 (video/band_filter.h).
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; vec4 band; uint bandOn; } p;
vec3 at(vec2 t) { return textureLod(src, t, 0.0).rgb; }
// The span of the band's three along each axis that this pixel's centre
// falls in, as uv: every tap stays between its first and last texel centres
// (band_filter.h's Clamp), so a strip's pixel reads only the strip.
vec2 spanLo = vec2(0.0), spanHi = vec2(1.0);
vec3 at(vec2 t)
{
if (p.bandOn != 0u)
t = clamp(t, spanLo, spanHi);
return textureLod(src, t, 0.0).rgb;
}
// band_filter.h's SpanOf: [0, lo), [lo, hi) or [hi, size) holding t.
vec2 spanOf(float t, float lo, float hi, float size)
{
return t < lo ? vec2(0.0, lo) : t < hi ? vec2(lo, hi) : vec2(hi, size);
}
void setSpan()
{
if (p.bandOn == 0u)
return;
vec2 pos = uv * p.size;
vec2 sx = spanOf(pos.x, p.band.x, p.band.y, p.size.x), sy = spanOf(pos.y, p.band.z, p.band.w, p.size.y);
spanLo = (vec2(sx.x, sy.x) + 0.5) / p.size;
spanHi = (vec2(sx.y, sy.y) - 0.5) / p.size;
}
// Catmull-Rom bicubic from 9 bilinear taps: the middle two weights of each
// axis are folded into one bilinear fetch.
@@ -791,6 +922,7 @@ vec3 catmullRom(vec2 t, vec2 texel)
void main()
{
setSpan();
if (p.bicubic == 0u)
{
color = vec4(at(uv), 1.0);
@@ -820,12 +952,34 @@ void main()
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 color;
layout(set = 0, binding = 0) uniform sampler2D src;
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; } p;
// band, bandOn: as kScaleFs's.
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; vec4 band; uint bandOn; } p;
vec3 at(vec2 t) { return textureLod(src, t, 0.0).rgb; }
vec2 spanLo = vec2(0.0), spanHi = vec2(1.0); // as kScaleFs's
vec3 at(vec2 t)
{
if (p.bandOn != 0u)
t = clamp(t, spanLo, spanHi);
return textureLod(src, t, 0.0).rgb;
}
vec2 spanOf(float t, float lo, float hi, float size)
{
return t < lo ? vec2(0.0, lo) : t < hi ? vec2(lo, hi) : vec2(hi, size);
}
void main()
{
vec2 sx = vec2(0.0), sy = vec2(0.0);
if (p.bandOn != 0u)
{
vec2 pos = uv * p.size;
sx = spanOf(pos.x, p.band.x, p.band.y, p.size.x);
sy = spanOf(pos.y, p.band.z, p.band.w, p.size.y);
spanLo = (vec2(sx.x, sy.x) + 0.5) / p.size;
spanHi = (vec2(sx.y, sy.y) - 0.5) / p.size;
}
if (p.bicubic == 0u)
{
color = vec4(at(uv), 1.0);
@@ -846,6 +1000,15 @@ void main()
vec2 mid = (c + 0.5) * texel;
vec4 r = textureGather(src, mid, 0), gr = textureGather(src, mid, 1), b = textureGather(src, mid, 2);
vec3 a = vec3(r.w, gr.w, b.w), e = vec3(r.z, gr.z, b.z), d = vec3(r.x, gr.x, b.x), h = vec3(r.y, gr.y, b.y);
if (p.bandOn != 0u)
{
// The gather's row or column past this pixel's span reads the
// span's edge one instead (band_filter.h's ClampTexel): rows a e
// (first) and d h, columns a d (first) and e h.
vec2 first = c - 0.5;
if (first.y < sy.x) { a = d; e = h; } else if (first.y + 1.0 >= sy.y) { d = a; h = e; }
if (first.x < sx.x) { a = e; d = h; } else if (first.x + 1.0 >= sx.y) { e = a; h = d; }
}
vec3 middle = mix(mix(a, e, g.x), mix(d, h, g.x), g.y);
vec3 bilinear = mix(mix(a, e, f.x), mix(d, h, f.x), f.y);
float wN = w12.x * w0.y, wW = w0.x * w12.y, wM = w12.x * w12.y, wE = w3.x * w12.y, wS = w12.x * w3.y;
@@ -986,7 +1149,7 @@ void main()
dslci.bindingCount = 1;
dslci.pBindings = &scaleBinding;
vkCreateDescriptorSetLayout(s_device, &dslci, nullptr, &s_post.scaleSet);
VkPushConstantRange aaRange{ VK_SHADER_STAGE_COMPUTE_BIT, 0, 16 }, scaleRange{ VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16 };
VkPushConstantRange aaRange{ VK_SHADER_STAGE_COMPUTE_BIT, 0, 32 }, scaleRange{ VK_SHADER_STAGE_FRAGMENT_BIT, 0, 36 };
VkPipelineLayoutCreateInfo plci{ VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO };
plci.setLayoutCount = 1;
plci.pSetLayouts = &s_post.aaSet;
@@ -1110,7 +1273,9 @@ void main()
{
WaitIdle();
DestroySwapchain();
s_swapReason = "V-Sync or Screen Resolution";
CreateSwapchain();
s_swapReason = nullptr;
}
}
@@ -1135,6 +1300,80 @@ void main()
settings::Set(settings::Id::WindowHeight, h);
}
}
// The device extensions the game can't start without: Initialize
// enables them, and ChooseDevice puts a device with all of them first.
constexpr const char* kNeededDeviceExtensions[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME,
VK_KHR_DYNAMIC_RENDERING_EXTENSION_NAME, VK_KHR_DEPTH_STENCIL_RESOLVE_EXTENSION_NAME,
VK_KHR_CREATE_RENDERPASS_2_EXTENSION_NAME, VK_KHR_MULTIVIEW_EXTENSION_NAME, VK_KHR_MAINTENANCE_2_EXTENSION_NAME,
VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME };
// The Vulkan device to draw with (s_physical, s_queueFamily; left
// null when none can present to the window), and the log's one line
// listing every device the loader found, the one used and why
// (video/gpu_choice.h: a discrete GPU first, NFSMW_GPU=<index or part
// of a name> to pick another). Up to v0.1.1 it was the loader's first
// that could present, which on a laptop with two GPUs was usually the
// integrated one; where only one device can present it still is that
// one. A device's queue family is its first with graphics that can
// present to the window, as before.
void ChooseDevice(const std::vector<VkPhysicalDevice>& devices)
{
std::vector<GpuCandidate> candidates;
std::vector<uint32_t> families;
for (size_t i = 0; i < devices.size(); i++)
{
VkPhysicalDevice d = devices[i];
VkPhysicalDeviceProperties props;
vkGetPhysicalDeviceProperties(d, &props);
uint32_t qc = 0;
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, nullptr);
std::vector<VkQueueFamilyProperties> qs(qc);
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, qs.data());
uint32_t family = UINT32_MAX;
for (uint32_t f = 0; f < qc && family == UINT32_MAX; f++)
{
VkBool32 present = VK_FALSE;
vkGetPhysicalDeviceSurfaceSupportKHR(d, f, s_surface, &present);
if ((qs[f].queueFlags & VK_QUEUE_GRAPHICS_BIT) && present)
family = f;
}
VkPhysicalDeviceMemoryProperties memory;
vkGetPhysicalDeviceMemoryProperties(d, &memory);
uint64_t local = 0;
for (uint32_t h = 0; h < memory.memoryHeapCount; h++)
if (memory.memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT)
local += memory.memoryHeaps[h].size;
uint32_t extCount = 0;
vkEnumerateDeviceExtensionProperties(d, nullptr, &extCount, nullptr);
std::vector<VkExtensionProperties> exts(extCount);
vkEnumerateDeviceExtensionProperties(d, nullptr, &extCount, exts.data());
std::string lacks;
for (const char* needed : kNeededDeviceExtensions)
if (lacks.empty() && std::none_of(exts.begin(), exts.end(),
[needed](const VkExtensionProperties& e) { return strcmp(e.extensionName, needed) == 0; }))
lacks = needed;
// Mesa's Dozen (Vulkan on Direct3D 12: Windows, WSL) lists GPUs
// a native driver may list too.
bool dozen = false;
if (props.apiVersion >= VK_API_VERSION_1_2)
{
VkPhysicalDeviceDriverProperties driver{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
VkPhysicalDeviceProperties2 props2{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2 };
props2.pNext = &driver;
vkGetPhysicalDeviceProperties2(d, &props2);
dozen = driver.driverID == VK_DRIVER_ID_MESA_DOZEN;
}
candidates.push_back({ i, int(props.deviceType), dozen, props.deviceName, local, family != UINT32_MAX, lacks });
families.push_back(family);
}
GpuChoice choice = ChooseGpu(candidates, std::getenv("NFSMW_GPU"));
fprintf(stderr, "[video] %s\n", GpuLogLine(candidates, choice).c_str());
if (choice.pick == kNoGpu)
return;
s_physical = devices[choice.pick];
s_queueFamily = families[choice.pick];
}
}
bool Initialize()
@@ -1186,6 +1425,9 @@ void main()
// the one swipe the windowed app had. ("2" defers them, if touch
// players leave the game by accident.)
SDL_SetHint(SDL_HINT_IOS_HIDE_HOME_INDICATOR, "0");
// On iPhone, landscape only to SDL too: else it turns its view
// portrait when a keyboard shows or hides (ios_ui.h).
platform::ios::PrepareOrientation();
#endif
s_window = SDL_CreateWindow("SpeedBreaker", windowW, windowH,
SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | (s_fullscreen ? SDL_WINDOW_FULLSCREEN : 0));
@@ -1194,6 +1436,9 @@ void main()
fprintf(stderr, "[video] SDL_CreateWindow: %s\n", SDL_GetError());
return false;
}
#if defined(__APPLE__) && TARGET_OS_IOS
platform::ios::WatchKeyboards(s_window);
#endif
Uint32 extCount = 0;
const char* const* sdlExts = SDL_Vulkan_GetInstanceExtensions(&extCount);
@@ -1244,26 +1489,8 @@ void main()
vkEnumeratePhysicalDevices(s_instance, &count, nullptr);
std::vector<VkPhysicalDevice> devices(count);
vkEnumeratePhysicalDevices(s_instance, &count, devices.data());
for (VkPhysicalDevice d : devices)
{
uint32_t qc = 0;
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, nullptr);
std::vector<VkQueueFamilyProperties> qs(qc);
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, qs.data());
for (uint32_t i = 0; i < qc; i++)
{
VkBool32 present = VK_FALSE;
vkGetPhysicalDeviceSurfaceSupportKHR(d, i, s_surface, &present);
if ((qs[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && present)
{
s_physical = d;
s_queueFamily = i;
break;
}
}
if (s_physical)
break;
}
devices.resize(count);
ChooseDevice(devices);
if (!s_physical)
{
fprintf(stderr, "[video] no Vulkan device can present to the window\n");
@@ -1275,10 +1502,7 @@ void main()
// The renderer needs dynamic rendering, and imports guest memory as
// a buffer where the device can (host-memory import; Turnip, the
// Steam Frame's driver, can't: the renderer's GPU copy instead).
std::vector<const char*> devExts = { VK_KHR_SWAPCHAIN_EXTENSION_NAME,
VK_KHR_DYNAMIC_RENDERING_EXTENSION_NAME, VK_KHR_DEPTH_STENCIL_RESOLVE_EXTENSION_NAME,
VK_KHR_CREATE_RENDERPASS_2_EXTENSION_NAME, VK_KHR_MULTIVIEW_EXTENSION_NAME, VK_KHR_MAINTENANCE_2_EXTENSION_NAME,
VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME };
std::vector<const char*> devExts(std::begin(kNeededDeviceExtensions), std::end(kNeededDeviceExtensions));
uint32_t extAvail = 0;
vkEnumerateDeviceExtensionProperties(s_physical, nullptr, &extAvail, nullptr);
std::vector<VkExtensionProperties> avail(extAvail);
@@ -1463,7 +1687,7 @@ void main()
hostQueryReset.hostQueryReset ? (PFN_vkResetQueryPool)vkGetDeviceProcAddr(s_device, "vkResetQueryPool") : nullptr };
s_contextReady = true;
s_settingsGeneration = settings::Generation();
fprintf(stderr, "[video] %s, swapchain %ux%u\n", props.deviceName, s_swapExtent.width, s_swapExtent.height);
fprintf(stderr, "[video] %s, swapchain %ux%u%s\n", props.deviceName, s_swapExtent.width, s_swapExtent.height, GeometryNote().c_str());
#if defined(__APPLE__) && TARGET_OS_IOS
if (s_presentScale < 1.0f)
{
@@ -1501,15 +1725,7 @@ void main()
props.deviceName, type, props.vendorID, props.deviceID, driver, VK_API_VERSION_MAJOR(props.apiVersion),
VK_API_VERSION_MINOR(props.apiVersion), VK_API_VERSION_PATCH(props.apiVersion), VK_API_VERSION_MAJOR(loader),
VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader)));
int windowW = 0, windowH = 0;
SDL_GetWindowSizeInPixels(s_window, &windowW, &windowH);
const char* name = SDL_GetDisplayName(SDL_GetDisplayForWindow(s_window));
report::SetSystemItem("Display", std::format("{} {}x{} at {:.2f} Hz{}; window {}x{} pixels{}, "
"swapchain {}x{}, V-Sync {}; SDL video driver {}; {}", name ? name : "?", mode ? mode->w : 0, mode ? mode->h : 0,
mode ? mode->refresh_rate : 0.0f, nativeW > 0 ? std::format(", monitor {}x{}", nativeW, nativeH) : std::string(),
windowW, windowH, s_fullscreen ? " (fullscreen)" : "",
s_swapExtent.width, s_swapExtent.height, s_vsync ? "on" : "off", SDL_GetCurrentVideoDriver(),
s_gameMode ? "Game Mode (gamescope)" : "desktop"));
UpdateDisplayItem();
}
if (s_gameMode && mode && nativeW > 0 && int64_t(mode->w) * mode->h < int64_t(nativeW) * nativeH)
s_resolutionHint = std::format("Steam gives this game {}x{} of your {}x{} display. For full sharpness, set this game's "
@@ -1525,6 +1741,27 @@ void main()
return true;
}
namespace
{
// The bug report's "Display" line: at start, and again with each
// swapchain of a new size (it said what the window was at launch).
void UpdateDisplayItem()
{
const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(s_window));
static const std::pair<int, int> native = NativeMonitorMode();
const auto [nativeW, nativeH] = native;
int windowW = 0, windowH = 0;
SDL_GetWindowSizeInPixels(s_window, &windowW, &windowH);
const char* name = SDL_GetDisplayName(SDL_GetDisplayForWindow(s_window));
report::SetSystemItem("Display", std::format("{} {}x{} at {:.2f} Hz{}; window {}x{} pixels{}, "
"swapchain {}x{}, V-Sync {}; SDL video driver {}; {}", name ? name : "?", mode ? mode->w : 0, mode ? mode->h : 0,
mode ? mode->refresh_rate : 0.0f, nativeW > 0 ? std::format(", monitor {}x{}", nativeW, nativeH) : std::string(),
windowW, windowH, s_fullscreen ? " (fullscreen)" : "",
s_swapExtent.width, s_swapExtent.height, s_vsync ? "on" : "off", SDL_GetCurrentVideoDriver(),
s_gameMode ? "Game Mode (gamescope)" : "desktop"));
}
}
void ReleaseFrontImage(VkImage image)
{
std::lock_guard use(s_frontUseMutex);
@@ -1635,18 +1872,26 @@ void main()
{
// NFSMW_WINDOW_AT=<s>:<w>x<h>,...: resize the window at those seconds
// (NFSMW_UI_KEYS's clock), to test what follows the window's size,
// such as the Auto internal resolution, without a person.
// such as the Auto internal resolution, without a person. A step of
// <s>:full or <s>:windowed toggles fullscreen there instead (on
// macOS a fullscreen Space, as the green button makes).
void ScriptedWindowSizes()
{
struct Step { double at; int w, h; };
struct Step { double at; int w, h; }; // w -1: fullscreen, -2: windowed
static std::vector<Step> steps = [] {
std::vector<Step> out;
const char* v = std::getenv("NFSMW_WINDOW_AT");
for (const char* p = v; p && *p;)
{
Step step{};
char word[16] = {};
if (sscanf(p, "%lf:%dx%d", &step.at, &step.w, &step.h) == 3 && step.w > 0 && step.h > 0)
out.push_back(step);
else if (sscanf(p, "%lf:%15[a-z]", &step.at, word) == 2 && (!strcmp(word, "full") || !strcmp(word, "windowed")))
{
step.w = word[0] == 'f' ? -1 : -2;
out.push_back(step);
}
p = strchr(p, ',');
p = p ? p + 1 : nullptr;
}
@@ -1658,6 +1903,12 @@ void main()
std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count() < steps[next].at)
return;
const Step& step = steps[next++];
if (step.w < 0)
{
fprintf(stderr, "[video] NFSMW_WINDOW_AT: %s\n", step.w == -1 ? "fullscreen" : "windowed");
SDL_SetWindowFullscreen(s_window, step.w == -1);
return;
}
fprintf(stderr, "[video] NFSMW_WINDOW_AT: window %dx%d\n", step.w, step.h);
SDL_SetWindowSize(s_window, step.w, step.h);
}
@@ -1803,6 +2054,8 @@ void main()
void SuspendGame()
{
if (s_window)
s_swapPolicy.Suspended();
// 1. Every guest clock stops at one instant. The game's threads, its
// timers, the vblank and the audio cadence then park on their own:
// nothing here waits for them.
@@ -1847,6 +2100,8 @@ void main()
std::lock_guard lock(s_queueMutex);
held = s_background.exchange(false, std::memory_order_acq_rel);
}
if (s_window)
s_swapPolicy.Resumed(); // the swapchain is checked against the surface for a while
if (held)
{
s_foreground.notify_all();
@@ -1902,11 +2157,68 @@ void main()
s_testEventsWaiting.store(true, std::memory_order_release);
}
namespace
{
// Builds the swapchain again when the policy says so
// (swapchain_policy.h), or (iOS) fits SDL's view back to its window.
// Once a frame, before the acquire. Main thread.
void CheckSwapchain()
{
SurfaceState state;
state.active = !s_background.load(std::memory_order_acquire);
state.swapchain = s_swapchain ? Extent{ s_swapExtent.width, s_swapExtent.height } : Extent{};
if (s_swapPolicy.WantsSurface()) // active or not: the policy holds off while inactive
{
state.surfaceRead = true;
state.surface = SurfaceExtent();
state.interface = InterfaceNow();
state.landscapeOnly = LandscapeOnlyHere();
#if defined(__APPLE__) && TARGET_OS_IOS
state.viewFitsWindow = platform::ios::ViewFitsWindow(s_window);
// Turning the phone over (landscape left and right) changes
// no size, so nothing else would say so.
static const char* lastScene = nullptr;
if (const char* scene = platform::ios::SceneOrientationName(s_window); scene != lastScene)
{
if (lastScene)
fprintf(stderr, "[video] the interface turned: %s -> %s (swapchain %ux%u, surface %ux%u)\n", lastScene, scene,
s_swapExtent.width, s_swapExtent.height, state.surface.width, state.surface.height);
lastScene = scene;
}
#endif
}
const SwapDecision d = s_swapPolicy.Decide(state);
if (d.settled)
fprintf(stderr, "[video] %u frames after the resume: swapchain %ux%u, surface %ux%u%s\n", s_swapPolicy.Rules().settleFrames,
s_swapExtent.width, s_swapExtent.height, state.surface.width, state.surface.height, GeometryNote().c_str());
if (d.action == SwapAction::RepairView)
{
fprintf(stderr, "[video] size check: %s (surface %ux%u, swapchain %ux%u, interface %s)\n", d.reason, state.surface.width,
state.surface.height, state.swapchain.width, state.swapchain.height, OrientationName(state.interface));
#if defined(__APPLE__) && TARGET_OS_IOS
platform::ios::FitViewToWindow(s_window); // SDL's resize events follow; the next frames look again
#endif
}
else if (d.action == SwapAction::Recreate)
{
WaitIdle();
DestroySwapchain();
s_swapReason = d.reason;
CreateSwapchain(); // at ChooseExtent of the surface then (d.extent, unless it moved since)
s_swapReason = nullptr;
}
}
}
bool RunFrame()
{
DeliverTestAppEvents();
ui::Tick();
platform::thermal::Poll(); // about once a second
memstatus::Poll(); // iOS: the memory footprint, once a minute
#if defined(__APPLE__) && TARGET_OS_IOS
platform::ios::PollBattery(); // every 5 seconds
#endif
ScriptedWindowSizes();
SDL_Event e;
while (SDL_PollEvent(&e))
@@ -1915,20 +2227,20 @@ void main()
hid::HandleEvent(e);
if (e.type == SDL_EVENT_QUIT || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED)
return false;
if (e.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED)
if (e.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED || e.type == SDL_EVENT_WINDOW_RESIZED ||
e.type == SDL_EVENT_WINDOW_METAL_VIEW_RESIZED || e.type == SDL_EVENT_DISPLAY_ORIENTATION)
{
// No GPU work while the app is inactive: the first frame
// after the resume rebuilds it.
if (s_background.load(std::memory_order_acquire))
s_swapchainStale = true;
else
{
WaitIdle();
DestroySwapchain();
CreateSwapchain();
}
// Only a reason to look: CheckSwapchain compares the surface
// with the swapchain, once the app is active (a size seen
// while it isn't may be iOS's snapshot of it, or SDL's guess).
s_swapPolicy.SizeChanged();
#if defined(__APPLE__) && TARGET_OS_IOS
if (e.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED)
fprintf(stderr, "[video] window pixels %dx%d%s%s\n", e.window.data1, e.window.data2,
s_background.load(std::memory_order_acquire) ? " (app inactive: checked after the resume)" : "", GeometryNote().c_str());
#endif
}
else if (e.type == SDL_EVENT_WINDOW_RESIZED)
if (e.type == SDL_EVENT_WINDOW_RESIZED)
RememberWindowSize();
else if (e.type == SDL_EVENT_WINDOW_FOCUS_GAINED || e.type == SDL_EVENT_WINDOW_FOCUS_LOST)
s_focused.store(e.type == SDL_EVENT_WINDOW_FOCUS_GAINED, std::memory_order_relaxed);
@@ -1964,13 +2276,7 @@ void main()
hid::Resume(); // the game's last rumble again
s_inputSuspended = false;
}
if (s_swapchainStale)
{
s_swapchainStale = false;
WaitIdle();
DestroySwapchain();
CreateSwapchain();
}
CheckSwapchain();
SuspendAudit();
hid::SetBlocked(ui::CapturingInput());
hid::Update();
@@ -2082,11 +2388,13 @@ void main()
}
uint32_t imageIndex;
VkResult acquire = vkAcquireNextImageKHR(s_device, s_swapchain, UINT64_MAX, f.imageAvailable, VK_NULL_HANDLE, &imageIndex);
// Either is for the next frame's CheckSwapchain, which runs before
// its acquire (one decision a frame: the policy counts in frames).
if (acquire == VK_SUBOPTIMAL_KHR)
s_swapPolicy.Reported(SurfaceResult::Suboptimal); // this frame is drawn and presented anyway
if (acquire == VK_ERROR_OUT_OF_DATE_KHR)
{
WaitIdle();
DestroySwapchain();
CreateSwapchain();
s_swapPolicy.Reported(SurfaceResult::OutOfDate);
// Screenshots wait for the next frame drawn.
std::lock_guard lock(s_captureMutex);
for (auto& promise : captures)
@@ -2154,6 +2462,7 @@ void main()
// non-uniform internal resolution such as 3x2 they differ.)
float frameW = float(aspectW ? aspectW : w), frameH = float(aspectH ? aspectH : h);
int32_t dw = int32_t(frameW), dh = int32_t(frameH);
float frameScaleX = 1.0f, frameScaleY = 1.0f; // the view scales this frame is shown for
if (haveSource)
{
// A new frame decides (and tells the game for the next ones); a
@@ -2174,7 +2483,22 @@ void main()
fit = PlacePicture(s_swapExtent.width, s_swapExtent.height, frameW, frameH, s_lastScaleX, s_lastScaleY);
dw = fit.width;
dh = fit.height;
frameScaleX = fit.scaleX;
frameScaleY = fit.scaleY;
}
// Under Hor+ and Vert+ the post-processing keeps to each side of the
// 16:9 band's edge (video/band_filter.h): the frame's band and the
// strips past it are filtered as if each were the whole image.
// NFSMW_BAND_FILTER=0 filters across it as before (an A/B switch).
static const bool s_bandFilter = [] {
const char* v = std::getenv("NFSMW_BAND_FILTER");
const bool on = !(v && v[0] == '0');
if (!on)
fprintf(stderr, "[video] NFSMW_BAND_FILTER=0: anti-aliasing and scaling read across the 16:9 band's edge\n");
return on;
}();
const video::FrameBand band = video::BandOf(w, h, frameScaleX, frameScaleY);
const bool bandOn = s_bandFilter && (band.x0 > 0 || band.x1 < int32_t(w) || band.y0 > 0 || band.y1 < int32_t(h));
int32_t dx = (int32_t(s_swapExtent.width) - dw) / 2, dy = (int32_t(s_swapExtent.height) - dh) / 2;
// The picture without its letterbox bars, for a screenshot.
VkRect2D pictureRect{};
@@ -2231,7 +2555,14 @@ void main()
{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 0, 0, 1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr },
{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 1, 0, 1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, &out, nullptr, nullptr },
};
struct { int32_t width, height; float subpixel, pad; } pc = { int32_t(w), int32_t(h), 0.5f, 0.0f };
struct
{
int32_t width, height;
float subpixel;
uint32_t bandOn;
int32_t band[4];
} pc = { int32_t(w), int32_t(h), 0.5f, bandOn ? 1u : 0u, { band.x0, band.x1, band.y0, band.y1 } };
static_assert(sizeof(pc) == 32);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s_post.aa);
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s_post.aaLayout, 0, 2, writes);
vkCmdPushConstants(cmd, s_post.aaLayout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(pc), &pc);
@@ -2271,7 +2602,15 @@ void main()
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr };
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, s_post.scaleLayout, 0, 1, &write);
bool bicubic = Bicubic();
struct { float width, height, sharpen; uint32_t bicubic; } pc = { float(w), float(h), Sharpen(), bicubic ? 1u : 0u };
struct
{
float width, height, sharpen;
uint32_t bicubic;
float band[4];
uint32_t bandOn;
} pc = { float(w), float(h), Sharpen(), bicubic ? 1u : 0u, { float(band.x0), float(band.x1), float(band.y0), float(band.y1) },
bandOn ? 1u : 0u };
static_assert(sizeof(pc) == 36);
vkCmdPushConstants(cmd, s_post.scaleLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
vkCmdDraw(cmd, 3, 1, 0, 0);
if (!captures.empty() && pictureRect.extent.width)
@@ -2491,12 +2830,9 @@ void main()
vkDestroyBuffer(s_device, captureBuffer, nullptr);
vkFreeMemory(s_device, captureMemory, nullptr);
}
// The next frame's CheckSwapchain builds again (before its acquire).
if (present == VK_ERROR_OUT_OF_DATE_KHR || present == VK_SUBOPTIMAL_KHR)
{
WaitIdle();
DestroySwapchain();
CreateSwapchain();
}
s_swapPolicy.Reported(present == VK_ERROR_OUT_OF_DATE_KHR ? SurfaceResult::OutOfDate : SurfaceResult::Suboptimal);
s_frameIndex = (s_frameIndex + 1) % FRAMES_IN_FLIGHT;
return true;
}
@@ -2599,6 +2935,11 @@ void main()
#endif
}
bool GameMode()
{
return s_gameMode;
}
void FrameRegion(uint32_t& width, uint32_t& height)
{
width = height = 0;
+5
View File
@@ -153,6 +153,11 @@ namespace video
// there.
bool FullscreenLocked();
// SteamOS Game Mode (gamescope; NFSMW_GAME_MODE=1|0 says otherwise), as
// detected when the window was made. No desktop is on screen there, so a
// system file dialog can't be seen either.
bool GameMode();
// NFSMW_PRESENT_SCALE (iOS): the present scale it sets for this run, over
// Settings > Display > Screen Resolution; 0 when it isn't set.
float PresentScaleOverride();
+73
View File
@@ -0,0 +1,73 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See scale_budget.h.
#include "scale_budget.h"
namespace video
{
namespace
{
// "Apple <family><generation>[ <tier>] GPU": family 'A' (iPhones, the
// iPad mini and the base iPad) or 'M' (iPad Pro and Air), tier "Pro"
// or empty. False for any other name.
bool ParseAppleChip(std::string_view name, char& family, unsigned& generation, std::string_view& tier)
{
constexpr std::string_view kApple = "Apple ";
if (!name.starts_with(kApple))
return false;
name.remove_prefix(kApple.size());
if (name.empty() || (name[0] != 'A' && name[0] != 'M'))
return false;
family = name[0];
name.remove_prefix(1);
generation = 0;
size_t digits = 0;
while (digits < name.size() && name[digits] >= '0' && name[digits] <= '9' && digits < 4)
generation = generation * 10 + unsigned(name[digits++] - '0');
if (digits == 0 || (digits < name.size() && name[digits] != ' '))
return false;
name.remove_prefix(digits < name.size() ? digits + 1 : digits);
// What's left: "GPU", or "<tier> GPU". MoltenVK names the device
// after Metal's, which ends in " GPU"; accept a name without it too.
constexpr std::string_view kGpu = "GPU";
if (name.ends_with(kGpu))
name.remove_suffix(kGpu.size());
while (!name.empty() && name.back() == ' ')
name.remove_suffix(1);
tier = name;
return true;
}
}
ScaleBudget MobileScaleBudget(std::string_view gpuName)
{
char family = 0;
unsigned generation = 0;
std::string_view tier;
if (!ParseAppleChip(gpuName, family, generation, tier))
return { 1, "an unrecognised GPU keeps 1x1" };
if (family == 'A' && generation == 20 && tier == "Pro")
{
// iPhone 18 Pro Max (2868x1320 screen), on a charger, scripted
// fly-in and race: 60 fps at 1x1 with GPU to spare (race 7.7 ms a
// frame), but iOS's thermal state went nominal -> fair at 75 s and
// -> serious at 350 s (Frame Rate's Auto then dropped to 30 fps),
// and was still serious 25 minutes later. More pixels only heat
// it sooner: 1x1 until 2x2 is shown to stay out of serious.
return { 1, "the A20 Pro heats to serious within 6 minutes at 1x1 on a charger" };
}
if (family == 'A' && generation <= 17)
{
// iPhone 15 Pro Max (A17 Pro), scripted race: GPU 15 ms a frame
// at 1x1, 36-40 ms at 2x2.
return { 1, "A17 Pro and older hold a race at 60 fps at 1x1 only" };
}
if (family == 'M')
{
// iPad Pro M2: 2x2 = 43-50 fps in a race. Newer M chips are not
// measured in an iPad.
return { 1, "iPads keep 1x1: the M2 makes 43-50 fps at 2x2" };
}
// An A-series chip newer than the A17 Pro but not measured (the
// A18/A19, a plain A20, an A21...).
return { 1, "a chip not measured yet keeps 1x1" };
}
}
+32
View File
@@ -0,0 +1,32 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Internal Resolution's Auto on iPhones and iPads: how many host pixels per
// guest pixel (sx x sy, video::AutoRenderScale's budget) the GPU gets, chip
// by chip. A pure function of the GPU's name, for tests/scale_budget_test.cpp.
//
// Only chips measured to hold a race at 60 fps with headroom, and to stay out
// of serious thermal throttling over a long session, get more than 1x1. Any
// other chip, a newer one included, keeps 1x1 until it is measured: a phone
// that renders too much heats up and throttles to 30 fps mid-session, which
// is worse than a softer picture. None has qualified yet: the A20 Pro (iPhone
// 18 Pro Max) reaches the serious thermal state at 1x1 already (on a
// charger), so it keeps 1x1 too, and Settings > Graphics > Internal
// Resolution > 2x stays the player's choice.
#pragma once
#include <cstdint>
#include <string_view>
namespace video
{
struct ScaleBudget
{
// At most this many host pixels per guest pixel (1: 1x1, 4: 2x2).
uint32_t pixels = 1;
// Why, for the log ("[renderer] Auto internal resolution: ...").
const char* reason = "";
};
// The budget for the GPU that MoltenVK names `gpuName` (Metal's device
// name, "Apple A20 Pro GPU", "Apple M2 GPU").
ScaleBudget MobileScaleBudget(std::string_view gpuName);
}
+220
View File
@@ -0,0 +1,220 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See swapchain_policy.h.
#include "swapchain_policy.h"
#include <algorithm>
#include <cmath>
namespace video
{
const char* OrientationName(Orientation orientation)
{
switch (orientation)
{
case Orientation::Landscape:
return "landscape";
case Orientation::Portrait:
return "portrait";
default:
return "unknown";
}
}
const char* SwapActionName(SwapAction action)
{
switch (action)
{
case SwapAction::Recreate:
return "recreate";
case SwapAction::RepairView:
return "repair the view";
default:
return "none";
}
}
ExtentChoice ChooseExtent(Extent surface, Orientation interface, bool landscapeOnly, bool refusePortrait)
{
// Unknown counts as landscape: the app shows in nothing else.
if (!refusePortrait || !landscapeOnly || !surface.Portrait() || interface == Orientation::Portrait)
return { surface, false };
return { Extent{ surface.height, surface.width }, true };
}
void SwapchainPolicy::Suspended()
{
resumeWatch_ = false;
}
void SwapchainPolicy::Resumed()
{
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
resumeWatch_ = rules_.settleFrames > 0;
}
void SwapchainPolicy::SizeChanged()
{
sizePending_ = true;
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
}
void SwapchainPolicy::Reported(SurfaceResult result)
{
if (result == SurfaceResult::OutOfDate)
outOfDate_ = true;
else if (rules_.followSuboptimal)
suboptimal_ = true;
}
void SwapchainPolicy::Created(Extent)
{
outOfDate_ = false;
sizePending_ = false;
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
}
bool SwapchainPolicy::WantsSurface() const
{
return sizePending_ || suboptimal_ || outOfDate_ || settleLeft_ > 0 || misfitFrames_ > 0 || refusedFrames_ > 0 ||
(rules_.periodFrames > 0 && (frame_ + 1) % rules_.periodFrames == 0);
}
SwapDecision SwapchainPolicy::Decide(const SurfaceState& s)
{
SwapDecision d;
if (!s.active && rules_.holdWhileInactive)
{
// What was seen meanwhile waits for the resume: a size reported
// now (iOS turning or snapshotting the window) is not the one the
// app will show at.
d.reason = "inactive";
return d;
}
frame_++;
if (settleLeft_ > 0 && --settleLeft_ == 0 && resumeWatch_)
{
resumeWatch_ = false;
d.settled = true;
}
if (outOfDate_)
{
// The old one can't be presented to: build again whatever the size.
outOfDate_ = suboptimal_ = sizePending_ = false;
ExtentChoice choice = ChooseExtent(s.surface, s.interface, s.landscapeOnly, rules_.refusePortrait);
d.action = SwapAction::Recreate;
d.reason = "VK_ERROR_OUT_OF_DATE_KHR";
d.extent = choice.extent;
d.refused = choice.refused;
return d;
}
if (!s.surfaceRead)
return d;
if (s.surface.Empty())
{
// Minimised (or no layer): as before, a size event tears the
// swapchain down and the presenter waits for a size again.
refusedFrames_ = misfitFrames_ = 0;
if (sizePending_ && !s.swapchain.Empty())
{
sizePending_ = suboptimal_ = false;
d.action = SwapAction::Recreate;
d.reason = "no surface (minimised)";
}
return d;
}
// iOS: the surface is the view's layer. A view that doesn't fill its
// window was sized by someone guessing (SDL's portrait guess): fit it
// back, and look again next frame, before following it anywhere.
if (!s.viewFitsWindow && rules_.repairView)
{
misfitFrames_++;
if (misfitFrames_ <= rules_.repairPatience)
{
if (misfitFrames_ <= 3 || (rules_.repairEvery && misfitFrames_ % rules_.repairEvery == 0))
{
d.action = SwapAction::RepairView;
d.reason = "the view doesn't fill its window";
}
else
d.reason = "waiting for the view to fill its window";
return d;
}
// It won't: the swapchain follows the layer after all (below).
}
else
misfitFrames_ = 0;
const ExtentChoice choice = ChooseExtent(s.surface, s.interface, s.landscapeOnly, rules_.refusePortrait);
if (choice.extent != s.swapchain)
{
d.action = SwapAction::Recreate;
d.reason = s.swapchain.Empty() ? "no swapchain"
: sizePending_ ? "the size changed"
: resumeWatch_ ? "after the resume"
: suboptimal_ ? "VK_SUBOPTIMAL_KHR"
: "the surface's size moved";
d.extent = choice.extent;
d.refused = choice.refused;
sizePending_ = suboptimal_ = false;
refusedFrames_ = choice.refused ? refusedFrames_ + 1 : 0;
return d;
}
sizePending_ = false;
if (choice.refused)
{
// A portrait layer under a landscape-only interface: the swapchain
// keeps its landscape size (its SUBOPTIMALs say only that), and the
// view is fitted back to its window.
suboptimal_ = false;
refusedFrames_++;
d.refused = true;
if (rules_.repairView && (refusedFrames_ <= 3 || (rules_.repairEvery && refusedFrames_ % rules_.repairEvery == 0)))
{
d.action = SwapAction::RepairView;
d.reason = "a portrait surface under a landscape interface";
}
else
d.reason = "a portrait surface refused";
return d;
}
refusedFrames_ = 0;
if (suboptimal_)
{
suboptimal_ = false;
// The same size: the drawables or the layer's scale disagree with
// it. Build again, but not every frame if that doesn't settle it.
if (lastSameRecreate_ == 0 || frame_ - lastSameRecreate_ >= rules_.suboptimalBackoff)
{
lastSameRecreate_ = frame_;
d.action = SwapAction::Recreate;
d.reason = "VK_SUBOPTIMAL_KHR at the same size";
d.extent = choice.extent;
return d;
}
d.reason = "VK_SUBOPTIMAL_KHR again (backing off)";
}
return d;
}
UiDisplay FitUiDisplay(float windowWidth, float windowHeight, Extent fb)
{
UiDisplay u;
u.width = windowWidth;
u.height = windowHeight;
if (fb.Empty() || windowWidth <= 0 || windowHeight <= 0)
return u;
// Pixels per point, whichever way round the two are.
const float ppp = float(std::max(fb.width, fb.height)) / std::max(windowWidth, windowHeight);
const float w = float(fb.width) / ppp, h = float(fb.height) / ppp;
if (std::fabs(w - windowWidth) > 2.0f || std::fabs(h - windowHeight) > 2.0f)
{
u.width = w;
u.height = h;
u.fromSwapchain = true;
}
u.scaleX = float(fb.width) / u.width;
u.scaleY = float(fb.height) / u.height;
return u;
}
}
+157
View File
@@ -0,0 +1,157 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// When the presenter builds its swapchain again, and at what size; and the
// size the settings UI lays out at. Pure decisions (no Vulkan, SDL or UIKit:
// presenter.cpp feeds them what it reads), tested with planted wrong rules in
// tests/swapchain_policy_test.cpp. Main thread.
//
// Why (v0.1.2, an iPhone 18 Pro Max on iOS 27): after an iOS screenshot the
// game's picture sat in a corner of the screen and the settings UI was blown
// up. SDL 3.4.16 sets its view's frame itself whenever a keyboard shows or
// hides (-[SDL_uikitviewcontroller updateKeyboard], UIKit_ComputeViewFrame),
// swapping width and height when it believes the interface is portrait:
// iOS 27's UIApplication.statusBarOrientation is "unknown" under the scene
// life cycle, and SDL thinks a resizable window may be portrait whatever the
// Info.plist says. The view became 440x956 points in a 956x440 window, its
// layer followed, and the presenter rebuilt its swapchain at the layer's
// 1320x2868 and never back (nothing moved the view again). So:
// - nothing is built while the app is inactive or in the background, and a
// size seen then is only a reason to look again once it is active;
// - after a resume, a size event or a new swapchain the surface is read
// every frame for a while (and once a second always), and the swapchain
// follows it only when they differ;
// - VK_SUBOPTIMAL_KHR and VK_ERROR_OUT_OF_DATE_KHR are reasons to build
// again (a SUBOPTIMAL at an unchanged size at most once per backoff);
// - on iOS a view that doesn't fill its window is fitted back first (the
// surface is its layer: following it would follow SDL's mistake);
// - a landscape-only app (SpeedBreaker on iPhone) never takes a portrait
// swapchain while its interface is landscape;
// - the UI lays out at the swapchain's size in points, not a window size
// that disagrees with it.
#pragma once
#include <cstdint>
namespace video
{
struct Extent
{
uint32_t width = 0, height = 0;
bool Empty() const { return width == 0 || height == 0; }
bool Portrait() const { return height > width; }
friend bool operator==(const Extent&, const Extent&) = default;
};
enum class Orientation : uint8_t
{
Unknown,
Landscape,
Portrait,
};
const char* OrientationName(Orientation orientation);
// The size to build a swapchain at for a surface of `surface`. A
// landscape-only app refuses a portrait surface unless its interface is
// portrait too: then it takes the same size turned to landscape (which
// is what its view's layer measures once fitted back to its window).
struct ExtentChoice
{
Extent extent;
bool refused = false; // `surface` itself was refused
};
ExtentChoice ChooseExtent(Extent surface, Orientation interface, bool landscapeOnly, bool refusePortrait = true);
// What vkAcquireNextImageKHR or vkQueuePresentKHR said, other than success.
enum class SurfaceResult : uint8_t
{
Suboptimal,
OutOfDate,
};
// What the presenter read this frame.
struct SurfaceState
{
bool active = true; // the app is in front (not inactive, not in the background)
bool surfaceRead = false; // `surface` (and the fields after it) were read this frame (WantsSurface)
Extent surface; // the surface's extent now (Vulkan's currentExtent, else the window's pixels)
Extent swapchain; // the current swapchain's (0x0: none)
Orientation interface = Orientation::Unknown; // the interface's (iOS: the window scene's), Unknown elsewhere
bool landscapeOnly = false; // the app shows only in landscape here (iPhone)
bool viewFitsWindow = true; // iOS: SDL's view fills its UIWindow (always elsewhere)
};
enum class SwapAction : uint8_t
{
None,
Recreate, // build the swapchain again, at `extent`
RepairView, // fit the view to its window (iOS), then look again next frame
};
struct SwapDecision
{
SwapAction action = SwapAction::None;
const char* reason = "";
Extent extent; // Recreate: the size (ChooseExtent's)
bool refused = false; // the surface's own size was refused
bool settled = false; // the watch after a resume ended this frame: the presenter logs where it stands
};
const char* SwapActionName(SwapAction action);
// The rules, a field each, so the tests can plant a wrong one.
struct SwapchainRules
{
bool holdWhileInactive = true; // nothing built while the app is inactive or in the background
bool refusePortrait = true; // landscape-only: no portrait swapchain under a landscape interface
bool repairView = true; // a view that doesn't fill its window is fitted back before anything else
bool followSuboptimal = true; // VK_SUBOPTIMAL_KHR builds again
uint32_t settleFrames = 120; // frames the surface is read every frame after a resume, a size event or a new swapchain
uint32_t periodFrames = 60; // otherwise once every this many frames
uint32_t suboptimalBackoff = 30; // a SUBOPTIMAL at an unchanged size builds again at most once per this many frames
uint32_t repairEvery = 30; // after the first three, a repair is tried once per this many frames
uint32_t repairPatience = 180; // a view that won't fit: after this many frames the swapchain follows its layer anyway
};
class SwapchainPolicy
{
public:
explicit SwapchainPolicy(SwapchainRules rules = {}) : rules_(rules) {}
void Suspended(); // the app went inactive (or to the background)
void Resumed(); // it is active again: watch the surface
void SizeChanged(); // a window, pixel-size or view-size event
void Reported(SurfaceResult result);
void Created(Extent extent); // a swapchain was built (on any path)
// Whether to read the surface (and on iOS the view) this frame.
bool WantsSurface() const;
// Once a frame, before acquiring an image.
SwapDecision Decide(const SurfaceState& state);
const SwapchainRules& Rules() const { return rules_; }
private:
SwapchainRules rules_;
uint64_t frame_ = 0; // active frames decided
uint32_t settleLeft_ = 0; // frames left reading the surface every frame
bool sizePending_ = false;
bool suboptimal_ = false;
bool outOfDate_ = false;
bool resumeWatch_ = false; // settleLeft_ counts down from a resume
uint32_t misfitFrames_ = 0; // frames the view hasn't filled its window
uint32_t refusedFrames_ = 0; // frames the surface has been refused
uint64_t lastSameRecreate_ = 0; // frame_ of the last SUBOPTIMAL rebuild at an unchanged size (0: none)
};
// The UI's display size (points) and framebuffer scale for a swapchain
// of `framebuffer` pixels, given the window's size in points. While they
// agree (to 2 points) the window's size stands, as before; when they
// don't (a view SDL turned, or a swapchain kept while it is fitted back)
// the swapchain's own shape wins, at the window's pixels per point.
struct UiDisplay
{
float width = 0, height = 0; // ImGui's DisplaySize
float scaleX = 1, scaleY = 1; // DisplayFramebufferScale
bool fromSwapchain = false; // the window's size was overruled
};
UiDisplay FitUiDisplay(float windowWidth, float windowHeight, Extent framebuffer);
}
+2 -1
View File
@@ -35,7 +35,8 @@ for p in "$ROOT"/patches/ffmpeg/*.patch; do patch -s -p1 < "$p"; done
./configure --disable-everything --disable-programs --disable-doc --disable-debug \
--disable-network --disable-autodetect --disable-avdevice --disable-avformat --disable-swscale \
--disable-swresample --disable-postproc --disable-avfilter \
--enable-decoder=xma1,xma2,wmapro --enable-static --disable-shared --enable-pic "$@" > "$WORK/configure.log"
--enable-decoder=xma1,xma2,wmapro --enable-static --disable-shared --enable-pic "$@" > "$WORK/configure.log" \
|| { tail -5 "$WORK/configure.log"; tail -15 ffbuild/config.log; echo "build_ffmpeg_xma.sh: configure failed"; exit 1; } >&2
make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" > "$WORK/make.log"
make install DESTDIR="$WORK/stage" > "$WORK/install.log"
# The default prefix (/usr/local) inside the stage, moved to its place.
+163 -20
View File
@@ -1,30 +1,173 @@
# Building SpeedBreaker for iPhone and iPad
# SpeedBreaker on iPhone and iPad
There's no download for iPhone and iPad, and SpeedBreaker isn't on the App Store. You build it on
a Mac and install it on your own device, signed with your own Apple developer account. Nothing
here is distributed: the app you build is yours.
SpeedBreaker isn't on the App Store. There are two ways to get it on your iPhone or iPad:
## What you need
- **Sideload the IPA** (experimental, from v0.1.2): a sideloading tool signs it with your own
Apple Account and installs it. Read [the memory caveat](#the-memory-caveat) first: with a free
Apple Account it may not start, depending on the tool.
- **Build it yourself** on a Mac with Xcode, signed with your own Apple developer account: see
[Building it yourself](#building-it-yourself). This is how it was developed and tested.
- **A Mac** with Apple silicon and **Xcode** (built with Xcode 26.4), plus everything the Mac
build needs: see [Building from source](../../README.md#building-from-source), step 1.
- **Your own disc image** of the USA Xbox 360 disc, for step 2 of that guide (the game's code is
translated from it on your Mac) and later for the installer on the device.
## What every device needs
- **iOS or iPadOS 17 or later** and an **A15-class chip or newer**: iPhone 13 and later, iPads
with an M-series chip. It was tested on an iPhone 15 Pro Max, an iPhone 18 Pro Max and an iPad
Pro 12.9-inch (M2), on version 27. Devices with 6 GB of memory or more are recommended; 4 GB
ones (iPhone 13 and 13 mini, iPhone SE 3rd generation, iPad mini 6th generation) are untested
and likely too small.
- **A controller, strongly recommended**: an Xbox or PlayStation controller over Bluetooth, or a
Backbone or similar. There are no touch controls for driving (a keyboard works too). A
three-finger tap opens the settings.
- **Your own disc image** of the USA Xbox 360 disc (an `.iso`, about 7.8 GB), and room for it plus
the installed game (about 7.1 GB) while it installs. You can delete the image afterwards.
## Sideloading the IPA (experimental)
The IPA, `SpeedBreaker-<version>-ios.ipa`, is signed by nobody: it carries only the entitlements
the game asks for, and the tool you sideload it with signs it with your Apple Account. Check the
download against its `.sha256` file (`shasum -a 256 -c SpeedBreaker-<version>-ios.ipa.sha256`).
### The memory caveat
SpeedBreaker lays out the Xbox 360's memory in one block of 4 GB of address space, needs about 3 GB
more address space besides, and at its busiest uses about 2.5 GB of memory ([Memory](#memory)).
iOS doesn't give an app that much by default, so the app asks for two entitlements:
- **Extended Virtual Addressing** lifts iOS's address-space limit. Only a paid Apple Developer
Program account (or an enterprise one) can sign it: Apple's
[capability table](https://developer.apple.com/help/account/reference/supported-capabilities-ios)
lists it for those memberships and not for a free Apple Account, Xcode refuses it for a free
Personal Team, and the sideloading tools drop it for free accounts.
- **Increased Memory Limit** raises the memory limit, from about 3.3 GB to about 6 GB on an 8 GB
iPhone (about 2.9 GB without it on a 6 GB one). On iOS 16 and later, on devices that support
it, it also lifts the address-space limit as Extended Virtual Addressing does
([xnu, kern_exec.c](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_exec.c)).
Apple gives it to free Apple Accounts too, but not every tool asks for it.
What that means for each way of signing (as of October 2026). The IPA itself hasn't been installed on a
device yet: the tested iPhones and iPad ran the same app built and signed with Xcode and a paid account.
| Signed with | Entitlements the app gets | Expected |
|---|---|---|
| A paid Apple Developer account, Xcode (building it yourself) | both | works, as tested |
| A paid Apple Developer account, a sideloading tool | depends on the tool (AltStore keeps only Increased Memory Limit) | likely works; untested |
| A free Apple Account, [AltStore Classic](https://altstore.io) 2.2 or later | Increased Memory Limit | likely works on devices that support it |
| A free Apple Account, Xcode Personal Team (building it yourself) | Increased Memory Limit, after removing the other one | likely works on devices that support it |
| A free Apple Account, [SideStore](https://sidestore.io) 0.7 | neither ([SideStore #1616](https://github.com/SideStore/SideStore/issues/1616)) | may start; iOS may close it in busy scenes |
| A free Apple Account, [Sideloadly](https://sideloadly.io) | unknown | unknown until tested |
Without either entitlement iOS gives an app 17 GB of address space, about 7 GB of it usable: the
4 GB can still fit on a device with more than 3 GB of memory (about 5 GB is free in one piece above
the system's shared region, and the game reserves its 4 GB first thing), but everything else then
has to fit in the 3 GB or so left, and the game in iOS's default memory limit. When the 4 GB
doesn't fit, the game says so instead of closing: a page titled **This copy of SpeedBreaker can't
start**, whose Details line names the entitlements this copy was signed with. When it starts with
iOS's default address space, or a memory limit below about 3 GB, a notice says iOS may close the
game in busy scenes.
Coming from a build you made yourself, the IPA installs as a separate app (the tools give it
another bundle identifier), with its own storage: install the game again from your disc image.
Saves made in the old app stay with it.
### Free Apple Account limits
These are Apple's, whatever the tool ([AltStore's FAQ](https://faq.altstore.io/altstore-classic/your-altstore),
[error codes](https://faq.altstore.io/altstore-world/error-codes)):
- **7 days.** An app signed by a free account stops opening after 7 days. AltStore and SideStore
refresh it in the background or when you tap Refresh; with Sideloadly you sideload it again.
Your installed game, saves and settings stay as long as the app keeps its bundle identifier
(the same tool and the same Apple Account).
- **3 apps.** At most 3 sideloaded apps can be active on a device at once (AltStore and SideStore
count themselves), and an account can register at most 10 App IDs in 7 days.
- **Developer Mode** must be on: Settings > Privacy & Security > Developer Mode (the device
restarts).
A paid account signs for a year, without the 3-app limit.
### Steps
1. Download the IPA and its `.sha256` from the release page, and check it.
2. Sideload it with your tool of choice, signing in to it with your Apple Account:
- **AltStore Classic** (AltServer on a Mac or PC, then AltStore on the device): My Apps, +, and
pick the IPA.
- **SideStore**: +, and pick the IPA.
- **Sideloadly** (Mac or PC, with a cable): drag the IPA in, choose the device, enter your Apple
Account and press Start.
3. On the device, trust the developer: Settings > General > VPN & Device Management, your Apple
Account, Trust. Turn on Developer Mode if iOS asks.
4. Put your disc image in **Files > On My iPhone (or iPad) > SpeedBreaker**: AirDrop it to Files,
drag it into the device's Files tab in Finder over a cable, or download it from iCloud Drive
first (the installer can't read a file still in the cloud).
5. Start SpeedBreaker: the installer finds the image, checks it against the disc and installs the
game. See [iPhone and iPad](../../README.md#iphone-and-ipad) in the main README for the rest.
If it doesn't work: the page's Details line, or Settings > Advanced > Save Bug Report (a zip in
the Files app's SpeedBreaker folder) after iOS closed the game, says what happened; the log's
`[memory]` lines give the entitlements, the address space and the memory limit, and once a minute
how close the game came to it. iOS's own record of closing an app for memory is in Settings >
Privacy & Security > Analytics & Improvements > Analytics Data, as a JetsamEvent report.
### Making the IPA
```sh
export IOS_DEPS=~/speedbreaker-ios-deps # step 2 of Building it yourself
scripts/ios/make_ipa.sh
```
This builds the app without signing it (`IOS_UNSIGNED=1 scripts/ios/build.sh`: no team, no
Apple Account, no provisioning profile) and packages `build/ios/ipa/SpeedBreaker-<version>-ios.ipa`
(`Payload/SpeedBreaker.app`, zipped), its `.sha256`, and the entitlements beside it. The version
is `project()`'s in the top-level CMakeLists.txt. `--app <SpeedBreaker.app>` packages a Release
build you already have instead, signed or not: any signature and `embedded.mobileprovision` are
removed. The app gets a generic bundle identifier (the tools give it one of yours) and an ad-hoc
signature with no certificate or team that holds only `SpeedBreaker/SpeedBreaker.entitlements`
(or `--entitlements <plist>`'s, to test with fewer), which is where the sideloading tools read the
entitlements an app asks for. The executable's debug map, which names the folders it was built in,
is stripped; its full symbols go to `build/ios/ipa/symbols/SpeedBreaker-<version>-<commit>-ios.dSYM`,
to keep and not to publish. The license notices go in `SpeedBreaker.app/licenses` (`COPYING`,
`NOTICE`, `THIRD_PARTY_NOTICES.md` and `LICENSES/`), as in the other downloads. Before writing the IPA it checks that no home-folder path, team,
provisioning profile or device ID is left in it; `IPA_SCAN_EXTRA="A1B2C3D4E5|your name"` adds
strings of your own to look for. The same app always gives the same IPA.
## Memory
What the game needs, measured on a Mac (macOS 27, Apple silicon) in a scripted run into the first
race, with the same code as the iOS app:
- **Address space:** one block of 4 GB for the guest (no smaller layout exists: the recompiled code
addresses guest memory as one base plus a 32-bit address, and the game's memory runs to
0xFFFFFFFF), plus about 3 GB for everything else at 1x internal resolution, the iPhone's
default (the graphics driver's memory, the game's heaps, thread stacks), 3.8 GB at 2x2.
- **Memory footprint, peak:** 2.2 GB at 1x, 2.8 GB at 2x2 (1440p). The graphics memory in that
race was 1.18 GB at 1x; on an iPhone 15 Pro Max in free roam it was 1.46 GB, so expect about
2.5 GB there.
- On iOS the log's `[memory]` lines give the real figures: the address space's top (0xfc0000000
or more with an entitlement, 0x458000000 without), the memory limit, and once a minute the
footprint, its peak and what is left.
## Building it yourself
### What you need
- **A Mac** with Apple silicon and **Xcode** (built with Xcode 26.4 and 27.0), plus everything the
Mac build needs: see [Building from source](../../README.md#building-from-source), step 1.
- **Your own disc image**, for step 2 of that guide (the game's code is translated from it on your
Mac) and later for the installer on the device.
- **An Apple developer account.** SpeedBreaker was built and tested with a paid Apple Developer
Program membership. It asks for two memory capabilities, Extended Virtual Addressing and
Increased Memory Limit, which a free Apple Account's Personal Team may not be able to sign;
apps signed by a free account also stop opening after 7 days.
- **An iPhone or iPad** with iOS or iPadOS 17 or later and an A15-class chip or newer (iPhone 13
and later, M-series iPads), and a controller or keyboard. Tested on an iPhone 15 Pro Max and an
iPad Pro 12.9-inch (M2), both on version 27.
Program membership. A free Apple Account's Personal Team can't sign Extended Virtual Addressing:
Xcode stops with "Personal development teams ... do not support the Extended Virtual Addressing
capability". Delete that key (and its `<true/>`) from `scripts/ios/SpeedBreaker/SpeedBreaker.entitlements`
first and keep Increased Memory Limit (see [the memory caveat](#the-memory-caveat)); apps signed
by a free account also stop opening after 7 days.
## 1. The game's code
### 1. The game's code
On the Mac, follow [Building from source](../../README.md#building-from-source) steps 1 and 2:
`scripts/setup_tools.sh`, your disc's `default.xex` in `game/files`, and XenonRecomp, which
writes `ppc/`. You don't need the Mac build itself (step 3).
## 2. The iOS libraries
### 2. The iOS libraries
```sh
scripts/ios/build_deps.sh ~/speedbreaker-ios-deps
@@ -34,7 +177,7 @@ This downloads and builds SDL3, glslang and FFmpeg's XMA decoders for iOS, and u
static library, into the folder you name (a few minutes; the sources, about 65 MB, are checked
against their SHA-256). Nothing is installed anywhere else.
## 3. Signing
### 3. Signing
Find your **team ID**: in Xcode, Settings > Accounts, select your account and team, or on
developer.apple.com under Membership details. It's 10 characters, like `A1B2C3D4E5`. Pick a
@@ -44,7 +187,7 @@ team.
On the device, turn on **Developer Mode** (Settings > Privacy & Security > Developer Mode; it
restarts), then connect it to the Mac with a cable and tap **Trust**.
## 4. Build and install
### 4. Build and install
```sh
export IOS_DEPS=~/speedbreaker-ios-deps
@@ -64,7 +207,7 @@ device connected, set `IOS_DEVICE` to the one you want (`xcrun devicectl list de
If the app doesn't open and iOS says the developer isn't trusted, go to Settings > General > VPN &
Device Management, choose your developer account and trust it.
## 5. Play
### 5. Play
Copy your `.iso` into **Files > On My iPhone (or iPad) > SpeedBreaker** and start the app: the
installer finds it, checks it and installs the game. See
+28 -6
View File
@@ -17,13 +17,24 @@
# IOS_BUNDLE_ID the app's bundle identifier (unique to the team)
# IOS_DEVICE the device to install on (`xcrun devicectl list
# devices`; the only connected one by default)
# IOS_UNSIGNED=1 an app nobody signed, for make_ipa.sh (an IPA that
# players sideload): no team, no account, no profile;
# in build/ios/xcode-unsigned. IOS_BUNDLE_ID is then
# optional (a generic one), and "install" refused.
# No game data: the game installs from the player's disc image on the device.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
export DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}
: "${IOS_DEPS:?set IOS_DEPS: the folder scripts/ios/build_deps.sh made (scripts/ios/README.md)}"
: "${DEVELOPMENT_TEAM:?set DEVELOPMENT_TEAM: your Apple developer team ID (scripts/ios/README.md)}"
: "${IOS_BUNDLE_ID:?set IOS_BUNDLE_ID: a bundle identifier of your own, such as com.yourname.SpeedBreaker}"
UNSIGNED=${IOS_UNSIGNED:-0}
if [ "$UNSIGNED" = 1 ]; then
[ "${1:-}" = install ] && { echo "build.sh: IOS_UNSIGNED=1 makes an app nobody signed, which no device installs: make_ipa.sh packages it for sideloading" >&2; exit 2; }
# Sideloading tools give it an identifier of the player's own anyway.
IOS_BUNDLE_ID=${IOS_BUNDLE_ID:-io.github.speedbreakerproject.SpeedBreaker}
else
: "${DEVELOPMENT_TEAM:?set DEVELOPMENT_TEAM: your Apple developer team ID (scripts/ios/README.md)}"
: "${IOS_BUNDLE_ID:?set IOS_BUNDLE_ID: a bundle identifier of your own, such as com.yourname.SpeedBreaker}"
fi
PREFIX=$IOS_DEPS/prefix
MOLTENVK=$IOS_DEPS/MoltenVK
BUILD=$ROOT/build/ios
@@ -67,13 +78,24 @@ LIBS+=(-weak_framework CoreHaptics)
VERSION=$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD/CMakeCache.txt")
BUILD_NUMBER=$(git -C "$ROOT" rev-list --count HEAD 2>/dev/null || echo 1)
# Signed for the team (Xcode's automatic signing makes the profile), or for
# make_ipa.sh not at all: then no account is asked anything.
if [ "$UNSIGNED" = 1 ]; then
DERIVED=$BUILD/xcode-unsigned
SIGNING=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= DEVELOPMENT_TEAM=)
else
DERIVED=$BUILD/xcode
SIGNING=(-allowProvisioningUpdates DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM")
fi
xcodebuild -project "$ROOT/scripts/ios/SpeedBreaker.xcodeproj" -scheme SpeedBreaker -configuration Release \
-destination generic/platform=iOS -derivedDataPath "$BUILD/xcode" -allowProvisioningUpdates -quiet \
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" PRODUCT_BUNDLE_IDENTIFIER="$IOS_BUNDLE_ID" \
-destination generic/platform=iOS -derivedDataPath "$DERIVED" -quiet "${SIGNING[@]}" \
PRODUCT_BUNDLE_IDENTIFIER="$IOS_BUNDLE_ID" \
MARKETING_VERSION="${VERSION:-0.0.0}" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
OTHER_LDFLAGS="${LIBS[*]}" build
APP=$BUILD/xcode/Build/Products/Release-iphoneos/SpeedBreaker.app
nm -gU "$APP/SpeedBreaker" | grep -q ' _vkGetInstanceProcAddr$' || { echo "build.sh: vkGetInstanceProcAddr was stripped" >&2; exit 1; }
APP=$DERIVED/Build/Products/Release-iphoneos/SpeedBreaker.app
# Counted, not grep -q: grep -q stops reading nm's long output at the first
# match, and with pipefail nm's SIGPIPE then fails the pipe (a false alarm).
[ "$(nm -gU "$APP/SpeedBreaker" | grep -c ' _vkGetInstanceProcAddr$')" != 0 ] || { echo "build.sh: vkGetInstanceProcAddr was stripped" >&2; exit 1; }
echo "$APP (v${VERSION:-?}, build $BUILD_NUMBER, $(du -sh "$APP" | cut -f1))"
if [ "${1:-}" = install ]; then
+184
View File
@@ -0,0 +1,184 @@
#!/bin/bash
# SpeedBreaker. GPL-3.0-or-later (see COPYING).
#
# scripts/ios/make_ipa.sh [--app <SpeedBreaker.app>] [--out <folder>] [--entitlements <plist>]:
# an IPA for sideloading (Sideloadly, AltStore, SideStore and the like, which
# sign it with the player's own Apple Account):
# <folder>/SpeedBreaker-<version>-ios.ipa (build/ios/ipa by default), its
# .sha256, and the entitlements it asks for beside it (.entitlements).
# EXPERIMENTAL: see README.md, "Sideloading the IPA".
# - The app: built here without signing (IOS_UNSIGNED=1 build.sh, which
# needs IOS_DEPS: README.md, step 2), or --app, a Release build for
# devices, signed or not, of this version.
# - Nobody's signature: a developer signature and embedded.mobileprovision
# are removed. The executable gets an ad-hoc signature (no certificate,
# no team) that holds only the entitlements in SpeedBreaker.entitlements
# (Extended Virtual Addressing, Increased Memory Limit), or --entitlements'
# (a test with fewer): an app's own signature is where the sideloading
# tools read what it asks for.
# - No debug map: the executable's links to the object files it was built
# from (with this Mac's paths) are stripped; the full symbols go to
# <folder>/symbols/SpeedBreaker-<version>-<commit>-ios.dSYM, for us (never
# published: they map a player's crash back to the build), as the Mac
# app's do (scripts/macos/make_app.py).
# - A generic bundle identifier and display name: the tools replace the
# identifier with one of the player's own.
# - The license notices in SpeedBreaker.app/licenses (COPYING, NOTICE,
# THIRD_PARTY_NOTICES.md, LICENSES/), as the other downloads carry them.
# - The version is CMakeLists.txt's project(): --app must have it too.
# - Checked before it's written: no team ID, provisioning profile, device ID
# or home folder path anywhere in the payload. IPA_SCAN_EXTRA="a|b|c"
# adds strings of your own to look for (your team ID, name, devices).
# Reproducible: the same app gives the same IPA (sorted, fixed times).
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
export DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}
APP=
OUT=$ROOT/build/ios/ipa
ENTITLEMENTS=$ROOT/scripts/ios/SpeedBreaker/SpeedBreaker.entitlements
while [ $# -gt 0 ]; do
case $1 in
--app) APP=${2:?--app needs a path}; shift 2 ;;
--out) OUT=${2:?--out needs a folder}; shift 2 ;;
--entitlements) ENTITLEMENTS=${2:?--entitlements needs a plist}; shift 2 ;;
-h|--help) sed -n '3,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "make_ipa.sh: unknown argument $1 (--help)" >&2; exit 2 ;;
esac
done
fail() { echo "make_ipa.sh: $*" >&2; exit 1; }
# grep -q on a long pipe stops reading early, and with pipefail the writer's
# SIGPIPE then fails the pipe: count instead, reading everything.
has() { [ "$(grep -c -E -e "$1")" != 0 ]; }
VERSION=$(sed -n 's/^project(SpeedBreaker VERSION \([0-9][0-9.]*\) .*/\1/p' "$ROOT/CMakeLists.txt")
[ -n "$VERSION" ] || fail "no version in CMakeLists.txt's project()"
BUNDLE_ID=io.github.speedbreakerproject.SpeedBreaker
NAME=SpeedBreaker-$VERSION-ios
plutil -lint -s "$ENTITLEMENTS" || fail "$ENTITLEMENTS isn't a property list"
if [ -z "$APP" ]; then
IOS_UNSIGNED=1 IOS_BUNDLE_ID=$BUNDLE_ID "$ROOT/scripts/ios/build.sh"
APP=$ROOT/build/ios/xcode-unsigned/Build/Products/Release-iphoneos/SpeedBreaker.app
fi
[ -f "$APP/SpeedBreaker" ] && [ -f "$APP/Info.plist" ] || fail "no SpeedBreaker.app at $APP"
[ "$(plutil -extract DTPlatformName raw "$APP/Info.plist" 2>/dev/null)" = iphoneos ] \
|| fail "$APP isn't built for iPhone and iPad devices (DTPlatformName iphoneos)"
APP_VERSION=$(plutil -extract CFBundleShortVersionString raw "$APP/Info.plist")
[ "$APP_VERSION" = "$VERSION" ] || fail "$APP is v$APP_VERSION, CMakeLists.txt says $VERSION: build it again"
[ "$(lipo -archs "$APP/SpeedBreaker")" = arm64 ] || fail "the executable isn't arm64 alone ($(lipo -archs "$APP/SpeedBreaker"))"
STAGE=$(mktemp -d "${TMPDIR:-/tmp}/make_ipa.XXXXXX")
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/Payload"
P=$STAGE/Payload/SpeedBreaker.app
ditto --norsrc --noextattr --noqtn "$APP" "$P"
# Nobody's signature or profile, and nothing of the Mac's.
rm -rf "$P/_CodeSignature" "$P/embedded.mobileprovision"
codesign --remove-signature "$P/SpeedBreaker" 2>/dev/null || true
find "$P" \( -name .DS_Store -o -name '._*' \) -delete
[ -z "$(find "$P" -name '*.mobileprovision' -o -name '*.xcent')" ] || fail "a profile is left in the app"
# The debug map out (it names this Mac's folders), the full symbols kept
# for us. A stripped --app has none: nothing to keep.
if nm -ap "$P/SpeedBreaker" 2>/dev/null | has ' OSO '; then
COMMIT=$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)
[ -z "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] || COMMIT=$COMMIT-dirty
DSYM=$OUT/symbols/SpeedBreaker-$VERSION-$COMMIT-ios.dSYM
mkdir -p "$OUT/symbols"
rm -rf "$DSYM"
dsymutil "$APP/SpeedBreaker" -o "$DSYM" 2>/dev/null || fail "dsymutil failed"
strip -S "$P/SpeedBreaker"
fi
nm -gU "$P/SpeedBreaker" | has ' _vkGetInstanceProcAddr$' || fail "vkGetInstanceProcAddr is gone (SDL looks it up)"
# Rewritten with its keys sorted (plutil -replace orders them differently
# from one run to the next, and the signature seals the file).
python3 -I - "$P/Info.plist" "$BUNDLE_ID" <<'PY'
import plistlib, sys
path, bundle_id = sys.argv[1:]
with open(path, 'rb') as f:
info = plistlib.load(f)
info['CFBundleIdentifier'] = bundle_id
info['CFBundleDisplayName'] = 'SpeedBreaker'
with open(path, 'wb') as f:
plistlib.dump(info, f, fmt=plistlib.FMT_BINARY, sort_keys=True)
PY
plutil -lint -s "$P/Info.plist"
# The license notices, as the Mac app and the SteamOS downloads carry them:
# an IPA is a download, and the libraries built into it ask for theirs.
# SpeedBreaker.app/licenses: COPYING, NOTICE, THIRD_PARTY_NOTICES.md and
# LICENSES/, which cover SpeedBreaker and everything in the app.
L=$P/licenses
rm -rf "$L"
mkdir -p "$L"
cp "$ROOT/COPYING" "$ROOT/NOTICE" "$ROOT/THIRD_PARTY_NOTICES.md" "$L/"
ditto --norsrc --noextattr --noqtn "$ROOT/LICENSES" "$L/LICENSES"
cat > "$L/README.txt" <<EOF
SpeedBreaker v$VERSION for iPhone and iPad is free software under the GPL-3.0-or-later
(COPYING, NOTICE). What it is built from and with, and each license, is in
THIRD_PARTY_NOTICES.md and LICENSES/.
Built into this app, besides SpeedBreaker's own code and what THIRD_PARTY_NOTICES.md
lists as compiled into every platform: SDL3 (zlib; with HIDAPI and yuv2rgb,
BSD-3-Clause), glslang (its own list, LICENSES/glslang.txt), MoltenVK (Apache-2.0;
with SPIRV-Cross and SPIRV-Tools, Apache-2.0, and cereal, BSD-3-Clause), the
Vulkan and SPIRV headers, and FFmpeg's XMA decoders (LGPL-2.1-or-later), all
statically linked (scripts/ios/build_deps.sh says which versions).
Source: https://github.com/SpeedBreakerProject/speedbreaker (tag v$VERSION), and the
LGPL parts' complete source in that release's third-party source archive.
EOF
# The entitlements, where the tools read them: an ad-hoc signature (XML and
# DER), no identity, no time stamp.
codesign --force --sign - --timestamp=none --generate-entitlement-der --entitlements "$ENTITLEMENTS" "$P"
codesign --verify --strict "$P"
SIG=$(codesign -dvv "$P" 2>&1)
grep -q '^Signature=adhoc$' <<<"$SIG" || fail "the signature isn't ad-hoc"
grep -q '^TeamIdentifier=not set$' <<<"$SIG" || fail "the signature names a team"
# Exactly the file's entitlements, nothing more (no team, no get-task-allow).
keys() { plutil -convert json -o - - | python3 -I -c 'import json, sys; print(" ".join(sorted(f"{k}={v}" for k, v in json.load(sys.stdin).items())))'; }
WANTED=$(keys < "$ENTITLEMENTS")
SIGNED=$(codesign -d --entitlements - --xml "$P" 2>/dev/null | keys)
[ "$SIGNED" = "$WANTED" ] || fail "the signature's entitlements ($SIGNED) aren't $(basename "$ENTITLEMENTS")'s ($WANTED)"
# Nothing personal: no home folders, profiles, teams or devices. A device's
# UDID (since 2018: 8 hex digits, a dash, 16) is looked for in any form.
PATTERNS=(/Users/ /home/ /private/var/mobile ProvisionedDevices embedded.mobileprovision com.apple.developer.team-identifier
application-identifier TeamIdentifier DEVELOPMENT_TEAM)
if [ -n "${IPA_SCAN_EXTRA:-}" ]; then
IFS='|' read -r -a EXTRA <<<"$IPA_SCAN_EXTRA"
PATTERNS+=("${EXTRA[@]}")
fi
HITS=0
for pattern in "${PATTERNS[@]}"; do
[ -n "$pattern" ] || continue
if found=$(LC_ALL=C grep -r -a -i -l -F -e "$pattern" "$STAGE/Payload"); then
echo "make_ipa.sh: \"$pattern\" found in: ${found//$STAGE\//}" >&2
HITS=$((HITS + 1))
fi
done
if found=$(LC_ALL=C grep -r -a -l -E '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}([^0-9A-Fa-f]|$)' "$STAGE/Payload"); then
echo "make_ipa.sh: something shaped like a device UDID in: ${found//$STAGE\//}" >&2
HITS=$((HITS + 1))
fi
[ "$HITS" = 0 ] || fail "$HITS personal string(s) in the payload: not written"
# A sorted zip with fixed times (2000-01-01, or SOURCE_DATE_EPOCH) and no
# extra fields: the same app, the same IPA, whatever the checkout's state.
EPOCH=${SOURCE_DATE_EPOCH:-946684800}
STAMP=$(TZ=UTC date -r "$EPOCH" +%Y%m%d%H%M.%S)
find "$STAGE/Payload" -exec env TZ=UTC touch -h -t "$STAMP" {} +
mkdir -p "$OUT"
rm -f "$OUT/$NAME.ipa" "$OUT/$NAME.ipa.sha256"
(cd "$STAGE" && find Payload | LC_ALL=C sort | TZ=UTC zip -q -X -y -9 -@ "$STAGE/$NAME.ipa")
unzip -Z1 "$STAGE/$NAME.ipa" | has '^(Payload/|Payload/SpeedBreaker\.app/.*)$' || fail "the IPA is empty"
unzip -Z1 "$STAGE/$NAME.ipa" | grep -v -E '^(Payload/|Payload/SpeedBreaker\.app/.*)$' | has . && fail "the IPA holds more than Payload/SpeedBreaker.app"
mv "$STAGE/$NAME.ipa" "$OUT/$NAME.ipa"
cp "$ENTITLEMENTS" "$OUT/$NAME.entitlements"
(cd "$OUT" && shasum -a 256 "$NAME.ipa" > "$NAME.ipa.sha256")
echo "$OUT/$NAME.ipa ($(du -h "$OUT/$NAME.ipa" | cut -f1 | tr -d ' '), sha256 $(cut -d' ' -f1 "$OUT/$NAME.ipa.sha256"))"
echo " $BUNDLE_ID v$VERSION (build $(plutil -extract CFBundleVersion raw "$P/Info.plist")), ad-hoc signed with" \
"$(sed 's/com\.apple\.developer\.//g; s/=True//g' <<<"$WANTED"); no team, profile or device"
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
# SpeedBreaker. GPL-3.0-or-later (see COPYING).
#
# scripts/macos/build_deps.sh <prefix> [<downloads folder>]
#
# The libraries the Mac app bundles (scripts/macos/make_app.py), built from
# their official sources for the oldest macOS the game runs on, arm64, into
# <prefix>. That macOS is the top-level CMakeLists.txt's deployment target
# (MACOSX_DEPLOYMENT_TARGET in the environment overrides it). Homebrew's
# bottles can't be bundled for it: a bottle is built for the macOS it was made
# on (26.0 for macOS 26), and macOS refuses to load a library built for a
# newer macOS than its own.
#
# Vulkan-Headers 1.4.357.0, SPIRV-Headers 1.4.357.0 headers (the game, the
# loader, SPIRV-Tools)
# SPIRV-Tools 1.4.357.0, glslang 16.6.0 shared libraries
# Vulkan-Loader 1.4.357.0 libvulkan.1.dylib
# SDL3 3.4.16 libSDL3.0.dylib
# FFmpeg 7.1.1 (scripts/build_ffmpeg_xma.sh) <prefix>/ffmpeg-xma,
# static, in the game
#
# The same versions v0.1.0 bundled from Homebrew, built the way Homebrew
# builds them, so they run exactly as fast: its formulas' CMake options,
# -Os -DNDEBUG, Apple's clang from the command line tools, and no -mcpu or
# -march (the compiler's default for arm64 Macs, apple-m1, which the
# deployment target doesn't change). -Os, not CMake's Release default -O3:
# Homebrew's bottles are -Os. Measured (2026-10-08, command line tools 26.6,
# the clang Homebrew's bottles were built with): this script with -Os and
# target 26.0 reproduces Homebrew's libSDL3.0.dylib byte for byte, and the
# loader's and SPIRV-Tools' code to the instruction (only build dates and
# the loader's search folders differ); with -O3, SDL's code is 29% larger.
# What differs from Homebrew's:
# - the deployment target;
# - the Vulkan loader's search folders are upstream's defaults
# (/usr/local/etc, /etc, /etc/xdg, /usr/local/share, /usr/share), not
# Homebrew's own (/opt/homebrew/etc and /opt/homebrew/share before
# those). The app points the loader at its own MoltenVK either way;
# - the command-line tools of SPIRV-Tools and glslang aren't built (the
# game uses only the libraries).
#
# Each source archive is checked against its SHA-256 (the archives Homebrew's
# formulas pin, from the projects' own GitHub releases and tags; FFmpeg's from
# ffmpeg.org, the one scripts/setup_tools.sh pins) and kept in the downloads
# folder (default <prefix>/downloads). An archive that's already there, or in
# tools/ffmpeg-src for FFmpeg, is used without downloading it. Each project's
# license files go to <prefix>/share/licenses/<project>/, and
# <prefix>/share/speedbreaker-deps.json records what was built, from what and
# how; make_app.py copies both into the app. Nothing of the machine it's built
# on ends up in the libraries (the sources are mapped to "." as Homebrew does),
# and nothing of when or where: SOURCE_DATE_EPOCH is each source's newest file,
# as Homebrew sets it (SPIRV-Tools' version string carries it), and each library
# is linked with the rpath it is installed with (CMAKE_BUILD_WITH_INSTALL_RPATH),
# never one naming the work folder or the prefix: the linker lays the code out
# after the load commands and hashes the file into its LC_UUID, and CMake's
# install step only deletes such an rpath afterwards, so its length moved
# libglslang's code and it changed libSPIRV-Tools-opt's UUID on every build.
# So a rebuild gives the same libraries, byte for byte (every .dylib, and
# FFmpeg-XMA's archives), whatever the prefix's path and the (temporary) work
# folder's; what differs is only what names the prefix (the .pc files) and
# the dates in libSDL3_test.a, neither in the app. Every compiler is the one
# recorded: Apple's clang from xcrun (FFmpeg's configure too, which would
# otherwise take the first `gcc` on PATH), with the cmake and ninja versions
# next to it.
#
# Nothing newer than the target is used unchecked: a library that calls a macOS
# API newer than the target without an @available check compiles with only a
# warning (clang's -Wunguarded-availability-new, on by default) and links it
# weakly, and on the older macOS that call is to null (an Objective-C method
# isn't even an import, so scripts/macos/check_app.py can't see it). Each
# project's build fails here on any such warning in its log, and on any
# compile command that silences them; a control compile first checks the
# warning's wording is still the one looked for. Not -Werror in the flags: that
# would also reach CMake's configure checks, and a feature test failing on it
# would quietly turn a feature off. MoltenVK, the one library the app takes
# from Homebrew (built for macOS 12.0), is not built here, so not checked
# here: its calls to newer Metal APIs rest on its own runtime checks.
#
# Then build the game against it (the README's cmake line, plus two options):
# 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_PREFIX_PATH=<prefix> -DFFMPEG_XMA=<prefix>/ffmpeg-xma
# ninja -C build/main SpeedBreaker && scripts/macos/make_app.py --dmg
#
# Needs: cmake 3.23+, ninja, curl, python3 and Apple's command line tools. No
# Homebrew library is used: Homebrew's prefixes are kept out of every search.
# A <prefix> made by this script before is rebuilt (its downloads stay);
# any other non-empty folder is refused.
set -euo pipefail
[ $# -ge 1 ] || { echo "usage: $0 <prefix> [<downloads folder>]" >&2; exit 2; }
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
mkdir -p "$1"
PREFIX="$(cd "$1" && pwd -P)"
DOWNLOADS="${2:-$PREFIX/downloads}"
mkdir -p "$DOWNLOADS"
DOWNLOADS="$(cd "$DOWNLOADS" && pwd -P)"
TARGET="${MACOSX_DEPLOYMENT_TARGET:-$(sed -n 's/^set(SB_OSX_DEFAULT_TARGET "\([0-9.]*\)").*/\1/p' "$ROOT/CMakeLists.txt" | head -1)}"
[[ "$TARGET" =~ ^[0-9]+(\.[0-9]+)*$ ]] || { echo "build_deps.sh: no deployment target in $ROOT/CMakeLists.txt" >&2; exit 1; }
export MACOSX_DEPLOYMENT_TARGET="$TARGET"
CC="$(xcrun --find clang)"
CXX="$(xcrun --find clang++)"
SDK="$(xcrun --sdk macosx --show-sdk-path)"
JOBS="$(sysctl -n hw.ncpu)"
OPT="-Os -DNDEBUG" # Homebrew's optimisation (see the top)
# A prefix of ours (its marker says so, even from a run that failed) is
# emptied but for its downloads; anything else must be empty.
MARKER="$PREFIX/.speedbreaker-deps"
if [ -f "$MARKER" ]; then
find "$PREFIX" -mindepth 1 -maxdepth 1 ! -path "$DOWNLOADS" ! -name downloads -exec rm -rf {} +
elif [ -n "$(find "$PREFIX" -mindepth 1 -maxdepth 1 ! -path "$DOWNLOADS" ! -name downloads | head -1)" ]; then
echo "build_deps.sh: $PREFIX isn't empty and wasn't made by this script" >&2
exit 1
fi
echo "made by SpeedBreaker's scripts/macos/build_deps.sh (it may empty this folder to rebuild it)" > "$MARKER"
LOGS="$PREFIX/build-logs"
mkdir -p "$LOGS" "$PREFIX/share/licenses"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/sb-deps.XXXXXX")"
WORK="$(cd "$WORK" && pwd -P)"
trap 'rm -rf "$WORK"' EXIT
# name | version | URL | SHA-256 | archive name
SOURCES=(
"Vulkan-Headers|1.4.357.0|https://github.com/KhronosGroup/Vulkan-Headers/archive/refs/tags/vulkan-sdk-1.4.357.0.tar.gz|e87dce08116151f6b6d7de6b6faf41498e87e6cf848ff16fa3bd5402190ad4a3|Vulkan-Headers-vulkan-sdk-1.4.357.0.tar.gz"
"SPIRV-Headers|1.4.357.0|https://github.com/KhronosGroup/SPIRV-Headers/archive/refs/tags/vulkan-sdk-1.4.357.0.tar.gz|4d703067a7e06331ccb37bdfed3f9b7879cc61969a2689ae95c95db34a47ff07|SPIRV-Headers-vulkan-sdk-1.4.357.0.tar.gz"
"SPIRV-Tools|1.4.357.0|https://github.com/KhronosGroup/SPIRV-Tools/archive/refs/tags/vulkan-sdk-1.4.357.0.tar.gz|d31e7109b6ef3559067e53e520870eafed7c9534d00db9728814b6df03fa4a5e|SPIRV-Tools-vulkan-sdk-1.4.357.0.tar.gz"
"glslang|16.6.0|https://github.com/KhronosGroup/glslang/archive/refs/tags/16.6.0.tar.gz|9c09b901149c729df745057dafa815278aaa101b84d2b6e14f16a42de52f97f2|glslang-16.6.0.tar.gz"
"Vulkan-Loader|1.4.357.0|https://github.com/KhronosGroup/Vulkan-Loader/archive/refs/tags/vulkan-sdk-1.4.357.0.tar.gz|54f2537df22313768da0317dda2abdaaab7711b4081c48c869a79db343d0ae70|Vulkan-Loader-vulkan-sdk-1.4.357.0.tar.gz"
"SDL3|3.4.16|https://github.com/libsdl-org/SDL/releases/download/release-3.4.16/SDL3-3.4.16.tar.gz|7322236cd12090c3eb40b9728be4d49c76f66ad17d04369584d4ecad5cf77c68|SDL3-3.4.16.tar.gz"
"FFmpeg|7.1.1|https://ffmpeg.org/releases/ffmpeg-7.1.1.tar.xz|733984395e0dbbe5c046abda2dc49a5544e7e0e1e2366bba849222ae9e3a03b1|ffmpeg-7.1.1.tar.xz"
)
field() { local IFS='|'; local a=($1); echo "${a[$2]}"; }
source_of() { local s; for s in "${SOURCES[@]}"; do [ "$(field "$s" 0)" = "$1" ] && { echo "$s"; return; }; done; return 1; }
# fetch <name>: the archive's path, downloaded (into a temporary file first)
# unless it's already in the downloads folder; checked either way.
fetch() {
local s url sum file
s="$(source_of "$1")"; url="$(field "$s" 2)"; sum="$(field "$s" 3)"; file="$DOWNLOADS/$(field "$s" 4)"
if [ ! -f "$file" ] && [ "$1" = FFmpeg ] && [ -f "$ROOT/tools/ffmpeg-src/$(field "$s" 4)" ]; then
cp "$ROOT/tools/ffmpeg-src/$(field "$s" 4)" "$file.part" && mv "$file.part" "$file"
fi
if [ ! -f "$file" ]; then
echo "downloading $url" >&2
# (errexit doesn't reach inside $(fetch): check here, and never keep a
# partial download under the archive's name)
if ! curl -fsSL -o "$file.part" "$url"; then
rm -f "$file.part"; echo "build_deps.sh: couldn't download $url" >&2; exit 1
fi
mv "$file.part" "$file"
fi
if ! echo "$sum $file" | shasum -a 256 -c - > /dev/null; then
echo "build_deps.sh: $file isn't the expected archive (SHA-256 $sum)" >&2
exit 1
fi
echo "$file"
}
# unpack <name>: the project's source folder (the archive's one top-level
# folder) in the work folder. Its newest file's time is kept in
# <folder>.epoch, before the build writes anything into it.
unpack() {
local archive dest="$WORK/src/$1"
archive="$(fetch "$1")" || exit 1
mkdir -p "$dest"
tar -xf "$archive" -C "$dest"
set -- "$dest"/*
[ $# -eq 1 ] && [ -d "$1" ] || { echo "build_deps.sh: $archive doesn't hold one folder" >&2; exit 1; }
find "$1" -type f -exec stat -f %m {} + | sort -n | tail -1 > "$1.epoch"
[ -s "$1.epoch" ] || { echo "build_deps.sh: no files in $archive" >&2; exit 1; }
echo "$1"
}
# licenses <name> <source folder> <file...>: the project's license files,
# kept at their paths in the source (each must exist).
licenses() {
local name=$1 src=$2 f
shift 2
for f in "$@"; do
[ -f "$src/$f" ] || { echo "build_deps.sh: $name has no $f" >&2; exit 1; }
mkdir -p "$PREFIX/share/licenses/$name/$(dirname "$f")"
cp "$src/$f" "$PREFIX/share/licenses/$name/$f"
done
}
# cmake_project <name> <source folder> [cmake options...]: configure, build
# and install, as Homebrew's formulas do (cmake -S . -B build, with its
# standard options), with this build's target and the sources mapped to ".".
cmake_project() {
local name=$1 src=$2
shift 2
local map="-ffile-prefix-map=$src=."
local log="$LOGS/$name.log"
echo "== $name" >&2
(cd "$src" && export SOURCE_DATE_EPOCH="$(cat "$src.epoch")" && cmake -S . -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$PREFIX" -DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_FIND_FRAMEWORK=LAST -DBUILD_TESTING=OFF -Wno-dev \
-DCMAKE_OSX_DEPLOYMENT_TARGET="$TARGET" -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT="$SDK" \
-DCMAKE_C_COMPILER="$CC" -DCMAKE_CXX_COMPILER="$CXX" -DCMAKE_OBJC_COMPILER="$CC" \
"-DCMAKE_C_FLAGS=$map" "-DCMAKE_CXX_FLAGS=$map" "-DCMAKE_OBJC_FLAGS=$map" "-DCMAKE_ASM_FLAGS=$map" \
"-DCMAKE_C_FLAGS_RELEASE=$OPT" "-DCMAKE_CXX_FLAGS_RELEASE=$OPT" "-DCMAKE_OBJC_FLAGS_RELEASE=$OPT" \
-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-headerpad_max_install_names \
-DCMAKE_MODULE_LINKER_FLAGS=-Wl,-headerpad_max_install_names \
-DCMAKE_EXE_LINKER_FLAGS=-Wl,-headerpad_max_install_names \
-DCMAKE_PREFIX_PATH="$PREFIX" "-DCMAKE_IGNORE_PREFIX_PATH=/opt/homebrew;/usr/local" \
-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
"$@" && cmake --build build -j "$JOBS" && cmake --install build) > "$log" 2>&1 \
|| { echo "build_deps.sh: $name failed; see $log" >&2; tail -20 "$log" >&2; exit 1; }
cp "$src/build/install_manifest.txt" "$LOGS/$name.installed.txt"
availability "$name" "$log" "$src/build/compile_commands.json"
}
# availability <name> <log> [compile_commands.json]: the project's build used
# nothing newer than the target unchecked (see the top): no availability
# warning in its log, and no compile command that silences them.
AVAILABILITY_WARNING='is only available on macOS [0-9.]+ or newer|-W(unguarded|partial)-availability'
availability() {
local found
found="$(grep -a -E "$AVAILABILITY_WARNING" "$2" | sort -u || true)"
if [ -n "$found" ]; then
echo "build_deps.sh: $1 uses an API newer than macOS $TARGET without an @available check (see $2):" >&2
echo "$found" | head -20 >&2
exit 1
fi
[ -n "${3:-}" ] || return 0
if [ ! -f "$3" ]; then # (CMake writes none for a project that compiles nothing: the headers)
! grep -q -a -E 'Building (C|CXX|OBJC|OBJCXX|ASM) object' "$2" && return 0
echo "build_deps.sh: $1 compiled code but wrote no $3" >&2; exit 1
fi
found="$(python3 -I -c '
import json, shlex, sys
for c in json.load(open(sys.argv[1])):
args = c.get("arguments") or shlex.split(c["command"])
bad = sorted({a for a in args if a in ("-w", "-Wno-everything") or a.startswith((
"-Wno-unguarded-availability", "-Wno-partial-availability", "-Wno-availability",
"-Wno-error=unguarded-availability", "-Wno-error=partial-availability"))})
if bad:
print(c["file"] + ": " + " ".join(bad))
' "$3")" || { echo "build_deps.sh: couldn't read $3" >&2; exit 1; }
if [ -n "$found" ]; then
echo "build_deps.sh: $1 compiles with availability warnings silenced, so they can't be checked:" >&2
echo "$found" | head -20 >&2
exit 1
fi
}
# The control: the compiler's warning for a newer API used unchecked must still
# read the way `availability` looks for (it needs an API newer than the target).
CONTROL_API=timingsafe_enable_if_supported CONTROL_HEADER=timingsafe.h CONTROL_MACOS=15.2
if [ "$TARGET" != "$CONTROL_MACOS" ] && [ "$(printf '%s\n' "$TARGET" "$CONTROL_MACOS" | sort -V | head -1)" = "$TARGET" ]; then
mkdir -p "$WORK/control"
printf '#include <%s>\nint control(void) { return %s(); }\n' "$CONTROL_HEADER" "$CONTROL_API" > "$WORK/control/control.c"
"$CC" -c -arch arm64 -isysroot "$SDK" -mmacosx-version-min="$TARGET" "$WORK/control/control.c" -o "$WORK/control/control.o" \
> "$WORK/control/control.log" 2>&1 || { cat "$WORK/control/control.log" >&2; exit 1; }
grep -q -E "$AVAILABILITY_WARNING" "$WORK/control/control.log" \
|| { echo "build_deps.sh: the control ($CONTROL_API, macOS $CONTROL_MACOS) gave no warning that reads as expected:" >&2
cat "$WORK/control/control.log" >&2; exit 1; }
echo "control: $CC warns on $CONTROL_API (macOS $CONTROL_MACOS) built for $TARGET, as looked for" > "$LOGS/availability-control.txt"
else
echo "control: skipped ($CONTROL_API is macOS $CONTROL_MACOS, not newer than $TARGET)" > "$LOGS/availability-control.txt"
fi
export PKG_CONFIG_LIBDIR="$WORK/no-pkgconfig" # no .pc file of Homebrew's or anyone's
export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig"
src="$(unpack Vulkan-Headers)"
cmake_project Vulkan-Headers "$src"
licenses Vulkan-Headers "$src" LICENSE.md LICENSES/Apache-2.0.txt LICENSES/MIT.txt
src="$(unpack SPIRV-Headers)"
cmake_project SPIRV-Headers "$src"
licenses SPIRV-Headers "$src" LICENSE
src="$(unpack SPIRV-Tools)"
cmake_project SPIRV-Tools "$src" -DCMAKE_INSTALL_RPATH=@loader_path/../lib -DBUILD_SHARED_LIBS=ON \
-DPython3_EXECUTABLE="$(command -v python3)" -DSPIRV-Headers_SOURCE_DIR="$PREFIX" \
-DSPIRV_SKIP_TESTS=ON -DSPIRV_TOOLS_BUILD_STATIC=OFF -DSPIRV_SKIP_EXECUTABLES=ON
licenses SPIRV-Tools "$src" LICENSE
src="$(unpack glslang)"
cmake_project glslang "$src" -DBUILD_EXTERNAL=OFF -DALLOW_EXTERNAL_SPIRV_TOOLS=ON -DBUILD_SHARED_LIBS=ON \
-DENABLE_CTEST=OFF -DENABLE_OPT=ON -DCMAKE_INSTALL_RPATH=@loader_path/../lib -DENABLE_GLSLANG_BINARIES=OFF -DGLSLANG_TESTS=OFF \
-DPython3_EXECUTABLE="$(command -v python3)"
licenses glslang "$src" LICENSE.txt
src="$(unpack Vulkan-Loader)"
cmake_project Vulkan-Loader "$src" -DVULKAN_HEADERS_INSTALL_DIR="$PREFIX" -DCMAKE_INSTALL_SYSCONFDIR=/usr/local/etc
licenses Vulkan-Loader "$src" LICENSE.txt
# cJSON (MIT) is compiled into the loader; its notice is the top of its source.
sed -n '1,/\*\//p' "$src/loader/cJSON.c" > "$PREFIX/share/licenses/Vulkan-Loader/cJSON-LICENSE.txt"
grep -q "Permission is hereby granted" "$PREFIX/share/licenses/Vulkan-Loader/cJSON-LICENSE.txt" \
|| { echo "build_deps.sh: cJSON's notice not found in the loader's loader/cJSON.c" >&2; exit 1; }
src="$(unpack SDL3)"
cmake_project SDL3 "$src" -DSDL_TESTS=OFF -DSDL_X11_XTEST=OFF
# SDL3 itself, and the two parts it includes with their own license files:
# HIDAPI (controllers; SDL takes it under one of its three licenses) and
# yuv2rgb (video colour conversion).
licenses SDL3 "$src" LICENSE.txt src/hidapi/LICENSE.txt src/hidapi/LICENSE-bsd.txt src/hidapi/LICENSE-gpl3.txt \
src/hidapi/LICENSE-orig.txt src/video/yuv2rgb/LICENSE
echo "== FFmpeg" >&2
ffmpeg_archive="$(fetch FFmpeg)" || exit 1
# Its configure takes the compiler by name (it records the command line in
# libavutil): `clang` from the folder of the one used above, first on PATH,
# with the same SDK (SDKROOT, which xcrun's /usr/bin shims would set).
SDKROOT="$SDK" PATH="$(dirname "$CC"):$PATH" "$ROOT/scripts/build_ffmpeg_xma.sh" "$ffmpeg_archive" "$PREFIX/ffmpeg-xma" \
--cc=clang > "$LOGS/FFmpeg.log" 2>&1 \
|| { echo "build_deps.sh: FFmpeg failed; see $LOGS/FFmpeg.log" >&2; tail -20 "$LOGS/FFmpeg.log" >&2; exit 1; }
availability FFmpeg "$LOGS/FFmpeg.log" # (its configure adds -Werror=partial-availability on macOS too)
FFMPEG_CC="$(PATH="$(dirname "$CC"):$PATH" clang --version | head -1)"
[ "$FFMPEG_CC" = "$("$CC" --version | head -1)" ] || { echo "build_deps.sh: FFmpeg's clang ($FFMPEG_CC) isn't $CC" >&2; exit 1; }
mkdir -p "$WORK/ffmpeg-licenses"
tar -xf "$ffmpeg_archive" -C "$WORK/ffmpeg-licenses" ffmpeg-7.1.1/COPYING.LGPLv2.1 ffmpeg-7.1.1/LICENSE.md
licenses FFmpeg "$WORK/ffmpeg-licenses/ffmpeg-7.1.1" COPYING.LGPLv2.1 LICENSE.md
cp "$ROOT"/patches/ffmpeg/*.patch "$PREFIX/share/licenses/FFmpeg/"
# The check: every library is arm64 only and built for exactly this macOS.
minos() { otool -l "$1" | awk '/LC_BUILD_VERSION/{b=1} b&&/minos/{print $2; b=0}' | sort -u; }
bad=0
for f in "$PREFIX"/lib/*.dylib "$PREFIX"/ffmpeg-xma/lib/*.a; do
[ -L "$f" ] && continue
m="$(minos "$f" | tr '\n' ' ' | sed 's/ $//')"
a="$(lipo -archs "$f" 2>/dev/null || echo '?')"
if [ "$m" != "$TARGET" ] || [ "$a" != arm64 ]; then
echo "build_deps.sh: $f is $a for macOS $m, not arm64 for $TARGET" >&2; bad=1
fi
done
[ $bad -eq 0 ] || exit 1
# The record make_app.py reads: each project, its source and the libraries it
# installed.
python3 -I - "$PREFIX" "$TARGET" "$("$CC" --version | head -1) ($CC)" "$(xcrun --sdk macosx --show-sdk-version)" \
"$(cmake --version | head -1)" "ninja $(ninja --version)" "${SOURCES[@]}" <<'EOF'
import json, os, sys
prefix, target, compiler, sdk, cmake, ninja = sys.argv[1:7]
projects = {}
for line in sys.argv[7:]:
name, version, url, sha256, archive = line.split('|')
installed = os.path.join(prefix, 'build-logs', name + '.installed.txt')
files = open(installed).read().split() if os.path.exists(installed) else []
licenses = os.path.join(prefix, 'share/licenses', name)
projects[name] = {
'version': version, 'url': url, 'sha256': sha256,
'libraries': sorted(os.path.basename(f) for f in files if f.endswith('.dylib')),
'licenses': sorted(os.path.relpath(os.path.join(d, f), licenses)
for d, _, fs in os.walk(licenses) for f in fs),
}
projects['FFmpeg']['libraries'] = ['ffmpeg-xma/lib/libavcodec.a', 'ffmpeg-xma/lib/libavutil.a']
record = {'deployment_target': target, 'architecture': 'arm64', 'compiler': compiler, 'sdk': sdk,
'tools': {'cmake': cmake, 'ninja': ninja},
'flags': 'CMake Release with -Os -DNDEBUG (as Homebrew), no -mcpu/-march; FFmpeg: its configure defaults '
'(-O3, as v0.1.0) with --cc=clang, the compiler above; SOURCE_DATE_EPOCH: each source\'s newest file',
'projects': projects}
with open(os.path.join(prefix, 'share/speedbreaker-deps.json'), 'w') as f:
json.dump(record, f, indent=2)
f.write('\n')
EOF
echo "libraries for macOS $TARGET+ (arm64) in $PREFIX:"
ls "$PREFIX/lib" | grep '\.dylib$' | tr '\n' ' '; echo
echo "FFmpeg-XMA in $PREFIX/ffmpeg-xma; licenses in $PREFIX/share/licenses; record in $PREFIX/share/speedbreaker-deps.json"
+378
View File
@@ -0,0 +1,378 @@
#!/usr/bin/env python3
# SpeedBreaker. GPL-3.0-or-later (see COPYING).
#
# check_app.py <SpeedBreaker.app | SpeedBreaker-macos.dmg> [--min-macos=<version>] [--sdk=<path>]
# [--same-code-as=<SpeedBreaker.app | dmg>]
#
# The release's checks of a Mac app (scripts/macos/make_app.py), that it opens on the oldest macOS it
# says it does (default: CMakeLists.txt's SB_OSX_DEFAULT_TARGET, 15.0) and needs nothing outside itself.
# A .dmg is checked against its .sha256 (when there is one) and mounted read-only to check the app in it.
# 1. Info.plist: LSMinimumSystemVersion is that macOS.
# 2. Every Mach-O in the app: arm64 only, built for that macOS or an older one; the game for exactly it.
# 3. Loads nothing outside the app or the system: every library @rpath/ or /usr/lib/ or /System/, and
# rpaths only @executable_path/../Frameworks and @loader_path; no build machine's folders in the files
# (/opt/homebrew, /usr/local/Cellar, /Users/).
# 4. The signature verifies (codesign --deep --strict).
# 5. No system symbol it imports is newer than that macOS, weak imports included. The SDK's .tbd stubs say
# what the newest macOS exports, not since when, so this asks the SDK's headers: each import of a system
# library is referenced in an Objective-C file compiled for that macOS with -Werror=unguarded-availability,
# and an import whose declaration says API_AVAILABLE(macos(x)), x newer, fails with x. Weak imports too:
# code that calls a newer API without an @available check compiles with only a warning and links it
# weakly, so it is null (a crash, or a nil class) on the older macOS; an @available check links it weakly
# as well. A newer weak import passes only when GUARDED below names it, once someone has read that every
# use of it is behind an @available check. libc++ is left to its own headers, which refuse at compile time
# what the target lacks (LLVM 18 = macOS 15.0, LLVM 19 = 15.4). What the headers don't declare is listed
# (in practice what the compiler emits itself: stack protection, ___chkstk_darwin, ARC's objc_* entry
# points; it emits only what the target has). First, the control: a small library that uses three APIs
# known to be newer (macOS 15.1 and 15.2: a libSystem function, a CoreGraphics constant, an AppKit class)
# without @available is built for that macOS and its imports are checked the same way. All three must be
# caught, as weak imports, or the check itself is broken. Objective-C methods are never imports: the
# game's build (-Werror=unguarded-availability-new) and scripts/macos/build_deps.sh (any availability
# warning in a library's build fails it) are what catch those. MoltenVK is outside both: it is
# Homebrew's bottle (built for macOS 12.0), so its calls to Metal APIs newer than the floor rest on
# MoltenVK's own runtime checks, and on a run on a real Apple GPU on the oldest macOS.
# 6. The code, for the record: each Mach-O's __TEXT,__text SHA-256 (its machine code; build dates and
# commits live elsewhere, so a rebuild of the same sources at the same paths gives the same hash).
# --same-code-as=<app | dmg>: which files' code differs from that build's (the one that was measured
# or tested, say). Informational: it doesn't fail the check, and it is no proof of speed (a release
# merges other work, so its game code differs from any earlier build's anyway). That proof is
# scripts/macos/check_codegen.py, run on the release's own commit: its code for this macOS against
# its code for macOS 26.0, function by function.
# Needs Apple's command line tools (otool, nm, codesign, clang, the macOS SDK).
import collections, contextlib, hashlib, os, plistlib, re, struct, subprocess, sys, tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
ARGS = [a for a in sys.argv[1:] if not a.startswith('--')]
OPTS = dict(a[2:].split('=', 1) if '=' in a else (a[2:], '') for a in sys.argv[1:] if a.startswith('--'))
def run(*args, check=True):
return subprocess.run(args, check=check, capture_output=True, text=True).stdout
def version_tuple(v):
v = tuple(int(x) for x in str(v).split('.'))
return v + (0,) * (3 - len(v))
def show(v):
return '.'.join(map(str, v[:2] if v[2] == 0 else v))
def default_target():
with open(os.path.join(ROOT, 'CMakeLists.txt')) as f:
return re.search(r'set\(SB_OSX_DEFAULT_TARGET "([0-9.]+)"\)', f.read()).group(1)
def macho_files(app):
files = []
for d, _, names in os.walk(os.path.join(app, 'Contents')):
for n in names:
p = os.path.join(d, n)
if not os.path.islink(p) and run('file', '-b', p).startswith('Mach-O'):
files.append(p)
return sorted(files)
def minos(path):
"""The newest macOS any Mach-O in the file needs (LC_BUILD_VERSION's minos, or the older
LC_VERSION_MIN_MACOSX), (0, 0, 0) if none says."""
found, field = [], None
for line in run('otool', '-l', path).splitlines():
line = line.strip()
if line.startswith('cmd '):
field = {'cmd LC_BUILD_VERSION': 'minos', 'cmd LC_VERSION_MIN_MACOSX': 'version'}.get(line)
elif field and line.startswith(field + ' '):
found.append(version_tuple(line.split()[1]))
field = None
return max(found) if found else (0, 0, 0)
def libraries(path):
lines = run('otool', '-L', path).splitlines()[1:]
own = run('otool', '-D', path).splitlines()[1:]
return [l.strip().split(' (')[0] for l in lines if l.strip().split(' (')[0] not in own]
def rpaths(path):
return re.findall(r'cmd LC_RPATH\n\s+cmdsize \d+\n\s+path (.+?) \(offset', run('otool', '-l', path))
# --- 5: imports against the SDK's availability annotations -------------------------------------------
LIBSYSTEM_HEADERS = '''stdlib.h stdio.h string.h strings.h stdint.h inttypes.h math.h time.h errno.h signal.h unistd.h
fcntl.h dirent.h dlfcn.h locale.h xlocale.h wchar.h wctype.h ctype.h setjmp.h stdarg.h assert.h pthread.h pthread/qos.h
sched.h spawn.h execinfo.h libgen.h glob.h fnmatch.h getopt.h poll.h syslog.h pwd.h grp.h termios.h utime.h copyfile.h
libproc.h notify.h removefile.h malloc/malloc.h libkern/OSAtomic.h libkern/OSCacheControl.h mach/mach.h mach/mach_time.h
mach/mach_vm.h mach/thread_act.h mach/thread_policy.h mach/vm_map.h mach-o/dyld.h mach-o/getsect.h dispatch/dispatch.h
os/log.h os/lock.h os/signpost.h os/proc.h os/activity.h os/workgroup.h os/os_sync_wait_on_address.h sys/types.h
sys/mman.h sys/sysctl.h sys/stat.h sys/statvfs.h sys/time.h sys/resource.h sys/ioctl.h sys/event.h sys/socket.h
sys/select.h sys/wait.h sys/param.h sys/mount.h sys/attr.h sys/xattr.h sys/uio.h sys/file.h sys/utsname.h sys/un.h
sys/random.h sys/clonefile.h sys/qos.h sys/kdebug_signpost.h netinet/in.h arpa/inet.h netdb.h ifaddrs.h net/if.h
uuid/uuid.h asl.h xpc/xpc.h libunwind.h unwind.h timingsafe.h langinfo.h crt_externs.h CommonCrypto/CommonDigest.h
objc/runtime.h objc/message.h objc/objc-exception.h objc/objc-sync.h'''.split()
FRAMEWORK_HEADERS = {
'IOKit': ['IOKit/IOKitLib.h', 'IOKit/hid/IOHIDManager.h', 'IOKit/hid/IOHIDLib.h', 'IOKit/pwr_mgt/IOPMLib.h',
'IOKit/graphics/IOGraphicsLib.h', 'IOKit/IOCFPlugIn.h', 'IOKit/usb/IOUSBLib.h', 'IOKit/ps/IOPowerSources.h',
'IOKit/ps/IOPSKeys.h', 'IOKit/serial/IOSerialKeys.h']}
# The control's three APIs, newer than 15.0: (name, library, macOS version, a pointer to it).
CONTROLS = [('timingsafe_enable_if_supported', 'libSystem', '15.2', '(const void *)&timingsafe_enable_if_supported'),
('kCGUseLegacyHDREcosystem', 'CoreGraphics', '15.1', '(const void *)&kCGUseLegacyHDREcosystem'),
('NSWritingToolsCoordinatorContext', 'AppKit', '15.2',
'(__bridge const void *)[NSWritingToolsCoordinatorContext class]')]
# Weak imports newer than the floor that are known to sit behind an @available check in every use:
# {name: (macOS version, where the checks are)}. Add one only after reading each use. (None in v0.1.1.)
GUARDED = {}
def system_imports(path):
"""[(symbol, weak, library short name)] of a file's imports from system libraries."""
system = set()
for p in libraries(path):
if p.startswith(('/usr/lib/', '/System/')):
leaf = os.path.basename(p)
system.add(re.sub(r'(\.[A-Za-z0-9]+)*\.dylib$', '', leaf) if leaf.endswith('.dylib') else leaf)
res = []
for line in run('nm', '-m', '-u', path).splitlines():
m = re.match(r'\s*\(undefined\) (weak )?external (\S+) \(from ([^)]+)\)', line)
if m and m.group(3) in system:
res.append((m.group(2), bool(m.group(1)), m.group(3)))
return res
def reference(sym):
m = re.match(r'_OBJC_(?:META)?CLASS_\$_(\w+)$', sym)
if m:
return m.group(1), f'(void)[{m.group(1)} class];'
if sym.startswith(('_OBJC_EHTYPE_$_', '_OBJC_IVAR_$_', '__Z')) or not sym.startswith('_'):
return None
name = sym[1:].split('$')[0] # _fopen$DARWIN_EXTSN -> fopen
return (name, f'(void)&{name};') if re.fullmatch(r'[A-Za-z_]\w*', name) else None
def probe(sdk, minimum, wanted, libs):
"""Compiles references to `wanted` ({name: statement}) for `minimum`: ({name: newer version}, {undeclared})."""
includes = list(LIBSYSTEM_HEADERS)
for lib in sorted(libs):
if lib not in ('libSystem', 'libobjc', 'libc++', 'libc++abi'):
includes += FRAMEWORK_HEADERS.get(lib, [f'{lib}/{lib}.h'])
lines = []
for h in includes:
lines += [f'#if __has_include(<{h}>)', f'#import <{h}>', '#endif']
lines.append('void probe(void) {')
at = {}
for name, stmt in wanted.items():
lines.append(f' {stmt}')
at[len(lines)] = name
lines.append('}')
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, 'probe.m')
with open(src, 'w') as f:
f.write('\n'.join(lines) + '\n')
r = subprocess.run(['xcrun', 'clang', '-x', 'objective-c', '-fsyntax-only', '-fobjc-arc', '-fmodules', '-arch', 'arm64',
'-isysroot', sdk, f'-mmacosx-version-min={minimum}', '-Wunguarded-availability',
'-Wunguarded-availability-new', '-Werror=unguarded-availability',
'-Werror=unguarded-availability-new', '-Wno-deprecated-declarations', '-ferror-limit=0', src],
capture_output=True, text=True)
newer, undeclared = {}, set()
for line in r.stderr.splitlines():
m = re.match(r'.*probe\.m:(\d+):\d+: (?:error|warning): (.*)', line)
name = at.get(int(m.group(1))) if m else None
if not name:
continue
a = re.search(r'is only available on macOS ([0-9.]+) or newer', m.group(2))
if a:
newer[name] = a.group(1)
elif re.search(r'undeclared|must be imported|unknown receiver', m.group(2)):
undeclared.add(name)
return newer, undeclared
def control_imports(sdk, minimum):
"""The control library: CONTROLS' APIs used without @available, built for `minimum` the way any library of
the app is (clang only warns, and links them weakly). Its system imports, or None and the compiler's output."""
src = ['#import <AppKit/AppKit.h>', '#import <CoreGraphics/CoreGraphics.h>', '#include <timingsafe.h>',
f'const void *sb_control[{len(CONTROLS)}];', 'void sb_control_fill(void) {']
src += [f' sb_control[{i}] = {expr};' for i, (_, _, _, expr) in enumerate(CONTROLS)] + ['}']
with tempfile.TemporaryDirectory() as tmp:
m, lib = os.path.join(tmp, 'control.m'), os.path.join(tmp, 'libcontrol.dylib')
with open(m, 'w') as f:
f.write('\n'.join(src) + '\n')
r = subprocess.run(['xcrun', 'clang', '-x', 'objective-c', '-dynamiclib', '-fobjc-arc', '-arch', 'arm64',
'-isysroot', sdk, f'-mmacosx-version-min={minimum}', '-Wno-unguarded-availability-new',
'-framework', 'AppKit', '-framework', 'CoreGraphics', '-o', lib, m],
capture_output=True, text=True)
if r.returncode != 0:
return None, r.stderr.strip()
return system_imports(lib), ''
def assess(imports, minimum, sdk):
"""imports: [(user, symbol, weak, library)]. -> (names checked, {name: users}, {name: every import of it
weak}, {name: newer version}, {undeclared names})."""
wanted, users, weak, libs = {}, collections.defaultdict(set), {}, set()
for user, sym, is_weak, lib in imports:
r = reference(sym)
if r:
wanted[r[0]] = r[1]
users[r[0]].add(f'{user} ({lib})')
weak[r[0]] = weak.get(r[0], True) and is_weak
libs.add(lib)
newer, undeclared = probe(sdk, minimum, wanted, libs)
return wanted, users, weak, newer, undeclared
def check_imports(files, minimum, sdk):
ok = True
control, err = control_imports(sdk, minimum)
if control is None:
print(f' FAIL: the control library didn\'t build: {err}')
return False
_, _, cweak, cnewer, _ = assess([('control', s, w, l) for s, w, l in control], minimum, sdk)
caught = {n: (cnewer.get(n), cweak.get(n)) for n, _, _, _ in CONTROLS}
if any(caught[n] != (v, True) for n, _, v, _ in CONTROLS):
print(f' FAIL: the check doesn\'t catch the control\'s newer weak imports (it found {caught}): fix check_app.py')
return False
print(f' control: {", ".join(f"{n} (macOS {v}, weak)" for n, (v, _) in caught.items())} caught')
imports = [(os.path.basename(f), s, w, l) for f in files for s, w, l in system_imports(f)
if l not in ('libc++', 'libc++abi')]
wanted, users, weak, newer, undeclared = assess(imports, minimum, sdk)
print(f' {len(imports)} system imports ({sum(w for _, _, w, _ in imports)} weak); {len(wanted)} distinct names, '
f'{len(wanted) - len(undeclared)} checked against their declarations, newer than macOS {minimum}: {len(newer)}')
for name, v in sorted(newer.items()):
who, g = ', '.join(sorted(users[name])), GUARDED.get(name)
if weak[name] and g and version_tuple(g[0]) == version_tuple(v):
print(f' guarded: {name} (macOS {v}, weak), imported by {who}: {g[1]}')
elif weak[name]:
print(f' FAIL: {name} is macOS {v}+, imported weakly by {who}: null before macOS {v}, so every use must be '
f'behind an @available check (once read, list it in check_app.py\'s GUARDED)')
ok = False
else:
print(f' FAIL: {name} is macOS {v}+, imported by {who} (not weakly: the app wouldn\'t open before {v})')
ok = False
for name in sorted(set(GUARDED) - set(newer)):
print(f' note: GUARDED names {name}, which no file imports as newer than macOS {minimum} (remove it?)')
if undeclared:
print(f' not declared in the SDK\'s headers (the compiler\'s own entry points; not checked): '
f'{", ".join(sorted(undeclared))}')
return ok
# --- 6: the code ---------------------------------------------------------------------------------------
def text_section(path):
"""(SHA-256, size, address) of a thin 64-bit Mach-O's __TEXT,__text, or None."""
with open(path, 'rb') as f:
data = f.read()
if len(data) < 32 or struct.unpack_from('<I', data, 0)[0] != 0xfeedfacf:
return None
ncmds, off = struct.unpack_from('<I', data, 16)[0], 32
for _ in range(ncmds):
cmd, size = struct.unpack_from('<II', data, off)
if cmd == 0x19: # LC_SEGMENT_64: 72 bytes, then nsects section_64s of 80
for i in range(struct.unpack_from('<I', data, off + 64)[0]):
s = off + 72 + 80 * i
if data[s:s + 16].rstrip(b'\0') == b'__text' and data[s + 16:s + 32].rstrip(b'\0') == b'__TEXT':
addr, n = struct.unpack_from('<QQ', data, s + 32)
at = struct.unpack_from('<I', data, s + 48)[0]
return hashlib.sha256(data[at:at + n]).hexdigest(), n, addr
off += size
return None
def code_of(app):
"""{path in the app: (SHA-256, size, address) of its code}."""
return {os.path.relpath(f, app): text_section(f) for f in macho_files(app)}
@contextlib.contextmanager
def opened(target):
"""The app: `target` itself, or the one in a dmg, mounted read-only for the duration."""
if not target.endswith('.dmg'):
yield target
return
with tempfile.TemporaryDirectory() as mnt:
run('hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mnt, target)
try:
yield os.path.join(mnt, 'SpeedBreaker.app')
finally:
run('hdiutil', 'detach', mnt, check=False)
def check(app, minimum, sdk):
"""-> (every check passed, the code of each Mach-O)."""
ok = True
print(f'{app}: for macOS {minimum}+')
with open(os.path.join(app, 'Contents/Info.plist'), 'rb') as f:
plist = plistlib.load(f)
declared = plist.get('LSMinimumSystemVersion', '')
good = re.fullmatch(r'\d+(\.\d+)*', declared or '') and version_tuple(declared) == version_tuple(minimum)
print(f'1. Info.plist: LSMinimumSystemVersion {declared}, v{plist.get("CFBundleShortVersionString")} '
f'({plist.get("SpeedBreakerCommit")}): {"ok" if good else "FAIL"}')
ok &= bool(good)
files = macho_files(app)
exe = os.path.join(app, 'Contents/MacOS', plist.get('CFBundleExecutable', 'SpeedBreaker'))
floor = version_tuple(minimum)
print(f'2. {len(files)} Mach-O files:')
for f in files:
v, archs = minos(f), run('lipo', '-archs', f).strip()
good = archs == 'arm64' and (v == floor if f == exe else v <= floor)
print(f' {show(v):>6} {archs:<6} {os.path.relpath(f, app)}{"" if good else " FAIL"}')
ok &= good
if exe not in files:
print(f' FAIL: no {exe}')
ok = False
bad = []
for f in files:
bad += [f'{os.path.basename(f)}: {l}' for l in libraries(f) if not l.startswith(('@rpath/', '/usr/lib/', '/System/'))]
bad += [f'{os.path.basename(f)}: rpath {r}' for r in rpaths(f) if r not in ('@executable_path/../Frameworks', '@loader_path')]
found = sorted(set(re.findall(r'(/opt/homebrew|/usr/local/Cellar|/Users/[^/\s]+)', run('strings', '-a', f))))
bad += [f'{os.path.basename(f)}: names {s}' for s in found]
print(f'3. loads only the app and the system, names no build machine\'s folder: {"ok" if not bad else "FAIL"}')
for b in bad:
print(f' {b}')
ok &= not bad
r = subprocess.run(['codesign', '--verify', '--deep', '--strict', app], capture_output=True, text=True)
print(f'4. codesign --verify --deep --strict: {"ok" if r.returncode == 0 else "FAIL " + r.stderr.strip()}')
ok &= r.returncode == 0
print(f'5. imports against the availability in {sdk}:')
ok &= check_imports(files, minimum, sdk)
code = code_of(app)
print('6. code (__TEXT,__text SHA-256, size, address):')
for rel, c in sorted(code.items()):
print(f' {c[0]} {c[1]:>10} 0x{c[2]:x} {rel}' if c else f' (no __text) {rel}')
return ok, code
def main():
if len(ARGS) != 1:
raise SystemExit('usage: check_app.py <SpeedBreaker.app | SpeedBreaker-macos.dmg> [--min-macos=<version>] '
'[--sdk=<path>] [--same-code-as=<SpeedBreaker.app | dmg>]')
minimum = OPTS.get('min-macos') or default_target()
sdk = OPTS.get('sdk') or run('xcrun', '--sdk', 'macosx', '--show-sdk-path').strip()
target = os.path.abspath(ARGS[0])
ok = True
if target.endswith('.dmg') and os.path.exists(target + '.sha256'):
want = open(target + '.sha256').read().split()[0]
got = run('shasum', '-a', '256', target).split()[0]
print(f'{os.path.basename(target)}: SHA-256 {got}: {"matches" if got == want else "FAIL, not " + want}')
ok &= got == want
with opened(target) as app:
passed, code = check(app, minimum, sdk)
ok &= passed
if OPTS.get('same-code-as'):
ref = os.path.abspath(OPTS['same-code-as'])
with opened(ref) as other:
theirs = code_of(other)
print(f' code compared with {ref} (informational):')
for rel in sorted(set(code) | set(theirs)):
a, b = code.get(rel), theirs.get(rel)
what = ('only here' if rel not in theirs else 'only there' if rel not in code
else 'same code' if a and b and a[0] == b[0]
else 'same size, different code' if a and b and a[1] == b[1]
else f'different code ({b[1] if b else "?"} -> {a[1] if a else "?"} bytes)')
print(f' {what:<28} {rel}')
print('PASS' if ok else 'FAILED')
sys.exit(0 if ok else 1)
if __name__ == '__main__':
main()
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more