mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 23:01:20 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05838d6b8b |
No files matched your search
@@ -4,6 +4,44 @@ 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.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
@@ -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.1 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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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) ·
|
||||
@@ -62,14 +63,14 @@ and a settings menu you can open mid-race.
|
||||
- **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.
|
||||
- **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>
|
||||
@@ -108,7 +112,7 @@ 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. |
|
||||
| **Mac** (Apple silicon) | [Download][dl-macos]<br>`SpeedBreaker-macos.dmg` | MacBook Pro (M1 Pro) and Mac Studio (M4 Max), on macOS 26; macOS 15.6 (Sequoia) in a virtual machine. There is no Intel Mac build. |
|
||||
| **iPhone** and **iPad** | [Build it yourself](#iphone-and-ipad) with Xcode | iPhone 15 Pro Max (iOS 27). iPad Pro 12.9-inch (M2, iPadOS 27) with a Backbone Pro. Not on the App Store. |
|
||||
| **Windows** | Coming soon | |
|
||||
| **Android** | Coming soon | |
|
||||
@@ -129,8 +133,8 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
|
||||
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.
|
||||
- **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.
|
||||
@@ -150,10 +154,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 +177,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 +190,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,
|
||||
@@ -210,8 +227,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 +239,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.
|
||||
|
||||
@@ -237,8 +254,12 @@ 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,
|
||||
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).
|
||||
|
||||
### Mac
|
||||
|
||||
@@ -253,10 +274,15 @@ 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
|
||||
|
||||
@@ -333,7 +359,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**
|
||||
|
||||
@@ -354,7 +380,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>
|
||||
|
||||
@@ -408,7 +435,8 @@ 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)).
|
||||
@@ -416,12 +444,22 @@ Security (see [Mac setup](#mac)).
|
||||
**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 +470,11 @@ 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.
|
||||
|
||||
### Reporting a bug
|
||||
|
||||
@@ -443,14 +482,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.1-<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
|
||||
|
||||
@@ -488,16 +528,21 @@ 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
|
||||
```
|
||||
|
||||
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 +554,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 +601,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 +621,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,
|
||||
|
||||
@@ -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) |
|
||||
| **SDL3**: Copyright (C) 1997-2026 Sam Lantinga | Windows, input, audio and the app's lifecycle. In the Mac app and both SteamOS downloads. It includes HIDAPI (controllers; Copyright (c) 2010 Alan Ott, Signal 11 Software; under its BSD-style license, one of the three it offers) and yuv2rgb (Copyright (c) 2016 Adrien Descamps). | zlib; HIDAPI and yuv2rgb: BSD-3-Clause | [`LICENSES/SDL3-Zlib.txt`](LICENSES/SDL3-Zlib.txt); [`LICENSES/HIDAPI-BSD-3-Clause.txt`](LICENSES/HIDAPI-BSD-3-Clause.txt); [`LICENSES/yuv2rgb-BSD-3-Clause.txt`](LICENSES/yuv2rgb-BSD-3-Clause.txt) |
|
||||
| **glslang**: The Khronos Group Inc. and others | Compiles the game's shaders while it runs. In the Mac app and both SteamOS downloads. | BSD-3-Clause and others (its own list) | [`LICENSES/glslang.txt`](LICENSES/glslang.txt) |
|
||||
| **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 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
|
||||
|
||||
|
||||
+33
-2
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
|
||||
// What to draw after the draw: `rectCount` more copies of it (none for a
|
||||
// bar that reaches the screen edge), with `posScale` and each rectangle
|
||||
// as the scissor.
|
||||
struct Plan
|
||||
{
|
||||
Fill fill = Fill::None;
|
||||
Box box{}; // the quad's, clip space
|
||||
uint32_t argb = 0; // its colour
|
||||
uint32_t rectCount = 0;
|
||||
Rect rects[2]{};
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+780
-98
File diff suppressed because it is too large.
Load diff
+65
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
#include "install_cli.h"
|
||||
#include "installer.h"
|
||||
#include "locate.h"
|
||||
#include "placement.h"
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
@@ -129,23 +134,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;
|
||||
}
|
||||
|
||||
@@ -188,7 +216,16 @@ namespace install
|
||||
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");
|
||||
return 1;
|
||||
}
|
||||
const char* origin = game->origin == InstallOrigin::Environment ? "NFSMW_GAME_DIR"
|
||||
@@ -209,14 +246,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")
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
@@ -191,14 +199,21 @@ namespace install
|
||||
text.pop_back();
|
||||
if (text.empty() || text.front() != '/')
|
||||
return std::nullopt;
|
||||
return fs::path(text);
|
||||
// No trailing separator (records before v0.1.1 kept `--dest dir/`'s).
|
||||
fs::path path = fs::path(text).lexically_normal();
|
||||
while (path.has_relative_path() && path.filename().empty())
|
||||
path = path.parent_path();
|
||||
return path;
|
||||
}
|
||||
|
||||
bool RecordInstallPath(const std::filesystem::path& dir)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::path file = LocationFile();
|
||||
if (fs::absolute(dir, ec).lexically_normal() == fs::absolute(DefaultInstallPath(), ec).lexically_normal())
|
||||
// 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;
|
||||
@@ -209,7 +224,7 @@ namespace install
|
||||
FILE* f = fopen(temp.c_str(), "wb");
|
||||
if (!f)
|
||||
return false;
|
||||
std::string text = fs::absolute(dir, ec).lexically_normal().string() + "\n";
|
||||
std::string text = folder.string() + "\n";
|
||||
bool ok = fwrite(text.data(), 1, text.size(), f) == text.size();
|
||||
ok = fflush(f) == 0 && ok;
|
||||
fsync(fileno(f));
|
||||
|
||||
@@ -0,0 +1,883 @@
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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);
|
||||
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 (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);
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// 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).
|
||||
// - 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::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);
|
||||
}
|
||||
@@ -21,6 +21,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>
|
||||
@@ -279,6 +280,9 @@ int main(int argc, char** argv)
|
||||
// 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).
|
||||
install::SetProgramPath(argc > 0 ? argv[0] : nullptr);
|
||||
// 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))
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+23
@@ -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.
|
||||
+1022
File diff suppressed because it is too large.
Load diff
+385
@@ -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
|
||||
+408
-32
@@ -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,25 @@ 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());
|
||||
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 +165,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 +292,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 +448,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 +503,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 +542,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 +663,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 +789,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 +854,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 +1135,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 +1189,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 +1220,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 +1296,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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -739,6 +739,11 @@ namespace ui
|
||||
}
|
||||
}
|
||||
|
||||
void RequestShot()
|
||||
{
|
||||
s_shots++;
|
||||
}
|
||||
|
||||
bool TakeShot()
|
||||
{
|
||||
if (s_shots == 0)
|
||||
@@ -916,6 +921,7 @@ namespace ui
|
||||
return;
|
||||
s_settingsOpen = false;
|
||||
UpdateCapture();
|
||||
ForgetUpdateDialog();
|
||||
report::NoteMenuClosed();
|
||||
XamNotifySystemUi(false);
|
||||
if (settings::Dirty() && !settings::Save())
|
||||
|
||||
+19
-1
@@ -62,6 +62,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
|
||||
@@ -117,9 +119,25 @@ namespace ui
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -130,7 +130,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",
|
||||
|
||||
@@ -81,9 +81,9 @@ namespace settings
|
||||
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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -2599,6 +2599,11 @@ void main()
|
||||
#endif
|
||||
}
|
||||
|
||||
bool GameMode()
|
||||
{
|
||||
return s_gameMode;
|
||||
}
|
||||
|
||||
void FrameRegion(uint32_t& width, uint32_t& height)
|
||||
{
|
||||
width = height = 0;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+363
@@ -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"
|
||||
Executable
+378
@@ -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()
|
||||
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env python3
|
||||
# SpeedBreaker. GPL-3.0-or-later (see COPYING).
|
||||
#
|
||||
# check_codegen.py <build folder> [--reference-target=26.0] [--against=<build folder>]
|
||||
#
|
||||
# The release's check that building for the oldest macOS the game runs on (the build folder's
|
||||
# CMAKE_OSX_DEPLOYMENT_TARGET, CMakeLists.txt's default 15.0) costs nobody any speed: that its game code is
|
||||
# the code the same sources give for macOS 26.0 (v0.1.0's target, what Macs on macOS 26 ran), function by
|
||||
# function, but for differences read and found cold (ALLOWED below). The deployment target doesn't choose
|
||||
# the CPU code (that's -mcpu, the compiler's default for arm64 Macs, apple-m1), but it does choose which
|
||||
# libc++ features the system's libc++.dylib is assumed to have, and which inline code Apple's headers give:
|
||||
# targeting 15.0, libc++ compiles in its own copy of what macOS 15.0's libc++.dylib lacks (std::
|
||||
# bad_function_call's destructor, which the dylib has from macOS 15.4, for one). Any of that can land in
|
||||
# new code. So this is run on the release candidate's own commit, never on an earlier build's results: an
|
||||
# A/B speed run measured one build, and this proves the candidate's code is that build's code at 26.0.
|
||||
#
|
||||
# The build folder is the release's (configured and built with the README's cmake line). Unless --against
|
||||
# names a build folder of the same sources for the reference macOS, this configures <the build folder's
|
||||
# parent>/codegen-<reference> with the same compilers, libraries and options but the reference target, and
|
||||
# builds the SpeedBreaker target in both (ninja) once steps 1 and 2 and
|
||||
# the controls have passed:
|
||||
# 1. Every compile and link command that builds SpeedBreaker is the same in both but for
|
||||
# -mmacosx-version-min (and the build folders' own paths).
|
||||
# 2. The compiler gets the same CPU, target features and optimisation for each: for each distinct set of
|
||||
# flags, the cc1 command clang -### makes from it for each target. Only the target triple's OS version
|
||||
# differs, and the defaults that follow it and change no code (the Objective-C runtime's version, two
|
||||
# debug-information options, with no -g).
|
||||
# 3. Every object: its code (every section of instructions: __text, __StaticInit, ...) function by
|
||||
# function, the bytes compared exactly but for the fields a relocation fills in and the nop padding to
|
||||
# the next function's alignment, and each relocation's kind and target compared too (a symbol by name;
|
||||
# an assembler-local label, which is numbered through the file, by the constant it labels; a C string
|
||||
# only as a string). A function that differs, or is only in one build, must match ALLOWED, or the check
|
||||
# fails, naming it. Data sections that differ are listed (the build date's string is one).
|
||||
# 4. For the record, the linked game: its code's size, how far the functions moved, and how many changed
|
||||
# their place in a 128-byte cache line (Apple silicon's line), and whether the data's layout is the
|
||||
# same (the writable __DATA, where hot arrays sit, and __DATA_CONST).
|
||||
# First, the controls (in a temporary folder, with the build's own C++ compiler and flags): a file calling an
|
||||
# empty std::function, built for both targets, must show libc++'s own copy of std::bad_function_call as the
|
||||
# (allowed) difference; and a function built with a different constant, once in an instruction and once in a
|
||||
# constant pool (the same instructions), must be caught as a difference that is not allowed. If any fails,
|
||||
# the check itself is broken.
|
||||
# Exit status 0 only when every step passes. Needs Apple's command line tools, cmake and ninja.
|
||||
import collections, hashlib, os, re, shlex, struct, subprocess, sys, tempfile
|
||||
|
||||
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('--'))
|
||||
|
||||
# Code that differs between the targets, each read and found to run only on a cold path:
|
||||
# {regex matching the whole mangled name: why}. Add one only after reading the difference (llvm-objdump -d -r
|
||||
# of the object in both builds); every match is listed in the output.
|
||||
ALLOWED = {
|
||||
r'__ZNSt3__117bad_function_callD[012]Ev':
|
||||
"std::bad_function_call's destructor: libc++ assumes macOS 15.4's libc++.dylib has it "
|
||||
"(_LIBCPP_AVAILABILITY_HAS_BAD_FUNCTION_CALL_KEY_FUNCTION), so a 15.0 build carries its own copy. "
|
||||
"Runs only after an empty std::function was called, which throws (a crash path).",
|
||||
r'__ZNSt3__125__throw_bad_function_call\w*':
|
||||
"throws std::bad_function_call when an empty std::function is called (a crash path); at 15.0 it "
|
||||
"takes the class's type information and destructor from the build's own copy (adrp/add) instead of "
|
||||
"libc++.dylib's (through the GOT): the same instructions otherwise.",
|
||||
}
|
||||
|
||||
# arm64 relocation kinds (mach-o/arm64/reloc.h) and the instruction bits each one fills in.
|
||||
RELOC_NAMES = {0: 'UNSIGNED', 1: 'SUBTRACTOR', 2: 'BRANCH26', 3: 'PAGE21', 4: 'PAGEOFF12', 5: 'GOT_LOAD_PAGE21',
|
||||
6: 'GOT_LOAD_PAGEOFF12', 7: 'POINTER_TO_GOT', 8: 'TLVP_LOAD_PAGE21', 9: 'TLVP_LOAD_PAGEOFF12',
|
||||
10: 'ADDEND', 11: 'AUTHENTICATED_POINTER'}
|
||||
INSN_FIELD = {2: 0x03FFFFFF, 3: 0x60FFFFE0, 5: 0x60FFFFE0, 8: 0x60FFFFE0, 4: 0x003FFC00, 6: 0x003FFC00, 9: 0x003FFC00}
|
||||
CODE_FLAGS = 0x80000000 | 0x00000400 # S_ATTR_PURE_INSTRUCTIONS | S_ATTR_SOME_INSTRUCTIONS
|
||||
ZEROFILL = {0x1, 0xc, 0x12} # S_ZEROFILL, S_GB_ZEROFILL, S_THREAD_LOCAL_ZEROFILL
|
||||
NOP = struct.pack('<I', 0xd503201f) # arm64 nop
|
||||
LINE = 128 # bytes in a cache line on Apple silicon (hw.cachelinesize)
|
||||
|
||||
|
||||
def run(*args, cwd=None, check=True):
|
||||
r = subprocess.run(args, cwd=cwd, capture_output=True, text=True)
|
||||
if check and r.returncode != 0:
|
||||
raise SystemExit(f'check_codegen.py: {" ".join(args[:4])} ... failed ({r.returncode}):\n'
|
||||
f'{(r.stdout + r.stderr)[-3000:]}')
|
||||
return r.stdout
|
||||
|
||||
|
||||
def cache(build):
|
||||
"""{name: value} of a build folder's CMakeCache.txt."""
|
||||
values = {}
|
||||
with open(os.path.join(build, 'CMakeCache.txt')) as f:
|
||||
for line in f:
|
||||
m = re.match(r'^([A-Za-z0-9_.+-]+):[A-Z]+=(.*)$', line.rstrip('\n'))
|
||||
if m:
|
||||
values[m.group(1)] = m.group(2)
|
||||
return values
|
||||
|
||||
|
||||
# --- Mach-O objects -------------------------------------------------------------------------------------
|
||||
class MachO:
|
||||
"""A thin 64-bit Mach-O: its sections, symbols and each section's relocations."""
|
||||
|
||||
def __init__(self, path):
|
||||
with open(path, 'rb') as f:
|
||||
self.data = d = f.read()
|
||||
if len(d) < 32 or struct.unpack_from('<I', d, 0)[0] != 0xfeedfacf:
|
||||
raise ValueError(f'{path}: not a thin 64-bit Mach-O')
|
||||
self.filetype, ncmds = struct.unpack_from('<I', d, 12)[0], struct.unpack_from('<I', d, 16)[0]
|
||||
self.sections, self.symbols, off = [], [], 32
|
||||
for _ in range(ncmds):
|
||||
cmd, size = struct.unpack_from('<II', d, off)
|
||||
if cmd == 0x19: # LC_SEGMENT_64
|
||||
for i in range(struct.unpack_from('<I', d, off + 64)[0]):
|
||||
s = off + 72 + 80 * i
|
||||
sect, seg = d[s:s + 16].rstrip(b'\0').decode(), d[s + 16:s + 32].rstrip(b'\0').decode()
|
||||
addr, size_, offset, _, reloff, nreloc, flags = struct.unpack_from('<QQIIIII', d, s + 32)
|
||||
zerofill = (flags & 0xff) in ZEROFILL
|
||||
self.sections.append({'name': f'{seg},{sect}', 'addr': addr, 'size': size_, 'flags': flags,
|
||||
'code': bool(flags & CODE_FLAGS),
|
||||
'bytes': b'' if zerofill else d[offset:offset + size_],
|
||||
'relocs': self._relocs(reloff, nreloc)})
|
||||
elif cmd == 0x2: # LC_SYMTAB
|
||||
symoff, nsyms, stroff, _ = struct.unpack_from('<IIII', d, off + 8)
|
||||
for i in range(nsyms):
|
||||
strx, ntype, nsect, _, value = struct.unpack_from('<IBBHQ', d, symoff + 16 * i)
|
||||
end = d.index(b'\0', stroff + strx)
|
||||
self.symbols.append((d[stroff + strx:end].decode(errors='replace'), ntype, nsect, value))
|
||||
off += size
|
||||
|
||||
def _relocs(self, reloff, nreloc):
|
||||
out = []
|
||||
for i in range(nreloc):
|
||||
address, info = struct.unpack_from('<iI', self.data, reloff + 8 * i)
|
||||
out.append((address, info & 0xffffff, (info >> 24) & 1, (info >> 25) & 3, (info >> 27) & 1, info >> 28))
|
||||
return out
|
||||
|
||||
def symbol_name(self, index):
|
||||
return self.symbols[index][0] if 0 <= index < len(self.symbols) else f'<symbol {index}>'
|
||||
|
||||
|
||||
def functions(obj, sec_index):
|
||||
"""[(name(s), start, end)] of a code section (sec_index counts from 1, as n_sect does), split at its
|
||||
symbols; assembler temporaries (l..., L...) don't start one."""
|
||||
sec = obj.sections[sec_index - 1]
|
||||
starts = collections.defaultdict(list)
|
||||
for name, ntype, nsect, value in obj.symbols:
|
||||
if ntype & 0xe0 or (ntype & 0x0e) != 0x0e or nsect != sec_index or name.startswith(('l', 'L')):
|
||||
continue
|
||||
starts[value - sec['addr']].append(name)
|
||||
offsets = sorted(starts)
|
||||
if not offsets or offsets[0] != 0:
|
||||
offsets.insert(0, 0)
|
||||
starts[0] = starts.get(0) or [f'<start of {sec["name"]}>']
|
||||
out = []
|
||||
for i, start in enumerate(offsets):
|
||||
end = offsets[i + 1] if i + 1 < len(offsets) else sec['size']
|
||||
if end > start or sec['size'] == 0:
|
||||
out.append((' = '.join(sorted(starts[start])), start, end))
|
||||
return out
|
||||
|
||||
|
||||
def resolved_relocs(obj, sec):
|
||||
"""A section's relocations with their targets named: [(offset, kind, pc-relative, length, target, addend)].
|
||||
The target is the symbol's name for an external relocation, else the section's (whose address sits in
|
||||
the relocated field); an ARM64_RELOC_ADDEND entry becomes the next relocation's addend."""
|
||||
out, addend = [], 0
|
||||
for address, symnum, pcrel, length, extern, rtype in sec['relocs']:
|
||||
if rtype == 10: # ARM64_RELOC_ADDEND: 24 bits, signed
|
||||
addend = symnum - (1 << 24) if symnum & 0x800000 else symnum
|
||||
continue
|
||||
if address & 0x80000000: # a scattered relocation (none on arm64): kept whole
|
||||
target = f'scattered {address:#x}'
|
||||
elif extern:
|
||||
target = obj.symbol_name(symnum)
|
||||
if target.startswith(('l', 'L')): # an assembler-local label: compared by what it labels (Local)
|
||||
target = local_content(obj, symnum)
|
||||
else:
|
||||
target = f'section {obj.sections[symnum - 1]["name"]}' if 0 < symnum <= len(obj.sections) else f'section {symnum}'
|
||||
out.append((address, RELOC_NAMES.get(rtype, rtype), pcrel, length, target, addend))
|
||||
addend = 0
|
||||
return out
|
||||
|
||||
|
||||
class Local:
|
||||
"""What an assembler-local label (lCPI3_0, l_.str.12, l_switch.table..., LJTI5_0) labels, as a relocation's
|
||||
target. The labels are numbered through the file (a constant pool's by its function's place in it), so one
|
||||
function more anywhere renumbers the rest without changing them: they are compared by their bytes instead,
|
||||
to the next label, but for the zero padding before it (which follows the next one's alignment). A C string
|
||||
is compared by its section only: a different string changes no code (the build's date is one), and the
|
||||
strings that differ are listed with the data."""
|
||||
|
||||
def __init__(self, section, data, cstring):
|
||||
self.section, self.data, self.cstring = section, data, cstring
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, Local) or self.section != other.section or self.cstring != other.cstring:
|
||||
return False
|
||||
if self.cstring:
|
||||
return True
|
||||
short, long_ = sorted((self.data, other.data), key=len)
|
||||
return long_.startswith(short) and not long_[len(short):].strip(b'\0')
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.section)
|
||||
|
||||
def __repr__(self):
|
||||
return f'local label in {self.section} ({len(self.data)} bytes, {hashlib.sha256(self.data).hexdigest()[:12]})'
|
||||
|
||||
|
||||
def local_content(obj, symnum):
|
||||
"""The Local an assembler-local label's relocation targets."""
|
||||
name, _, nsect, value = obj.symbols[symnum]
|
||||
if not 0 < nsect <= len(obj.sections):
|
||||
return name
|
||||
if not hasattr(obj, 'starts'):
|
||||
obj.starts = collections.defaultdict(list)
|
||||
for _, ntype, n, v in obj.symbols:
|
||||
if not ntype & 0xe0 and (ntype & 0x0e) == 0x0e:
|
||||
obj.starts[n].append(v)
|
||||
for v in obj.starts.values():
|
||||
v.sort()
|
||||
sec = obj.sections[nsect - 1]
|
||||
later = [v for v in obj.starts[nsect] if v > value]
|
||||
end = (later[0] if later else sec['addr'] + sec['size']) - sec['addr']
|
||||
data = sec['bytes'][value - sec['addr']:end]
|
||||
cstring = (sec['flags'] & 0xff) == 0x2 # S_CSTRING_LITERALS
|
||||
return Local(sec['name'], data.split(b'\0', 1)[0] if cstring else data, cstring)
|
||||
|
||||
|
||||
def section_symbols(obj, sec_index):
|
||||
sec = obj.sections[sec_index - 1]
|
||||
return sorted((value - sec['addr'], name) for name, ntype, nsect, value in obj.symbols
|
||||
if not ntype & 0xe0 and (ntype & 0x0e) == 0x0e and nsect == sec_index)
|
||||
|
||||
|
||||
def masked_code(obj, sec_index):
|
||||
"""{function name: (its bytes with every relocated field zeroed, its relocations, offsets from the
|
||||
function's start)} of a code section."""
|
||||
sec = obj.sections[sec_index - 1]
|
||||
code = bytearray(sec['bytes'])
|
||||
relocs = resolved_relocs(obj, sec)
|
||||
for address, kind, _, length, target, _ in relocs:
|
||||
rtype = next((k for k, v in RELOC_NAMES.items() if v == kind), None)
|
||||
if isinstance(target, str) and target.startswith('scattered'):
|
||||
continue
|
||||
if rtype in INSN_FIELD and address + 4 <= len(code):
|
||||
word = struct.unpack_from('<I', code, address)[0] & ~INSN_FIELD[rtype]
|
||||
struct.pack_into('<I', code, address, word)
|
||||
elif address + (1 << length) <= len(code):
|
||||
code[address:address + (1 << length)] = bytes(1 << length)
|
||||
out, seen = {}, collections.Counter()
|
||||
for name, start, end in functions(obj, sec_index):
|
||||
seen[name] += 1
|
||||
key = name if seen[name] == 1 else f'{name} #{seen[name]}'
|
||||
own = tuple((r[0] - start,) + r[1:] for r in relocs if start <= r[0] < end)
|
||||
body = bytes(code[start:end])
|
||||
while len(body) >= 4 and body[-4:] == NOP and not any(r[0] >= len(body) - 4 for r in own):
|
||||
body = body[:-4] # the padding to the next function's alignment, which moves with it
|
||||
out[key] = (body, own)
|
||||
return out
|
||||
|
||||
|
||||
def compare_objects(path_a, path_b):
|
||||
"""Two objects' code. -> (status, {function: how it differs}, [data sections that differ]); status is
|
||||
'identical' (every code section the same bytes, relocations and symbols), 'same' (the same functions
|
||||
once the relocated fields are masked) or 'differs'."""
|
||||
a, b = MachO(path_a), MachO(path_b)
|
||||
code_a = {s['name']: i + 1 for i, s in enumerate(a.sections) if s['code']}
|
||||
code_b = {s['name']: i + 1 for i, s in enumerate(b.sections) if s['code']}
|
||||
differ, identical = {}, set(code_a) == set(code_b)
|
||||
for name in sorted(set(code_a) | set(code_b)):
|
||||
sa = a.sections[code_a[name] - 1] if name in code_a else None
|
||||
sb = b.sections[code_b[name] - 1] if name in code_b else None
|
||||
if sa and sb and sa['bytes'] == sb['bytes'] and resolved_relocs(a, sa) == resolved_relocs(b, sb) \
|
||||
and section_symbols(a, code_a[name]) == section_symbols(b, code_b[name]):
|
||||
continue # the same code, byte for byte, with the same relocations and symbols
|
||||
identical = False
|
||||
fa = masked_code(a, code_a[name]) if sa else {}
|
||||
fb = masked_code(b, code_b[name]) if sb else {}
|
||||
for f in sorted(set(fa) | set(fb)):
|
||||
if f not in fb:
|
||||
differ[f] = f'only in the first build ({len(fa[f][0])} bytes, {name})'
|
||||
elif f not in fa:
|
||||
differ[f] = f'only in the second build ({len(fb[f][0])} bytes, {name})'
|
||||
elif fa[f] != fb[f]:
|
||||
what = 'instructions' if fa[f][0] != fb[f][0] else 'relocations'
|
||||
differ[f] = f'{what} differ ({len(fa[f][0])} vs {len(fb[f][0])} bytes, {name})'
|
||||
data_a = {s['name']: s for s in a.sections if not s['code']}
|
||||
data_b = {s['name']: s for s in b.sections if not s['code']}
|
||||
data = [n for n in sorted(set(data_a) | set(data_b))
|
||||
if n not in data_a or n not in data_b or data_a[n]['bytes'] != data_b[n]['bytes']
|
||||
or data_a[n]['size'] != data_b[n]['size']
|
||||
or resolved_relocs(a, data_a[n]) != resolved_relocs(b, data_b[n])]
|
||||
return ('identical' if identical else 'same' if not differ else 'differs'), differ, data
|
||||
|
||||
|
||||
def allowed(function):
|
||||
"""The ALLOWED pattern a function (or one of its names) matches, or None."""
|
||||
for name in function.split(' #')[0].split(' = '):
|
||||
for pattern in ALLOWED:
|
||||
if re.fullmatch(pattern, name):
|
||||
return pattern
|
||||
return None
|
||||
|
||||
|
||||
# --- the build's commands -------------------------------------------------------------------------------
|
||||
def commands(build):
|
||||
"""({object path relative to the build: its compile command}, the link command) of the SpeedBreaker
|
||||
target, from ninja."""
|
||||
compiles, link = {}, None
|
||||
for line in run('ninja', '-C', build, '-t', 'commands', 'SpeedBreaker').splitlines():
|
||||
words = shlex.split(line)
|
||||
if '-c' in words and '-o' in words:
|
||||
compiles[words[words.index('-o') + 1]] = words
|
||||
elif '-o' in words and words[words.index('-o') + 1].endswith('SpeedBreaker'):
|
||||
link = words
|
||||
return compiles, link
|
||||
|
||||
|
||||
def normalized(words, build, other_build):
|
||||
"""A command with the build folders' paths and the deployment target replaced by placeholders."""
|
||||
paths = sorted({build, os.path.realpath(build), other_build, os.path.realpath(other_build)}, key=len, reverse=True)
|
||||
out = []
|
||||
for w in words:
|
||||
for path in paths:
|
||||
w = w.replace(path, '<build>')
|
||||
out.append(re.sub(r'^(-mmacosx-version-min=|-mmacos-version-min=)[0-9.]+$', r'\1<target>', w))
|
||||
return out
|
||||
|
||||
|
||||
def cc1(compiler, language, flags, target):
|
||||
"""The cc1 arguments clang makes from `flags` for `target`."""
|
||||
flags = [re.sub(r'^(-mmacosx-version-min=|-mmacos-version-min=)[0-9.]+$', rf'\g<1>{target}', f) for f in flags]
|
||||
out = subprocess.run([compiler, '-###', '-x', language, '-c', '/dev/null', '-o', '/dev/null'] + flags,
|
||||
capture_output=True, text=True).stderr
|
||||
for line in out.splitlines():
|
||||
if '"-cc1"' in line:
|
||||
return shlex.split(line)
|
||||
raise SystemExit(f'check_codegen.py: no cc1 command from {compiler} -### for {language}:\n{out[-2000:]}')
|
||||
|
||||
|
||||
def flag_set(words):
|
||||
"""(compiler, language, the flags that reach code generation) of a compile command: without the
|
||||
source, the output, the dependency file, include paths, macros and forced includes (which pick the
|
||||
source, not how it is compiled; step 1 compares them)."""
|
||||
source = words[words.index('-c') + 1]
|
||||
lang = {'.mm': 'objective-c++', '.m': 'objective-c', '.c': 'c'}.get(os.path.splitext(source)[1], 'c++')
|
||||
flags, i = [], 1
|
||||
while i < len(words):
|
||||
w = words[i]
|
||||
if w in ('-o', '-c', '-MF', '-MT', '-MQ', '-include', '-isystem', '-iquote', '-idirafter', '-I', '-D', '-U'):
|
||||
i += 2
|
||||
continue
|
||||
if w == '-x':
|
||||
lang = words[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
if w not in ('-MD', '-MMD') and not w.startswith(('-I', '-D', '-U')):
|
||||
flags.append(w)
|
||||
i += 1
|
||||
return words[0], lang, tuple(flags)
|
||||
|
||||
|
||||
# Where cc1's arguments may differ between the two targets, and why that changes no code.
|
||||
CC1_EXPECTED = [
|
||||
(r'-triple', 'the target triple names the OS version (the CPU part, arm64-apple-macosx, is the same)'),
|
||||
(r'-fobjc-runtime=macosx-[0-9.]+', 'the Objective-C runtime version follows the target (Objective-C++ only)'),
|
||||
(r'-gsimple-template-names=simple', 'debug information only (the build has no -g)'),
|
||||
(r'-debug-forward-template-params', 'debug information only (the build has no -g)'),
|
||||
(r'-target-sdk-version=[0-9.]+', 'the SDK, the same in both'),
|
||||
]
|
||||
|
||||
|
||||
def cc1_differences(args_a, args_b):
|
||||
"""(differences that may change code, the expected ones) between two cc1 commands."""
|
||||
def keyed(args):
|
||||
out, i = [], 0
|
||||
while i < len(args):
|
||||
if args[i] in ('-triple', '-target-cpu', '-target-feature', '-target-abi', '-mllvm', '-tune-cpu') \
|
||||
and i + 1 < len(args):
|
||||
out.append(f'{args[i]} {args[i + 1]}')
|
||||
i += 2
|
||||
else:
|
||||
out.append(args[i])
|
||||
i += 1
|
||||
return out
|
||||
ka, kb = keyed(args_a[1:]), keyed(args_b[1:])
|
||||
only_a = [x for x in ka if x not in kb and not x.startswith(('/dev/null', '-main-file-name'))]
|
||||
only_b = [x for x in kb if x not in ka and not x.startswith(('/dev/null', '-main-file-name'))]
|
||||
bad, expected = [], []
|
||||
for side, items in (('first', only_a), ('second', only_b)):
|
||||
for x in items:
|
||||
why = next((w for p, w in CC1_EXPECTED if re.match(p, x)), None)
|
||||
if x.startswith('-triple '):
|
||||
why = why if re.sub(r'[0-9.]+$', '', x.split()[1]) == 'arm64-apple-macosx' else None
|
||||
(expected if why else bad).append(f'{side} build only: {x}' + (f' ({why})' if why else ''))
|
||||
return bad, expected
|
||||
|
||||
|
||||
# --- the controls -----------------------------------------------------------------------------------------
|
||||
CONTROL_FUNCTION = '#include <functional>\nstd::function<void()> sb_control_target;\n' \
|
||||
'void sb_control_call() { sb_control_target(); }\n'
|
||||
CONTROL_CONSTANT = 'extern "C" int sb_control_constant(int x) { return x + SB_CONTROL_VALUE; }\n'
|
||||
CONTROL_POOL = 'typedef float sb_v4 __attribute__((ext_vector_type(4)));\n' \
|
||||
'extern "C" sb_v4 sb_control_pool(sb_v4 x) { return x * (sb_v4){1.5f, 2.5f, 3.5f, SB_CONTROL_VALUE}; }\n'
|
||||
|
||||
|
||||
def controls(compiler, flags, target, reference):
|
||||
"""The two controls, with the build's C++ compiler and flags. -> (ok, lines)."""
|
||||
lines, ok = [], True
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
def build(side, name, src, version, extra=()):
|
||||
# the same file name on both sides: a static initializer is named after its file
|
||||
os.makedirs(os.path.join(tmp, side), exist_ok=True)
|
||||
path = os.path.join(tmp, side, name)
|
||||
with open(path + '.cpp', 'w') as f:
|
||||
f.write(src)
|
||||
fl = [re.sub(r'^-mmacosx-version-min=[0-9.]+$', f'-mmacosx-version-min={version}', x) for x in flags]
|
||||
run(compiler, *fl, *extra, '-c', path + '.cpp', '-o', path + '.o')
|
||||
return path + '.o'
|
||||
_, differ, _ = compare_objects(build('a', 'function', CONTROL_FUNCTION, target),
|
||||
build('b', 'function', CONTROL_FUNCTION, reference))
|
||||
good = bool(differ) and all(allowed(f) for f in differ)
|
||||
lines.append(f' control 1, an empty std::function called, {target} vs {reference}: '
|
||||
f'{len(differ)} functions differ, {"all allowed: ok" if good else "FAIL"} '
|
||||
f'({", ".join(sorted(differ)) or "none: the comparison sees no target-dependent code"})')
|
||||
ok &= good
|
||||
_, differ, _ = compare_objects(build('a', 'constant', CONTROL_CONSTANT, target, ['-DSB_CONTROL_VALUE=1']),
|
||||
build('b', 'constant', CONTROL_CONSTANT, target, ['-DSB_CONTROL_VALUE=2']))
|
||||
good = list(differ) == ['_sb_control_constant'] and not allowed('_sb_control_constant')
|
||||
lines.append(f' control 2, a function built with x + 1 and with x + 2: '
|
||||
f'{"caught, not allowed: ok" if good else "FAIL (found " + repr(differ) + ")"}')
|
||||
ok &= good
|
||||
_, differ, _ = compare_objects(build('a', 'pool', CONTROL_POOL, target, ['-DSB_CONTROL_VALUE=4.5f']),
|
||||
build('b', 'pool', CONTROL_POOL, target, ['-DSB_CONTROL_VALUE=5.5f']))
|
||||
good = list(differ) == ['_sb_control_pool'] and not allowed('_sb_control_pool')
|
||||
lines.append(f' control 3, a function whose constant (in a constant pool, the same instructions) is 4.5 and '
|
||||
f'5.5: {"caught, not allowed: ok" if good else "FAIL (found " + repr(differ) + ")"}')
|
||||
ok &= good
|
||||
return ok, lines
|
||||
|
||||
|
||||
# --- the linked game ------------------------------------------------------------------------------------
|
||||
def linked_report(exe_a, exe_b):
|
||||
lines = []
|
||||
a, b = MachO(exe_a), MachO(exe_b)
|
||||
ta = next(s for s in a.sections if s['name'] == '__TEXT,__text')
|
||||
tb = next(s for s in b.sections if s['name'] == '__TEXT,__text')
|
||||
lines.append(f' __TEXT,__text: {ta["size"]:,} bytes at {ta["addr"]:#x} vs {tb["size"]:,} at {tb["addr"]:#x} '
|
||||
f'({tb["size"] - ta["size"]:+,} bytes)')
|
||||
|
||||
def addresses(m):
|
||||
out, seen = {}, collections.Counter()
|
||||
text = next(i + 1 for i, s in enumerate(m.sections) if s['name'] == '__TEXT,__text')
|
||||
for name, ntype, nsect, value in m.symbols:
|
||||
if not ntype & 0xe0 and (ntype & 0x0e) == 0x0e and nsect == text:
|
||||
seen[name] += 1
|
||||
out[name if seen[name] == 1 else f'{name} #{seen[name]}'] = value
|
||||
return out
|
||||
fa, fb = addresses(a), addresses(b)
|
||||
both = [n for n in fa if n in fb]
|
||||
shifts = collections.Counter(fb[n] - fa[n] for n in both)
|
||||
moved_line = sum(1 for n in both if (fa[n] - ta['addr']) % LINE != (fb[n] - tb['addr']) % LINE)
|
||||
lines.append(f' functions: {len(fa):,} vs {len(fb):,} ({len(both):,} in both); moved by '
|
||||
+ ', '.join(f'{s:+} bytes: {c:,}' for s, c in shifts.most_common(6))
|
||||
+ (f', {len(shifts) - 6} other distances' if len(shifts) > 6 else ''))
|
||||
lines.append(f' place in a {LINE}-byte cache line changed: {moved_line:,} of {len(both):,} functions')
|
||||
for seg, what in (('__DATA,', 'writable data (__DATA)'), ('__DATA_CONST,', 'data read-only once loaded (__DATA_CONST)')):
|
||||
lines += layout(a, b, seg, what)
|
||||
return lines
|
||||
|
||||
|
||||
def layout(a, b, seg, what):
|
||||
lines = []
|
||||
wa = [(s['name'], s['addr'], s['size']) for s in a.sections if s['name'].startswith(seg)]
|
||||
wb = [(s['name'], s['addr'], s['size']) for s in b.sections if s['name'].startswith(seg)]
|
||||
if wa == wb:
|
||||
lines.append(f' {what}, {len(wa)} sections: the same addresses and sizes')
|
||||
else:
|
||||
lines.append(f' {what}: the layout differs (section, address, size):')
|
||||
for n in sorted({x[0] for x in wa} | {y[0] for y in wb}):
|
||||
x, y = next((v for v in wa if v[0] == n), None), next((v for v in wb if v[0] == n), None)
|
||||
if x != y:
|
||||
show = lambda v: f'{v[1]:#x} {v[2]:,}' if v else '-'
|
||||
lines.append(f' {n}: {show(x)} vs {show(y)}')
|
||||
return lines
|
||||
|
||||
|
||||
# --- main -----------------------------------------------------------------------------------------------
|
||||
def configure_reference(build, values, reference, out):
|
||||
"""Configures `out` like `build` but for macOS `reference`."""
|
||||
args = ['cmake', '-S', values['CMAKE_HOME_DIRECTORY'], '-B', out, '-G', values.get('CMAKE_GENERATOR', 'Ninja'),
|
||||
f'-DCMAKE_OSX_DEPLOYMENT_TARGET={reference}', '-DCMAKE_EXPORT_COMPILE_COMMANDS=ON']
|
||||
for k in ('CMAKE_BUILD_TYPE', 'CMAKE_C_COMPILER', 'CMAKE_CXX_COMPILER', 'CMAKE_OBJCXX_COMPILER', 'CMAKE_PREFIX_PATH',
|
||||
'FFMPEG_XMA', 'CMAKE_C_FLAGS', 'CMAKE_CXX_FLAGS', 'CMAKE_OBJCXX_FLAGS', 'CMAKE_EXE_LINKER_FLAGS'):
|
||||
if values.get(k):
|
||||
args.append(f'-D{k}={values[k]}')
|
||||
for k, v in values.items():
|
||||
if k.startswith('SB_') and k not in ('SB_TESTS', 'SB_OSX_TARGET_FROM') and not k.endswith('-ADVANCED'):
|
||||
args.append(f'-D{k}={v}')
|
||||
run(*args)
|
||||
|
||||
|
||||
def main():
|
||||
if len(ARGS) != 1:
|
||||
raise SystemExit('usage: check_codegen.py <build folder> [--reference-target=26.0] [--against=<build folder>]')
|
||||
build = os.path.abspath(ARGS[0])
|
||||
values = cache(build)
|
||||
target = values.get('CMAKE_OSX_DEPLOYMENT_TARGET', '')
|
||||
reference = OPTS.get('reference-target') or '26.0'
|
||||
if not target or target == reference:
|
||||
raise SystemExit(f'check_codegen.py: {build} is built for macOS {target or "(no target)"}: nothing to '
|
||||
f'compare with {reference}')
|
||||
other = os.path.abspath(OPTS.get('against') or os.path.join(os.path.dirname(build), f'codegen-{reference}'))
|
||||
if not OPTS.get('against') and not os.path.exists(os.path.join(other, 'CMakeCache.txt')):
|
||||
print(f'configuring {other} for macOS {reference} (like {build})')
|
||||
configure_reference(build, values, reference, other)
|
||||
got = cache(other).get('CMAKE_OSX_DEPLOYMENT_TARGET')
|
||||
if got != reference:
|
||||
raise SystemExit(f'check_codegen.py: {other} is built for macOS {got}, not {reference}')
|
||||
ok = True
|
||||
print(f'{build}: macOS {target}\n{other}: macOS {reference}')
|
||||
|
||||
# 1. the commands
|
||||
ca, la = commands(build)
|
||||
cb, lb = commands(other)
|
||||
print(f'1. compile and link commands of SpeedBreaker ({len(ca)} compiles):')
|
||||
bad = sorted(set(ca) ^ set(cb))
|
||||
for o in bad:
|
||||
print(f' FAIL: {o} is built only in {"the first" if o in ca else "the second"} build')
|
||||
diff = [o for o in sorted(set(ca) & set(cb)) if normalized(ca[o], build, other) != normalized(cb[o], other, build)]
|
||||
for o in diff[:20]:
|
||||
x, y = normalized(ca[o], build, other), normalized(cb[o], other, build)
|
||||
print(f' FAIL: {o}: {" ".join(w for w in x if w not in y)} | {" ".join(w for w in y if w not in x)}')
|
||||
if len(diff) > 20:
|
||||
print(f' ... {len(diff) - 20} more')
|
||||
link_same = la is not None and lb is not None and normalized(la, build, other) == normalized(lb, other, build)
|
||||
if not link_same:
|
||||
print(' FAIL: the link commands differ (beyond the target): '
|
||||
f'{[w for w in normalized(la or [], build, other) if w not in normalized(lb or [], other, build)]} | '
|
||||
f'{[w for w in normalized(lb or [], other, build) if w not in normalized(la or [], build, other)]}')
|
||||
good = not bad and not diff and link_same
|
||||
print(f' {"ok: the same commands but for -mmacosx-version-min" if good else "FAIL"}')
|
||||
ok &= good
|
||||
|
||||
# 2. what the compiler makes of them
|
||||
groups = collections.OrderedDict()
|
||||
for o in sorted(ca):
|
||||
groups.setdefault(flag_set(ca[o]), []).append(o)
|
||||
print(f'2. code generation options: {len(groups)} distinct sets of flags')
|
||||
good, notes = True, set()
|
||||
for (compiler, lang, flags), objs in groups.items():
|
||||
a1, b1 = cc1(compiler, lang, list(flags), target), cc1(compiler, lang, list(flags), reference)
|
||||
diffs, expected = cc1_differences(a1, b1)
|
||||
cpu = a1[a1.index('-target-cpu') + 1] if '-target-cpu' in a1 else '?'
|
||||
opt = next((x for x in a1 if re.fullmatch(r'-O[0-9sz]', x)), '-O0')
|
||||
nfeat = sum(1 for x in a1 if x == '-target-feature')
|
||||
print(f' {len(objs):>4} {lang:<14} {os.path.basename(compiler)}: {opt}, -target-cpu {cpu}, {nfeat} target '
|
||||
f'features: {"the same for both targets" if not diffs else "FAIL"}')
|
||||
for d in diffs:
|
||||
print(f' {d}')
|
||||
notes.update(expected)
|
||||
good &= not diffs
|
||||
for n in sorted(notes):
|
||||
print(f' expected: {n}')
|
||||
ok &= good
|
||||
|
||||
# the controls, with the runtime's C++ flags (the set with the most C++ objects)
|
||||
cxx = max(((k, v) for k, v in groups.items() if k[1] == 'c++'), key=lambda kv: len(kv[1]))[0]
|
||||
print('controls:')
|
||||
good, lines = controls(cxx[0], [f for f in cxx[2] if f not in ('-x', 'c++')], target, reference)
|
||||
print('\n'.join(lines))
|
||||
if not good:
|
||||
print(' FAIL: the comparison itself is broken: fix check_codegen.py')
|
||||
ok &= good
|
||||
|
||||
if not ok:
|
||||
print('FAILED: the two builds are not the same build but for the target (or the check is broken), so '
|
||||
'their code is not compared')
|
||||
sys.exit(1)
|
||||
for b in (build, other):
|
||||
print(f'ninja -C {b} SpeedBreaker')
|
||||
run('ninja', '-C', b, 'SpeedBreaker')
|
||||
|
||||
# 3. the objects
|
||||
print('3. the code of every object, function by function:')
|
||||
status = collections.Counter()
|
||||
used, unexplained, data = collections.defaultdict(list), [], []
|
||||
for o in sorted(set(ca) & set(cb)):
|
||||
st, differ, dsec = compare_objects(os.path.join(build, o), os.path.join(other, o))
|
||||
status[st] += 1
|
||||
for f, how in sorted(differ.items()):
|
||||
p = allowed(f)
|
||||
(used[p].append(f'{o}: {f}: {how}') if p else unexplained.append(f'{o}: {f}: {how}'))
|
||||
data += [f'{o}: {s}' for s in dsec]
|
||||
print(f' {len(set(ca) & set(cb))} objects: {status["identical"]} the same code byte for byte; {status["same"]} '
|
||||
f'the same functions once relocated fields are masked; {status["differs"]} with functions that differ')
|
||||
for p, items in used.items():
|
||||
print(f' allowed ({p}): {ALLOWED[p]}')
|
||||
for i in items:
|
||||
print(f' {i}')
|
||||
for u in unexplained:
|
||||
print(f' FAIL: {u}')
|
||||
if unexplained:
|
||||
print(f' {len(unexplained)} functions differ that ALLOWED doesn\'t name: read each (llvm-objdump -d -r of '
|
||||
f'the object in both builds); if it runs only on a cold path, add it to ALLOWED with why; if not, '
|
||||
f'the code for macOS {target} isn\'t the code that was measured')
|
||||
print(f' data sections that differ (not code; for the record): {len(data)}')
|
||||
for d in data[:40]:
|
||||
print(f' {d}')
|
||||
ok &= not unexplained
|
||||
|
||||
# 4. the linked game
|
||||
print('4. the linked game (for the record):')
|
||||
print('\n'.join(linked_report(os.path.join(build, 'runtime/SpeedBreaker'), os.path.join(other, 'runtime/SpeedBreaker'))))
|
||||
print('PASS' if ok else 'FAILED')
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+223
-32
@@ -1,19 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
# SpeedBreaker. GPL-3.0-or-later (see COPYING).
|
||||
#
|
||||
# make_app.py [build dir] [output dir] [--dmg]: SpeedBreaker.app for macOS
|
||||
# (Apple silicon) from a build (default build/main), in build/app:
|
||||
# make_app.py [build dir] [output dir] [--dmg] [--min-macos=<version>]:
|
||||
# SpeedBreaker.app for macOS (Apple silicon) from a build (default
|
||||
# build/main), in build/app:
|
||||
# Contents/MacOS/SpeedBreaker the game
|
||||
# Contents/Frameworks/ the libraries it links (Homebrew's SDL3,
|
||||
# Vulkan loader, glslang, SPIRV-Tools, and
|
||||
# what they link), named @rpath/<file>, and
|
||||
# MoltenVK, the Vulkan driver
|
||||
# Contents/Frameworks/ the libraries it links (SDL3, the Vulkan
|
||||
# loader, glslang, SPIRV-Tools, and what
|
||||
# they link), named @rpath/<file>, and
|
||||
# MoltenVK, the Vulkan driver (Homebrew's
|
||||
# 1.4.2: it runs on macOS 12+; another
|
||||
# version only with MOLTENVK_VERSION=<it>)
|
||||
# Contents/Resources/vulkan/icd.d/ MoltenVK's manifest (the Vulkan loader
|
||||
# looks for drivers in the app's bundle)
|
||||
# Contents/Resources/AppIcon.icns from scripts/macos/AppIcon.png
|
||||
# Contents/Resources/licenses/ COPYING, NOTICE, THIRD_PARTY_NOTICES.md,
|
||||
# LICENSES/, and each bundled library's
|
||||
# own license files (from its Homebrew keg)
|
||||
# own license files (from build_deps.sh's
|
||||
# prefix, with what each was built from, or
|
||||
# from the library's Homebrew keg)
|
||||
# Contents/Info.plist a game (macOS Game Mode when fullscreen),
|
||||
# versioned from the build: CFBundleShort-
|
||||
# VersionString is project()'s version,
|
||||
@@ -21,6 +26,21 @@
|
||||
# the history, LSMinimumSystemVersion the
|
||||
# build's deployment target, which every
|
||||
# binary in the app must match (checked)
|
||||
#
|
||||
# The macOS the app runs on: the build's deployment target (CMakeLists.txt's
|
||||
# default, 15.0, unless the build was configured with another). Every library
|
||||
# in the app, and the FFmpeg the game links statically, must be built for it
|
||||
# or an older macOS; a newer one is refused, with the two ways out:
|
||||
# - the libraries built for it by scripts/macos/build_deps.sh <prefix>, the
|
||||
# build configured with -DCMAKE_PREFIX_PATH=<prefix>
|
||||
# -DFFMPEG_XMA=<prefix>/ffmpeg-xma (how the release is built);
|
||||
# - or Homebrew's libraries, which are built for the macOS they were made
|
||||
# on (26.0 on macOS 26), with the build configured for that macOS:
|
||||
# -DCMAKE_OSX_DEPLOYMENT_TARGET=26.0. Fine for an app of your own; it
|
||||
# won't open on an older macOS, and this script says so.
|
||||
# --min-macos=<version> (release scripts): refuse an app for any other macOS.
|
||||
# scripts/macos/check_app.py checks the app or dmg it makes (the release's
|
||||
# gate: the floor, every binary's, what it loads and imports).
|
||||
# signed ad hoc (Apple silicon runs nothing unsigned, and rewriting a
|
||||
# library's names breaks its signature). Nothing of Homebrew is needed to
|
||||
# run it. No game data: the game installs from the player's disc image on
|
||||
@@ -36,15 +56,27 @@
|
||||
# --dmg: also <output>/SpeedBreaker-macos.dmg (the app and a shortcut to
|
||||
# Applications, to drag it onto) with its .sha256, the release download, and
|
||||
# a copy named by the version (SpeedBreaker-<version>-macos.dmg, a hard link).
|
||||
import os, plistlib, re, shutil, subprocess, sys, tempfile
|
||||
import json, os, plistlib, re, shutil, 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('--')]
|
||||
DMG = '--dmg' in sys.argv[1:]
|
||||
OPTIONS = [a for a in sys.argv[1:] if a.startswith('--')]
|
||||
DMG = '--dmg' in OPTIONS
|
||||
MIN_MACOS = next((a.split('=', 1)[1] for a in OPTIONS if a.startswith('--min-macos=')), None)
|
||||
for a in OPTIONS:
|
||||
if a != '--dmg' and not a.startswith('--min-macos='):
|
||||
raise SystemExit(f'make_app.py: unknown option {a} (usage: make_app.py [build dir] [output dir] [--dmg] [--min-macos=<version>])')
|
||||
BUILD = os.path.abspath(ARGS[0] if len(ARGS) > 0 else os.path.join(ROOT, 'build/main'))
|
||||
OUT = os.path.abspath(ARGS[1] if len(ARGS) > 1 else os.path.join(ROOT, 'build/app'))
|
||||
BIN = os.path.join(BUILD, 'runtime/SpeedBreaker')
|
||||
MOLTENVK = os.environ.get('MOLTENVK', '/opt/homebrew/opt/molten-vk/lib/libMoltenVK.dylib')
|
||||
# The MoltenVK the releases ship (v0.1.0 and on): the Vulkan driver, so another
|
||||
# version can change how the game runs and how fast. A `brew upgrade` must not
|
||||
# change it silently: another is taken only when MOLTENVK_VERSION names it.
|
||||
MOLTENVK_PINNED = '1.4.2'
|
||||
# Projects compiled into a bundled library that come with their own license:
|
||||
# their files go next to the library's (LICENSES/ has them).
|
||||
EMBEDDED = {'molten-vk': [('cereal, BSD-3-Clause', 'LICENSES/cereal-BSD-3-Clause.txt', 'cereal-LICENSE.txt')]}
|
||||
|
||||
|
||||
def run(*args):
|
||||
@@ -113,13 +145,59 @@ def build_number(commit):
|
||||
return out if out.isdigit() else '0'
|
||||
|
||||
|
||||
def version_tuple(v):
|
||||
"""'15.0' -> (15, 0, 0), comparable."""
|
||||
v = tuple(int(x) for x in str(v).split('.'))
|
||||
return v + (0,) * (3 - len(v))
|
||||
|
||||
|
||||
def minos_all(path):
|
||||
"""The oldest macOS each Mach-O in a file runs on (LC_BUILD_VERSION or the
|
||||
older LC_VERSION_MIN_MACOSX): one for a binary or library, one per member
|
||||
of a static archive."""
|
||||
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 found
|
||||
|
||||
|
||||
def minos(path):
|
||||
"""The oldest macOS a Mach-O file runs on (LC_BUILD_VERSION or the older
|
||||
LC_VERSION_MIN_MACOSX), as a tuple."""
|
||||
out = run('otool', '-l', path)
|
||||
m = re.search(r'cmd LC_BUILD_VERSION\n.*?\n\s+minos (\S+)', out, re.S) or \
|
||||
re.search(r'cmd LC_VERSION_MIN_MACOSX\n.*?\n\s+version (\S+)', out, re.S)
|
||||
return tuple(int(x) for x in m.group(1).split('.')) if m else (0,)
|
||||
"""The oldest macOS a Mach-O file runs on, as a tuple ((0, 0, 0) if it
|
||||
doesn't say)."""
|
||||
found = minos_all(path)
|
||||
return max(found) if found else (0, 0, 0)
|
||||
|
||||
|
||||
def project_default_target():
|
||||
"""CMakeLists.txt's default deployment target (SB_OSX_DEFAULT_TARGET), or None."""
|
||||
try:
|
||||
with open(os.path.join(cache_value('CMAKE_HOME_DIRECTORY') or ROOT, 'CMakeLists.txt')) as f:
|
||||
m = re.search(r'set\(SB_OSX_DEFAULT_TARGET "([0-9.]+)"\)', f.read())
|
||||
return m.group(1) if m else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def moltenvk_version():
|
||||
"""The version of the MoltenVK to bundle (its Homebrew keg's, or
|
||||
MOLTENVK_VERSION's), refused unless it's the pinned one or
|
||||
MOLTENVK_VERSION names it."""
|
||||
k = keg(MOLTENVK)
|
||||
found = k.split('/')[-1] if k else None
|
||||
wanted = os.environ.get('MOLTENVK_VERSION')
|
||||
if found and wanted and found != wanted:
|
||||
raise SystemExit(f'make_app.py: {MOLTENVK} is MoltenVK {found}, not MOLTENVK_VERSION={wanted}')
|
||||
version = found or wanted
|
||||
if version != MOLTENVK_PINNED and not wanted:
|
||||
raise SystemExit(f'make_app.py: {MOLTENVK} is MoltenVK {version or "of an unknown version (not a Homebrew keg)"}, '
|
||||
f'not {MOLTENVK_PINNED}, the one the releases ship (the Vulkan driver: another can change how the '
|
||||
f'game runs). To bundle it anyway, set MOLTENVK_VERSION=<its version>.')
|
||||
return version
|
||||
|
||||
|
||||
def keg(path):
|
||||
@@ -128,9 +206,27 @@ def keg(path):
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
DEPS_RECORD = 'share/speedbreaker-deps.json' # written by scripts/macos/build_deps.sh
|
||||
|
||||
|
||||
def deps_prefix(path):
|
||||
"""The scripts/macos/build_deps.sh prefix a library file is in (<prefix>/lib/<file>, or
|
||||
<prefix>/ffmpeg-xma/lib/<file>), or None."""
|
||||
d = os.path.dirname(os.path.realpath(path))
|
||||
for prefix in (os.path.dirname(d), os.path.dirname(os.path.dirname(d))):
|
||||
if os.path.isfile(os.path.join(prefix, DEPS_RECORD)):
|
||||
return prefix
|
||||
return None
|
||||
|
||||
|
||||
def copy_licenses(resources, libraries):
|
||||
"""Contents/Resources/licenses: SpeedBreaker's notices and every bundled
|
||||
library's own license files, from its Homebrew keg."""
|
||||
library's own license files: from the build_deps.sh prefix it was built
|
||||
in (with the project, version, source and SHA-256 it was built from), or
|
||||
from its Homebrew keg. A prefix's projects that the game and the
|
||||
libraries are built with but that aren't a library of their own here
|
||||
(the Vulkan and SPIR-V headers, the FFmpeg the game links statically)
|
||||
get their license files too."""
|
||||
licenses = os.path.join(resources, 'licenses')
|
||||
os.makedirs(licenses)
|
||||
for name in ('COPYING', 'NOTICE', 'THIRD_PARTY_NOTICES.md'):
|
||||
@@ -139,11 +235,48 @@ def copy_licenses(resources, libraries):
|
||||
lines = ['SpeedBreaker 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/.', '',
|
||||
'The libraries in Contents/Frameworks, with their own license files here:']
|
||||
records = {} # prefix -> its build_deps.sh record
|
||||
used = set() # (prefix, project) whose license files are here
|
||||
|
||||
def record(prefix):
|
||||
if prefix not in records:
|
||||
with open(os.path.join(prefix, DEPS_RECORD)) as f:
|
||||
records[prefix] = json.load(f)
|
||||
return records[prefix]
|
||||
|
||||
def copy_project(prefix, project):
|
||||
src = os.path.join(prefix, 'share/licenses', project)
|
||||
dest = os.path.join(licenses, project)
|
||||
files = record(prefix)['projects'][project]['licenses']
|
||||
if (prefix, project) not in used:
|
||||
if os.path.exists(dest):
|
||||
raise SystemExit(f'make_app.py: two sources of license files for {project}')
|
||||
for f in files:
|
||||
os.makedirs(os.path.dirname(os.path.join(dest, f)), exist_ok=True)
|
||||
shutil.copy2(os.path.join(src, f), os.path.join(dest, f))
|
||||
used.add((prefix, project))
|
||||
return files
|
||||
|
||||
def describe(prefix, project, built=True):
|
||||
r = record(prefix); j = r['projects'][project]
|
||||
built = f', built for macOS {r["deployment_target"]}+ ({r["architecture"]})' if built else ''
|
||||
return f'{project} {j["version"]}{built}, from {j["url"]} (SHA-256 {j["sha256"]})'
|
||||
|
||||
missing = []
|
||||
for lib, src in sorted(libraries.items()):
|
||||
k = keg(src)
|
||||
found = []
|
||||
if k:
|
||||
prefix = deps_prefix(src)
|
||||
k = keg(src)
|
||||
if prefix:
|
||||
projects = record(prefix)['projects']
|
||||
project = next((n for n, j in projects.items()
|
||||
if lib in j['libraries'] or os.path.basename(os.path.realpath(src)) in j['libraries']), None)
|
||||
if project:
|
||||
found = copy_project(prefix, project)
|
||||
lines.append(f' {lib}: {describe(prefix, project)}; license files in {project}/: {", ".join(found)}')
|
||||
else:
|
||||
lines.append(f' {lib}: from {src} (not in {os.path.join(prefix, DEPS_RECORD)})')
|
||||
elif k:
|
||||
package, version = k.split('/')[-2:]
|
||||
dest = os.path.join(licenses, package)
|
||||
candidates = [os.path.join(k, f) for f in os.listdir(k)
|
||||
@@ -159,11 +292,40 @@ def copy_licenses(resources, libraries):
|
||||
if not os.path.exists(target):
|
||||
shutil.copy2(c, target)
|
||||
found.append(os.path.basename(c))
|
||||
lines.append(f' {lib}: {package} {version} ({", ".join(sorted(set(found))) or "no license file found"})')
|
||||
embedded = []
|
||||
for name, text, file in EMBEDDED.get(package, []) if found else []:
|
||||
shutil.copy2(os.path.join(ROOT, text), os.path.join(dest, file))
|
||||
embedded.append(f'{name} ({file})')
|
||||
lines.append(f' {lib}: {package} {version}, from Homebrew '
|
||||
f'({", ".join(sorted(set(found))) or "no license file found"})'
|
||||
+ (f'; it includes {", ".join(embedded)}' if embedded else ''))
|
||||
else:
|
||||
lines.append(f' {lib}: from {src} (not a Homebrew keg)')
|
||||
lines.append(f' {lib}: from {src} (neither a build_deps.sh prefix nor a Homebrew keg)')
|
||||
if not found:
|
||||
missing.append(lib)
|
||||
|
||||
# What the game and those libraries are built with from the same prefixes
|
||||
# (the headers of a prefix that a bundled library came from), and the
|
||||
# FFmpeg the game links statically when it comes from one. A prefix only
|
||||
# FFmpeg came from, as when the libraries are Homebrew's, gives FFmpeg
|
||||
# alone: the game was built with Homebrew's headers then.
|
||||
library_prefixes = {prefix for prefix, _ in used}
|
||||
ffmpeg = cache_value('FFMPEG_XMA')
|
||||
ffmpeg_prefix = deps_prefix(os.path.join(ffmpeg, 'lib/libavcodec.a')) if ffmpeg else None
|
||||
if ffmpeg_prefix:
|
||||
record(ffmpeg_prefix)
|
||||
# (A project with libraries of its own that aren't in the app isn't in it at all.)
|
||||
others = [(prefix, project) for prefix in sorted(records) for project, j in records[prefix]['projects'].items()
|
||||
if (prefix, project) not in used
|
||||
and (prefix == ffmpeg_prefix if project == 'FFmpeg'
|
||||
else not j['libraries'] and prefix in library_prefixes)]
|
||||
if others:
|
||||
lines += ['', 'Built into the game and the libraries above, with their license files here:']
|
||||
for prefix, project in others:
|
||||
files = copy_project(prefix, project)
|
||||
ffmpeg_here = project == 'FFmpeg'
|
||||
what = 'linked into the game, statically, with SpeedBreaker\'s patch' if ffmpeg_here else 'headers'
|
||||
lines.append(f' {describe(prefix, project, built=ffmpeg_here)}: {what}; license files in {project}/: {", ".join(files)}')
|
||||
with open(os.path.join(licenses, 'README.txt'), 'w') as f:
|
||||
f.write('\n'.join(lines) + '\n')
|
||||
if missing:
|
||||
@@ -204,6 +366,14 @@ def main():
|
||||
minimum = cache_value('CMAKE_OSX_DEPLOYMENT_TARGET')
|
||||
if not re.fullmatch(r'\d+(\.\d+)*', minimum):
|
||||
raise SystemExit(f'make_app.py: no CMAKE_OSX_DEPLOYMENT_TARGET in {BUILD} (the top-level CMakeLists.txt sets one)')
|
||||
if MIN_MACOS and version_tuple(MIN_MACOS) != version_tuple(minimum):
|
||||
raise SystemExit(f'make_app.py: {BUILD} is built for macOS {minimum}, not {MIN_MACOS} (--min-macos); '
|
||||
f'configure it with -DCMAKE_OSX_DEPLOYMENT_TARGET={MIN_MACOS}, or in a new build folder')
|
||||
default = project_default_target()
|
||||
if default and version_tuple(default) != version_tuple(minimum):
|
||||
print(f'make_app.py: warning: {BUILD} is configured for macOS {minimum}, not the project\'s {default} '
|
||||
f'(CMakeLists.txt): not for release', file=sys.stderr)
|
||||
mvk_version = moltenvk_version()
|
||||
app = os.path.join(OUT, 'SpeedBreaker.app')
|
||||
shutil.rmtree(app, ignore_errors=True)
|
||||
contents = os.path.join(app, 'Contents')
|
||||
@@ -277,17 +447,35 @@ def main():
|
||||
run('sips', '-z', str(px), str(px), src, '--out', os.path.join(iconset, name))
|
||||
run('iconutil', '-c', 'icns', iconset, '-o', os.path.join(resources, 'AppIcon.icns'))
|
||||
|
||||
# Every binary here runs on the macOS the plist promises.
|
||||
def norm(v):
|
||||
v = tuple(v)
|
||||
return v + (0,) * (3 - len(v))
|
||||
need = {f: norm(minos(f)) for f in [exe] + [os.path.join(frameworks, n) for n in os.listdir(frameworks)]}
|
||||
floor = norm(int(x) for x in minimum.split('.'))
|
||||
newer = [f'{os.path.basename(f)} ({".".join(map(str, v))})' for f, v in need.items() if v > floor]
|
||||
# Every binary here runs on the macOS the plist promises, and so does the
|
||||
# FFmpeg linked into the game (an archive built for a newer macOS links
|
||||
# with only an ld warning).
|
||||
def show(v):
|
||||
return '.'.join(map(str, v[:2] if v[2] == 0 else v))
|
||||
floor = version_tuple(minimum)
|
||||
need = {f: minos(f) for f in [exe] + [os.path.join(frameworks, n) for n in os.listdir(frameworks)]}
|
||||
newer = [f'{os.path.basename(f)} ({show(v)}, from {origin[f]})' for f, v in sorted(need.items()) if v > floor]
|
||||
newest = max(need.values())
|
||||
ffmpeg = cache_value('FFMPEG_XMA')
|
||||
for a in ('lib/libavcodec.a', 'lib/libavutil.a') if ffmpeg else ():
|
||||
path = os.path.join(ffmpeg, a)
|
||||
found = minos_all(path) if os.path.isfile(path) else []
|
||||
if any(v > floor for v in found):
|
||||
newer.append(f'{os.path.basename(a)} ({show(max(found))}, from {path}, linked into the game)')
|
||||
newest = max(newest, max(found))
|
||||
if newer:
|
||||
raise SystemExit(f'make_app.py: these need a newer macOS than the build\'s {minimum}: ' + ', '.join(newer))
|
||||
brew = any('/Cellar/' in n or '/opt/homebrew/' in n for n in newer)
|
||||
raise SystemExit(
|
||||
f'make_app.py: these need a newer macOS than the build\'s {minimum}, so the app would not open on macOS '
|
||||
f'{minimum}:\n ' + '\n '.join(newer) + '\n'
|
||||
+ ('Homebrew\'s bottles are built for the macOS they were made on. ' if brew else '')
|
||||
+ f'Either build the libraries for {minimum} (how the release is built):\n'
|
||||
f' scripts/macos/build_deps.sh <prefix>, then configure the build with -DCMAKE_PREFIX_PATH=<prefix> '
|
||||
f'-DFFMPEG_XMA=<prefix>/ffmpeg-xma and rebuild;\n'
|
||||
f'or make an app for this Mac\'s macOS and newer only: configure the build with '
|
||||
f'-DCMAKE_OSX_DEPLOYMENT_TARGET={show(newest)} and rebuild.')
|
||||
if need[exe] != floor:
|
||||
raise SystemExit(f'make_app.py: the game was built for macOS {".".join(map(str, need[exe]))}, not {minimum}')
|
||||
raise SystemExit(f'make_app.py: the game was built for macOS {show(need[exe])}, not {minimum}')
|
||||
|
||||
copy_licenses(resources, {**copied, 'libMoltenVK.dylib': MOLTENVK})
|
||||
|
||||
@@ -322,13 +510,16 @@ def main():
|
||||
run('codesign', '--force', '--sign', '-', app)
|
||||
run('codesign', '--verify', '--deep', '--strict', app)
|
||||
|
||||
leftover = [f'{os.path.basename(f)}: {r}' for f in [exe] + [os.path.join(frameworks, n) for n in os.listdir(frameworks)]
|
||||
for r in deps(f) if r.startswith('/opt/') or r.startswith('/usr/local/')]
|
||||
# Nothing but the system's and the app's own: no library or search path
|
||||
# outside it (Homebrew's, or the prefix the libraries were built in).
|
||||
binaries = [exe] + [os.path.join(frameworks, n) for n in os.listdir(frameworks)]
|
||||
leftover = [f'{os.path.basename(f)}: {r}' for f in binaries for r in deps(f) if not r.startswith('@rpath/')]
|
||||
leftover += [f'{os.path.basename(f)}: rpath {r}' for f in binaries for r in rpaths(f) if not r.startswith('@')]
|
||||
if leftover:
|
||||
raise SystemExit('make_app.py: still linked outside the app: ' + '; '.join(leftover))
|
||||
size = int(run('du', '-sk', app).split()[0]) // 1024
|
||||
print(f'{app} ({size} MB, v{version}, {commit}{"-dirty" if dirty else ""}, macOS {minimum}+): '
|
||||
f'{len(copied)} libraries and MoltenVK in Frameworks; symbols in {dsym}')
|
||||
f'{len(copied)} libraries and MoltenVK {mvk_version} in Frameworks; symbols in {dsym}')
|
||||
if DMG:
|
||||
dmg = make_dmg(app, version)
|
||||
print(f'{dmg} ({os.path.getsize(dmg) // (1 << 20)} MB) and {os.path.basename(dmg)}.sha256')
|
||||
|
||||
+14
-1
@@ -49,9 +49,22 @@ fi
|
||||
|
||||
# FFmpeg for XMA audio: a minimal static libavcodec (XMA1/XMA2/WMAPro
|
||||
# decoders only) with patches/ffmpeg applied (scripts/build_ffmpeg_xma.sh).
|
||||
# On macOS it is linked into the game, so it is built for the oldest macOS
|
||||
# the game runs on (CMakeLists.txt's SB_OSX_DEFAULT_TARGET), not this Mac's
|
||||
# own; one built for another macOS is rebuilt.
|
||||
FFMPEG_VERSION=7.1.1
|
||||
FFMPEG_SHA256=733984395e0dbbe5c046abda2dc49a5544e7e0e1e2366bba849222ae9e3a03b1
|
||||
if [ ! -f "$TOOLS/ffmpeg-xma/lib/libavcodec.a" ]; then
|
||||
ffmpeg_ok=1
|
||||
[ -f "$TOOLS/ffmpeg-xma/lib/libavcodec.a" ] || ffmpeg_ok=0
|
||||
if [ "$(uname -s)" = Darwin ]; then
|
||||
export MACOSX_DEPLOYMENT_TARGET="$(sed -n 's/^set(SB_OSX_DEFAULT_TARGET "\([0-9.]*\)").*/\1/p' "$ROOT/CMakeLists.txt" | head -1)"
|
||||
[ -n "$MACOSX_DEPLOYMENT_TARGET" ] || { echo "no SB_OSX_DEFAULT_TARGET in $ROOT/CMakeLists.txt"; exit 1; }
|
||||
if [ $ffmpeg_ok = 1 ] && [ "$(otool -l "$TOOLS/ffmpeg-xma/lib/libavcodec.a" "$TOOLS/ffmpeg-xma/lib/libavutil.a" \
|
||||
| awk '/LC_BUILD_VERSION/{b=1} b&&/minos/{print $2; b=0}' | sort -u)" != "$MACOSX_DEPLOYMENT_TARGET" ]; then
|
||||
echo "tools/ffmpeg-xma isn't built for macOS $MACOSX_DEPLOYMENT_TARGET: rebuilding it"; ffmpeg_ok=0
|
||||
fi
|
||||
fi
|
||||
if [ $ffmpeg_ok = 0 ]; then
|
||||
mkdir -p "$TOOLS/ffmpeg-src"
|
||||
cd "$TOOLS/ffmpeg-src"
|
||||
[ -f "ffmpeg-$FFMPEG_VERSION.tar.xz" ] || curl -sfLO "https://ffmpeg.org/releases/ffmpeg-$FFMPEG_VERSION.tar.xz"
|
||||
|
||||
@@ -62,10 +62,12 @@ or a compiler. After building (step 3), inside the container:
|
||||
This makes `build/bundle/SpeedBreaker/` and the release download
|
||||
`build/bundle/SpeedBreaker-steamos-x86_64.tar.xz` (+ `.sha256`; about 16 MB,
|
||||
64 MB unpacked), with a copy named by the version
|
||||
(`SpeedBreaker-<version>-steamos-x86_64.tar.xz`): the game (its debug
|
||||
information stripped; the full symbols go to `build/bundle/symbols/`, never
|
||||
published), SDL3, glslang, SPIRV-Tools, the C and C++ runtimes it was built
|
||||
against, the launcher `bundle/speedbreaker.sh`, the player's instructions
|
||||
(`SpeedBreaker-<version>-steamos-x86_64.tar.xz`): the game, `lib/SpeedBreaker`
|
||||
(its debug information stripped; the full symbols go to
|
||||
`build/bundle/symbols/`, never published), SDL3, glslang, SPIRV-Tools, the C
|
||||
and C++ runtimes it was built against (all in `lib/`), the launcher
|
||||
`bundle/speedbreaker.sh` (the only program at the top of the folder: the game
|
||||
can't pick its libraries on its own), the player's instructions
|
||||
`bundle/README.md` and licenses (`COPYING`, `NOTICE`,
|
||||
`THIRD_PARTY_NOTICES.md`, `LICENSES/` and each bundled library's package
|
||||
licenses). The version is `project()`'s in the top-level `CMakeLists.txt`. No
|
||||
|
||||
@@ -8,10 +8,12 @@ or Valve.
|
||||
This is the arm64 SteamOS download, for the Steam Frame (the Steam Deck,
|
||||
Steam Machine and Linux PCs have their own, the x86-64 one). In the headset
|
||||
the game plays on a big flat screen: it is not a VR mode. This folder has the
|
||||
game program, the libraries it needs and a launcher. It has **no game data**:
|
||||
you need your own disc image of your own Xbox 360 disc of the game (an
|
||||
`.iso`), and the game installs itself from it on the first run. `VERSION`
|
||||
says which release this is.
|
||||
launcher, `speedbreaker.sh`, and in `lib/` the game program and the libraries
|
||||
it needs. **Always start the game with `speedbreaker.sh`** (your Steam
|
||||
shortcut does): the program in `lib/` doesn't find its libraries on its own.
|
||||
It has **no game data**: you need your own disc image of your own Xbox 360
|
||||
disc of the game (an `.iso`), and the game installs itself from it on the
|
||||
first run. `VERSION` says which release this is.
|
||||
|
||||
It runs on the Frame's SteamOS as it is (tested on SteamOS 0.3.0) and needs
|
||||
no developer tools. About 60 MB, plus 7.2 GB for the installed game.
|
||||
@@ -37,8 +39,13 @@ should say OK.
|
||||
Keep the folder on the Frame's internal storage, and don't use `:`, `;` or
|
||||
`$` in the names of the folders it's in: the game can't start from there.
|
||||
|
||||
To update later, unpack a newer download over the same folder. Your saves,
|
||||
settings and the installed game are kept elsewhere (below).
|
||||
To update later, unpack the newer download over the same folder (the same
|
||||
command). Your Steam shortcut keeps working, and your saves, settings and the
|
||||
installed game are kept (they're elsewhere, below), so no disc image is
|
||||
needed. Over v0.1.0, which kept the program at the top of this folder, the
|
||||
first start removes that old copy. Settings menu > Advanced > **Check for
|
||||
Updates** says whether a newer one is out, with a QR code of the download page
|
||||
for your phone. Nothing is checked online unless you press Check for Updates.
|
||||
|
||||
## 3. Add it to Steam
|
||||
|
||||
@@ -68,6 +75,12 @@ delete the `.iso`.
|
||||
From Konsole, the same install:
|
||||
`~/Games/SpeedBreaker/speedbreaker.sh --install ~/Downloads/<your image>.iso`
|
||||
|
||||
The game goes to `~/.local/share/speedbreaker/game`. To put it somewhere
|
||||
else, add `--dest <folder>` to that command: it installs into exactly that
|
||||
folder, and the game remembers it. (The installer's own "Choose a
|
||||
folder..." opens a folder picker only on a desktop; started from your Steam
|
||||
library, it says to use this command instead.)
|
||||
|
||||
## Controls and settings
|
||||
|
||||
- The Frame's controllers work as an Xbox 360 controller.
|
||||
@@ -93,11 +106,13 @@ the run before is kept as `previous-run.log`.
|
||||
|
||||
## Where things are
|
||||
|
||||
- The installed game, saves and `settings.toml`: `~/.local/share/speedbreaker/`.
|
||||
- Saves and `settings.toml`: `~/.local/share/speedbreaker/`.
|
||||
- The installed game: `game/` in that folder, or the folder you gave
|
||||
`--dest`.
|
||||
- Shader caches (safe to delete): `~/.cache/speedbreaker/`.
|
||||
- To uninstall: delete this folder, `~/.local/share/speedbreaker/` (that
|
||||
deletes your saves too) and `~/.cache/speedbreaker/`, and remove the
|
||||
shortcut from Steam.
|
||||
deletes your saves too), `~/.cache/speedbreaker/` and the game's folder
|
||||
if you chose one, and remove the shortcut from Steam.
|
||||
|
||||
## Licenses
|
||||
|
||||
|
||||
@@ -6,14 +6,17 @@ project(u), not affiliated with, endorsed by or sponsored by Electronic Arts
|
||||
or Valve.
|
||||
|
||||
This is the x86-64 SteamOS download (the Steam Frame has its own, the arm64
|
||||
one). This folder has the game program, the libraries it needs and a
|
||||
launcher. It has **no game data**: you need your own disc image of your own
|
||||
Xbox 360 disc of the game (an `.iso`), and the game installs itself from it on
|
||||
the first run. `VERSION` says which release this is.
|
||||
one). This folder has the launcher, `speedbreaker.sh`, and in `lib/` the
|
||||
game program and the libraries it needs. **Always start the game with
|
||||
`speedbreaker.sh`** (your Steam shortcut does): the program in `lib/` can't
|
||||
start on its own, since the launcher picks the libraries it runs with. It has
|
||||
**no game data**: you need your own disc image of your own Xbox 360 disc of
|
||||
the game (an `.iso`), and the game installs itself from it on the first run.
|
||||
`VERSION` says which release this is.
|
||||
|
||||
It runs on SteamOS as it is and needs no distrobox or developer tools. Tested
|
||||
on a Steam Deck OLED (SteamOS 3.9) and a Steam Machine (SteamOS 3.8). On other
|
||||
Linux distributions it hasn't been tried yet.
|
||||
Linux distributions it hasn't been tried yet; run `speedbreaker.sh` there too.
|
||||
|
||||
## 1. Unpack
|
||||
|
||||
@@ -39,8 +42,13 @@ program needs. No `:`, `;` or `$`
|
||||
in the names of the folders it's in (not "Need for Speed: Most Wanted"): the
|
||||
game can't start from there.
|
||||
|
||||
To update later, unpack a newer build over the same folder. Your saves,
|
||||
settings and the installed game are kept elsewhere (below).
|
||||
To update later, unpack the newer download over the same folder (the same
|
||||
command, or Extract archive here, letting it overwrite). Your Steam shortcut
|
||||
keeps working, and your saves, settings and the installed game are kept
|
||||
(they're elsewhere, below), so no disc image is needed. Over v0.1.0, which
|
||||
kept the program at the top of this folder, the first start removes that old
|
||||
copy. Settings menu > Advanced > **Check for Updates** says whether a newer
|
||||
one is out. Nothing is checked online unless you press Check for Updates.
|
||||
|
||||
## 2. Add it to Steam
|
||||
|
||||
@@ -69,10 +77,18 @@ images it finds rather than "Browse for image...", whose file browser may not
|
||||
show in Game Mode.
|
||||
It checks the image first (the right game and version, nothing missing),
|
||||
then copies and checks every file: about 7 GB, a few minutes at most. You
|
||||
choose where it goes: the internal storage, or the SD card. Afterwards you
|
||||
can delete the `.iso`.
|
||||
choose where it goes with left and right: the internal storage, the SD card,
|
||||
or "Choose a folder...", which opens a folder picker (press A on it). An
|
||||
empty folder is used as it is; a folder that already holds files gets a new
|
||||
`SpeedBreaker Game` folder inside it (the top of a card or drive gets
|
||||
`speedbreaker/game`, like the SD card's own entry). The picker opens in
|
||||
Desktop Mode only: in Game Mode the installer says so. Pick a folder on the
|
||||
internal storage or on a card or drive formatted by SteamOS, or Game Mode
|
||||
won't find the game. Afterwards you can delete the `.iso`.
|
||||
|
||||
From a terminal, the same install: `~/Games/SpeedBreaker/speedbreaker.sh --install ~/Downloads/<your image>.iso`
|
||||
(add `--dest <folder>` to install into exactly that folder: the game
|
||||
remembers it, in Game Mode too).
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -86,8 +102,10 @@ From a terminal, the same install: `~/Games/SpeedBreaker/speedbreaker.sh --insta
|
||||
|
||||
## Screen
|
||||
|
||||
- The Deck's screen is 16:10; the game is 16:9, so it shows with thin bars
|
||||
above and below. That's intended.
|
||||
- The Deck's screen is 16:10 and the game is 16:9. By default (Settings >
|
||||
Display > Aspect Ratio: Fill Screen) it fills the screen with a slightly
|
||||
taller view, the HUD kept at 16:9; choose 16:9 there for the console's
|
||||
picture with thin bars above and below.
|
||||
- **Steam Deck OLED: set the refresh rate to 60 Hz** (Quick Access menu >
|
||||
Performance, the battery icon > Refresh Rate; turn on Advanced View if it
|
||||
isn't shown). The game runs at 60 frames a second and times itself to a
|
||||
@@ -123,12 +141,14 @@ ends with where in the program it was.
|
||||
|
||||
## Where things are
|
||||
|
||||
- The installed game, saves and `settings.toml`: `~/.local/share/speedbreaker/`
|
||||
(or the SD card's `speedbreaker/game` if you installed it there).
|
||||
- Saves and `settings.toml`: `~/.local/share/speedbreaker/`.
|
||||
- The installed game: `game/` in that folder, or the SD card's
|
||||
`speedbreaker/game`, or the folder you chose. The game remembers where it
|
||||
is; if that card or drive isn't there when it starts, it says so.
|
||||
- Shader caches (safe to delete): `~/.cache/speedbreaker/`.
|
||||
- To uninstall: delete this folder, `~/.local/share/speedbreaker/` (that
|
||||
deletes your saves too) and `~/.cache/speedbreaker/`, and remove the
|
||||
shortcut from Steam.
|
||||
deletes your saves too), `~/.cache/speedbreaker/` and the game's folder
|
||||
if you chose one, and remove the shortcut from Steam.
|
||||
|
||||
## Licenses
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/bin/bash
|
||||
# SpeedBreaker on SteamOS without a distrobox (a Steam Deck, a Steam Machine,
|
||||
# a Steam Frame): add this script to Steam as a non-Steam game (README.md next
|
||||
# to it). It runs SpeedBreaker from this folder with the libraries in lib/
|
||||
# and logs to last-run.log here;
|
||||
# to it). It is the only program at the top of this folder: it runs the game,
|
||||
# lib/SpeedBreaker, with the libraries in lib/ (the game on its own can't pick
|
||||
# them: started directly it fails, e.g. "GLIBC_2.44 not found"), and logs to
|
||||
# last-run.log here;
|
||||
# the run before stays as previous-run.log, so a relaunch after a crash
|
||||
# doesn't lose it. SpeedBreaker's own options pass through
|
||||
# (`speedbreaker.sh --install <image>`), and NFSMW_* settings can go in Steam's
|
||||
@@ -26,7 +28,7 @@
|
||||
# game starts (a file dialog) never see these libraries.
|
||||
# NFSMW_BUNDLED_GLIBC=1|0 and NFSMW_BUNDLED_CXX=1|0 force a choice (debugging).
|
||||
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
|
||||
BIN="$HERE/SpeedBreaker"
|
||||
BIN="$HERE/lib/SpeedBreaker"
|
||||
|
||||
# The log: here, or in the data folder if this one isn't writable (or /tmp,
|
||||
# the last place a log can go). On a terminal it's shown as well
|
||||
@@ -46,6 +48,26 @@ fi
|
||||
|
||||
say() { echo "[launcher] $*"; }
|
||||
say "$(cat "$HERE/VERSION" 2>/dev/null || echo "SpeedBreaker (no VERSION file)"), started $(date '+%F %T %z')"
|
||||
|
||||
# Up to v0.1.0 the game was here at the top, beside this script; since v0.1.1
|
||||
# it is lib/SpeedBreaker. A newer release unpacked over an older folder (the
|
||||
# README's way to update) leaves the old program behind: an old version that
|
||||
# runs if started directly. With the new one in lib/, it goes (or, if it
|
||||
# can't be removed, it's renamed aside), once, and the log says so.
|
||||
OLD="$HERE/SpeedBreaker"
|
||||
if [ -f "$BIN" ] && [ -f "$OLD" ] && [ ! -L "$OLD" ]; then
|
||||
if rm -f "$OLD" 2>/dev/null && [ ! -e "$OLD" ]; then
|
||||
say "removed SpeedBreaker at the top of this folder: an earlier release's program, left by unpacking this one over it (the game is lib/SpeedBreaker now)"
|
||||
elif mv -f "$OLD" "$OLD.old" 2>/dev/null; then
|
||||
say "renamed SpeedBreaker at the top of this folder to SpeedBreaker.old: an earlier release's program (the game is lib/SpeedBreaker now)"
|
||||
else
|
||||
say "warning: can't remove SpeedBreaker at the top of this folder, an earlier release's program (the game is lib/SpeedBreaker now): delete it, and start the game with speedbreaker.sh"
|
||||
fi
|
||||
fi
|
||||
if [ ! -f "$BIN" ]; then
|
||||
say "no lib/SpeedBreaker in $HERE: unpack the whole download again"
|
||||
exit 1
|
||||
fi
|
||||
say "system: $(. /etc/os-release 2>/dev/null; echo "${NAME:-unknown} ${VERSION_ID:-}${BUILD_ID:+ build $BUILD_ID}${VARIANT_ID:+ ($VARIANT_ID)}"), kernel $(uname -r)," \
|
||||
"CPU$(grep -m1 '^model name' /proc/cpuinfo | cut -d: -f2-), $(nproc) threads, $(awk '/^MemTotal/ { printf "%.1f GB", $2 / 1048576 }' /proc/meminfo)"
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
# arm64) and licenses. Never any game data: players install from their own
|
||||
# disc image on the first run.
|
||||
#
|
||||
# The launcher is the only program at the top of the folder: the game is
|
||||
# lib/SpeedBreaker, beside its libraries (since v0.1.1; v0.1.0 had it at the
|
||||
# top, where a player ran it directly and got "GLIBC_2.44 not found", since
|
||||
# only the launcher picks the C library it needs). A v0.1.1 folder unpacked
|
||||
# over a v0.1.0 one still holds v0.1.0's SpeedBreaker at the top: the
|
||||
# launcher removes it at its first start.
|
||||
#
|
||||
# The game's binary has its debug information stripped (its symbol table
|
||||
# stays: crash reports name functions with it, and the recompiled ones with
|
||||
# the game's own table either way). The full symbols go to
|
||||
@@ -98,20 +105,21 @@ OBJCOPY="$(command -v objcopy || command -v llvm-objcopy || true)"
|
||||
[ -n "$OBJCOPY" ] || { echo "make_bundle.sh: no objcopy (binutils) to strip the binary" >&2; exit 1; }
|
||||
DEBUG="SpeedBreaker-$VERSION-$COMMIT-steamos-$ARCH.debug"
|
||||
"$OBJCOPY" --only-keep-debug "$BIN" "$SYMBOLS/$DEBUG"
|
||||
"$OBJCOPY" --strip-debug --add-gnu-debuglink="$SYMBOLS/$DEBUG" "$BIN" "$STAGE/SpeedBreaker"
|
||||
GAME="$STAGE/lib/SpeedBreaker"
|
||||
"$OBJCOPY" --strip-debug --add-gnu-debuglink="$SYMBOLS/$DEBUG" "$BIN" "$GAME"
|
||||
# A build-time library path (a hand-built SDL3 in ~/deps) has no use in the
|
||||
# bundle, which passes the loader its own lib/.
|
||||
if readelf -d "$STAGE/SpeedBreaker" | grep -qE '\((RUNPATH|RPATH)\)'; then
|
||||
if readelf -d "$GAME" | grep -qE '\((RUNPATH|RPATH)\)'; then
|
||||
if command -v patchelf > /dev/null; then
|
||||
patchelf --remove-rpath "$STAGE/SpeedBreaker"
|
||||
patchelf --remove-rpath "$GAME"
|
||||
else
|
||||
echo "make_bundle.sh: warning: the binary keeps its build's library path ($(readelf -d "$STAGE/SpeedBreaker" |
|
||||
echo "make_bundle.sh: warning: the binary keeps its build's library path ($(readelf -d "$GAME" |
|
||||
sed -n 's/.*(R[UN]*PATH).*\[\(.*\)\]/\1/p')): harmless (the launcher's lib/ comes first), but install patchelf to drop it" >&2
|
||||
fi
|
||||
fi
|
||||
cp "$SCRIPTS/bundle/speedbreaker.sh" "$STAGE/"
|
||||
cp "$SCRIPTS/bundle/$README" "$STAGE/README.md"
|
||||
chmod 755 "$STAGE/SpeedBreaker" "$STAGE/speedbreaker.sh"
|
||||
chmod 755 "$GAME" "$STAGE/speedbreaker.sh"
|
||||
echo "SpeedBreaker v$VERSION ($COMMIT, built $(date -u -r "$BIN" +%F)), SteamOS $ARCH" > "$STAGE/VERSION"
|
||||
|
||||
# Sorted by soname; `ldd` lists the whole closure. A library the build found
|
||||
@@ -173,7 +181,7 @@ newest() { # newest <prefix> <files...>: from each file's needs, not its defini
|
||||
}
|
||||
provides() { grep -ao "${1}_[0-9.]*[0-9]" "$2" | sort -uV | tail -1; }
|
||||
shopt -s nullglob
|
||||
elves=("$STAGE/SpeedBreaker" "$STAGE"/lib/*.so* "$STAGE"/lib/cxx/*)
|
||||
elves=("$GAME" "$STAGE"/lib/*.so* "$STAGE"/lib/cxx/*)
|
||||
shopt -u nullglob
|
||||
need_glibc="$(newest GLIBC "${elves[@]}")"
|
||||
need_cxx="$(newest GLIBCXX "${elves[@]}")"
|
||||
@@ -266,13 +274,21 @@ fi
|
||||
folders=(-e "$SRC" -e "$BUILD")
|
||||
for d in tools ppc; do [ -L "$SRC/$d" ] && folders+=(-e "$(dirname "$(readlink -f "$SRC/$d")")"); done
|
||||
[ "${#HOME}" -gt 1 ] && folders+=(-e "$HOME/")
|
||||
leaks="$(grep -aoF "${folders[@]}" "$STAGE/SpeedBreaker" | sort | uniq -c || true)"
|
||||
leaks="$(grep -aoF "${folders[@]}" "$GAME" | sort | uniq -c || true)"
|
||||
if [ -n "$leaks" ]; then
|
||||
echo "make_bundle.sh: the binary names the build machine's folders (for a library folder, configure with -DCMAKE_SKIP_BUILD_RPATH=ON):" >&2
|
||||
echo "$leaks" >&2
|
||||
[ "${SB_ALLOW_PATHS:-0}" = 1 ] || exit 1
|
||||
fi
|
||||
|
||||
# The top of the folder: the launcher is the only program there (lib/ holds the
|
||||
# game), so nothing else can be run by mistake.
|
||||
top="$(cd "$STAGE" && find . -mindepth 1 -maxdepth 1 -type f -perm -u+x ! -name speedbreaker.sh | sed 's|^\./||')"
|
||||
if [ -n "$top" ]; then
|
||||
echo "make_bundle.sh: programs at the top of $STAGE besides speedbreaker.sh: $top" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "SpeedBreaker v$VERSION ($COMMIT), $TYPE build for SteamOS $ARCH, bundled $(date -u '+%F %T UTC')"
|
||||
echo "Needs: $need_glibc and $need_cxx ($runtimes: glibc $have_glibc, $have_cxx)"
|
||||
|
||||
@@ -16,7 +16,15 @@ function(sb_test name)
|
||||
endfunction()
|
||||
|
||||
sb_test(block_ranges_test)
|
||||
# Where guest memory and the upload ring go (gpu/memory_plan.h, header only).
|
||||
sb_test(memory_plan_test)
|
||||
sb_test(zpd_report_test)
|
||||
# The resolve shader's 24-bit depth pack (gpu/depth24_glsl.h), header only;
|
||||
# renderer.cpp is read to check its resolve shader uses it.
|
||||
sb_test(depth24_pack_test ARGS ${RT}/gpu/renderer.cpp)
|
||||
# The police vid-cam's side bars and the fill past the 16:9 band
|
||||
# (gpu/edge_bars.h, header only).
|
||||
sb_test(edge_bars_test)
|
||||
sb_test(picture_fit_test SOURCES ${RT}/video/picture_fit.cpp)
|
||||
sb_test(refresh_fit_test SOURCES ${RT}/video/refresh_fit.cpp)
|
||||
sb_test(frame_rate_policy_test SOURCES ${RT}/video/frame_rate_policy.cpp)
|
||||
@@ -31,9 +39,25 @@ if(Python3_FOUND)
|
||||
${CMAKE_CURRENT_BINARY_DIR}/report_zip_out)
|
||||
set_tests_properties(report_zip_check PROPERTIES DEPENDS report_zip_test)
|
||||
endif()
|
||||
# "Choose a folder..." in the installer (install/placement.h): where the game
|
||||
# goes in a picked folder and what is refused, on planted facts and in a
|
||||
# scratch folder with a sandbox HOME. POSIX only (mkdtemp, chmod, ACLs and
|
||||
# links in its disk checks); the screen hides the entry on Windows until
|
||||
# placement.cpp is ported there.
|
||||
if(NOT WIN32)
|
||||
sb_test(install_folder_test SOURCES ${RT}/install/placement.cpp ${RT}/install/installer.cpp ${RT}/install/locate.cpp
|
||||
${RT}/install/manifest.cpp ${RT}/install/result.cpp ${RT}/install/disc_source.cpp ${RT}/install/sha256.cpp
|
||||
ARGS ${CMAKE_BINARY_DIR}/install_folder_scratch) # not beside the test: that's its "own folder", refused
|
||||
if(APPLE AND NOT IOS)
|
||||
target_link_libraries(install_folder_test PRIVATE "-framework CoreFoundation") # placement.cpp's iCloud check
|
||||
endif()
|
||||
endif()
|
||||
# The version line in the log, and when the "What's new" note shows
|
||||
# (user/version_check.h, header only).
|
||||
sb_test(version_check_test)
|
||||
# Check for Updates' logic: versions, GitHub's JSON, curl's output and the
|
||||
# release notes (update/check.cpp, self-contained).
|
||||
sb_test(update_check_test SOURCES ${RT}/update/check.cpp)
|
||||
# The write watch around CPU write faults, on physical memory mapped as the
|
||||
# runtime maps it.
|
||||
sb_test(write_watch_test SOURCES ${RT}/kernel/write_watch.cpp ${RT}/cpu/guest_time.cpp)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
// Tests for gpu/depth24_glsl.h: the resolve shader's 24-bit depth pack
|
||||
// (formats 22 and 23, k_24_8 and k_24_8_FLOAT) and its C++ mirror. Until
|
||||
// d24-fix the pack was `unorm(c.r, 16777215.0) << 8`, which wrapped depth
|
||||
// 1.0 (the cleared far plane) to 0: every texel of the sun's shadow map
|
||||
// without a caster read "nearest", and roads lost their sunlight. From the
|
||||
// repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/depth24_pack_test.cpp -o build/depth24_pack_test
|
||||
// build/depth24_pack_test runtime/gpu/renderer.cpp
|
||||
// PackDepth24 evaluates the shader's own tokens (SB_DEPTH24_PACK_EXPR), so
|
||||
// what is checked here is the expression the GPU compiles. With
|
||||
// renderer.cpp's path (ctest passes it) it also checks that the resolve
|
||||
// shader, rebuilt from its literal and comments left out, defines the
|
||||
// helper and packs formats 22 and 23 with it.
|
||||
#include <gpu/depth24_glsl.h>
|
||||
#include <gpu/shared_memory_glsl.h>
|
||||
|
||||
#include <cfloat>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
using gpu::Depth24Rounding;
|
||||
using gpu::PackDepth24;
|
||||
using gpu::UnpackDepth24;
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
static const Depth24Rounding kRoundings[] = { Depth24Rounding::Separate, Depth24Rounding::Fused };
|
||||
static const char* Name(Depth24Rounding r) { return r == Depth24Rounding::Fused ? "fma" : "separate"; }
|
||||
|
||||
// The released pack (renderer.cpp up to v0.1.0): the same arithmetic, no
|
||||
// clamp to 24 bits, shifted into the word.
|
||||
static uint32_t ReleasedWord(float v, Depth24Rounding rounding)
|
||||
{
|
||||
float c = std::fmin(std::fmax(v, 0.0f), 1.0f);
|
||||
float sum;
|
||||
if (rounding == Depth24Rounding::Fused)
|
||||
sum = std::fmaf(c, 16777215.0f, 0.5f);
|
||||
else
|
||||
{
|
||||
volatile float product = c * 16777215.0f;
|
||||
sum = product + 0.5f;
|
||||
}
|
||||
return uint32_t(sum) << 8;
|
||||
}
|
||||
|
||||
static float FromBits(uint32_t bits)
|
||||
{
|
||||
float v;
|
||||
memcpy(&v, &bits, 4);
|
||||
return v;
|
||||
}
|
||||
|
||||
static void TestFarPlane()
|
||||
{
|
||||
printf("far plane (1.0)\n");
|
||||
for (Depth24Rounding r : kRoundings)
|
||||
{
|
||||
CHECK(PackDepth24(1.0f, r) == 0xFFFFFFu, "%s: 1.0 packs to %06X", Name(r), PackDepth24(1.0f, r));
|
||||
CHECK(uint32_t(PackDepth24(1.0f, r) << 8) == 0xFFFFFF00u, "%s: word %08X", Name(r), uint32_t(PackDepth24(1.0f, r) << 8));
|
||||
CHECK(UnpackDepth24(PackDepth24(1.0f, r)) == 1.0f, "%s: reads back as %.9g", Name(r), UnpackDepth24(PackDepth24(1.0f, r)));
|
||||
// The bug this guards: the arithmetic without the clamp wraps 1.0 to
|
||||
// the word 0 (nearest) either way. If this ever stops holding, the
|
||||
// mirror no longer models the shader's arithmetic.
|
||||
CHECK(ReleasedWord(1.0f, r) == 0u, "%s: the unclamped pack gave %08X, not the wrap to 0", Name(r), ReleasedWord(1.0f, r));
|
||||
}
|
||||
CHECK(PackDepth24(1.0f) == 0xFFFFFFu, "default rounding: %06X", PackDepth24(1.0f));
|
||||
}
|
||||
|
||||
// Every binary32 in [0.5, 1] (the top of the range, where 24 bits can
|
||||
// overflow): at most 0xFFFFFF, never decreasing, within 1 LSB of v * (2^24 - 1).
|
||||
static void TestTopHalf()
|
||||
{
|
||||
printf("every binary32 in [0.5, 1]\n");
|
||||
const uint32_t first = 0x3F000000u, last = 0x3F800000u; // 0.5, 1.0
|
||||
for (Depth24Rounding r : kRoundings)
|
||||
{
|
||||
uint64_t over = 0, decreasing = 0, off = 0, released = 0;
|
||||
uint32_t previous = 0, firstBad = 0;
|
||||
double worst = 0;
|
||||
for (uint32_t bits = first; bits <= last; bits++)
|
||||
{
|
||||
float v = FromBits(bits);
|
||||
uint32_t d = PackDepth24(v, r);
|
||||
double exact = double(v) * 16777215.0; // exact: 24 x 24 significant bits
|
||||
double error = std::fabs(double(d) - exact);
|
||||
worst = std::max(worst, error);
|
||||
bool bad = false;
|
||||
if (d > 0xFFFFFFu)
|
||||
over++, bad = true;
|
||||
if (bits > first && d < previous)
|
||||
decreasing++, bad = true;
|
||||
if (error > 1.0)
|
||||
off++, bad = true;
|
||||
if (bad && !firstBad)
|
||||
firstBad = bits;
|
||||
released += (ReleasedWord(v, r) >> 8) != d;
|
||||
previous = d;
|
||||
}
|
||||
printf(" %s: %u values, worst error %.8f LSB; %llu differ from the released pack\n", Name(r), last - first + 1, worst,
|
||||
(unsigned long long)released);
|
||||
CHECK(over == 0 && decreasing == 0 && off == 0, "%s: %llu above 0xFFFFFF, %llu decreasing, %llu more than 1 LSB off (first at %.9g)",
|
||||
Name(r), (unsigned long long)over, (unsigned long long)decreasing, (unsigned long long)off, FromBits(firstBad));
|
||||
// Only 1.0 changed: the clamp touches nothing else.
|
||||
CHECK(released == 1, "%s: %llu values pack differently from the released arithmetic, expected 1 (1.0)", Name(r),
|
||||
(unsigned long long)released);
|
||||
}
|
||||
}
|
||||
|
||||
static void TestZeroAndBelow()
|
||||
{
|
||||
printf("0, negatives, and outside [0, 1]\n");
|
||||
const float inf = std::numeric_limits<float>::infinity();
|
||||
const float low[] = { 0.0f, -0.0f, -FLT_TRUE_MIN, -FLT_MIN, -1e-30f, -0.25f, -0.5f, -1.0f, -2.0f, -FLT_MAX, -inf,
|
||||
FLT_TRUE_MIN, FLT_MIN, 1e-30f, 1e-8f };
|
||||
const float high[] = { std::nextafter(1.0f, 2.0f), 1.5f, 2.0f, 1e30f, FLT_MAX, inf };
|
||||
for (Depth24Rounding r : kRoundings)
|
||||
{
|
||||
for (float v : low)
|
||||
CHECK(PackDepth24(v, r) == 0u, "%s: %.9g packs to %06X", Name(r), v, PackDepth24(v, r));
|
||||
for (float v : high)
|
||||
CHECK(PackDepth24(v, r) == 0xFFFFFFu, "%s: %.9g packs to %06X", Name(r), v, PackDepth24(v, r));
|
||||
CHECK(PackDepth24(std::numeric_limits<float>::quiet_NaN(), r) == 0u, "%s: NaN packs to %06X", Name(r),
|
||||
PackDepth24(std::numeric_limits<float>::quiet_NaN(), r));
|
||||
}
|
||||
}
|
||||
|
||||
// Every 24-bit value, read as the untile reads it (float(d) / 16777215) and
|
||||
// resolved again: within 1 LSB, and the ends exact.
|
||||
static void TestRoundTrip()
|
||||
{
|
||||
printf("every 24-bit value round-trips\n");
|
||||
for (Depth24Rounding r : kRoundings)
|
||||
{
|
||||
uint64_t offByOne = 0, worse = 0;
|
||||
uint32_t firstWorse = 0;
|
||||
for (uint32_t k = 0; k <= 0xFFFFFFu; k++)
|
||||
{
|
||||
uint32_t back = PackDepth24(UnpackDepth24(k), r);
|
||||
uint32_t diff = back > k ? back - k : k - back;
|
||||
offByOne += diff == 1;
|
||||
if (diff > 1 && !worse++)
|
||||
firstWorse = k;
|
||||
}
|
||||
printf(" %s: %llu of 16777216 off by one, %llu by more\n", Name(r), (unsigned long long)offByOne, (unsigned long long)worse);
|
||||
CHECK(worse == 0, "%s: %llu values more than 1 LSB off (first %06X -> %06X)", Name(r), (unsigned long long)worse, firstWorse,
|
||||
PackDepth24(UnpackDepth24(firstWorse), r));
|
||||
CHECK(PackDepth24(UnpackDepth24(0), r) == 0u, "%s: 0 came back as %06X", Name(r), PackDepth24(UnpackDepth24(0), r));
|
||||
CHECK(PackDepth24(UnpackDepth24(0xFFFFFFu), r) == 0xFFFFFFu, "%s: 0xFFFFFF came back as %06X", Name(r),
|
||||
PackDepth24(UnpackDepth24(0xFFFFFFu), r));
|
||||
}
|
||||
}
|
||||
|
||||
// GLSL text without its comments (GLSL has no string literals): a line
|
||||
// commented out, or an expression quoted in a comment, doesn't count.
|
||||
static std::string StripGlslComments(const std::string& text)
|
||||
{
|
||||
std::string out;
|
||||
for (size_t i = 0; i < text.size();)
|
||||
{
|
||||
if (text.compare(i, 2, "//") == 0)
|
||||
i = std::min(text.find('\n', i), text.size());
|
||||
else if (text.compare(i, 2, "/*") == 0)
|
||||
{
|
||||
size_t end = text.find("*/", i + 2);
|
||||
i = end == std::string::npos ? text.size() : end + 2;
|
||||
out += ' ';
|
||||
}
|
||||
else
|
||||
out += text[i++];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static uint32_t Count(const std::string& text, const std::string& what)
|
||||
{
|
||||
uint32_t n = 0;
|
||||
for (size_t at = text.find(what); at != std::string::npos; at = text.find(what, at + 1))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Each line of `text` that is exactly `line` once surrounding blanks are
|
||||
// trimmed.
|
||||
static uint32_t CountLines(const std::string& text, const std::string& line)
|
||||
{
|
||||
uint32_t n = 0;
|
||||
std::istringstream in(text);
|
||||
for (std::string l; std::getline(in, l);)
|
||||
{
|
||||
size_t a = l.find_first_not_of(" \t\r"), b = l.find_last_not_of(" \t\r");
|
||||
n += a != std::string::npos && l.compare(a, b - a + 1, line) == 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// The GLSL text: packDepth24 is defined once, outside comments, with the
|
||||
// body PackDepth24 evaluates (SB_DEPTH24_PACK_EXPR's tokens, so the two
|
||||
// can't differ; GLSL_DEPTH24_PACK_DEFINITION is that line).
|
||||
static void TestGlslText()
|
||||
{
|
||||
printf("GLSL text\n");
|
||||
const std::string definition = GLSL_DEPTH24_PACK_DEFINITION;
|
||||
printf(" %s\n", definition.c_str());
|
||||
CHECK(definition == "uint packDepth24(float v) { return " SB_DEPTH24_STRINGIFY(SB_DEPTH24_PACK_EXPR(v)) "; }",
|
||||
"the definition isn't SB_DEPTH24_PACK_EXPR's body: %s", definition.c_str());
|
||||
// The clamp, as text (the checks above test what it does).
|
||||
CHECK(definition.find("min(") != std::string::npos && definition.find("0xFFFFFFu") != std::string::npos,
|
||||
"no clamp to 0xFFFFFF in %s", definition.c_str());
|
||||
const std::string code = StripGlslComments(GLSL_DEPTH24_PACK);
|
||||
CHECK(CountLines(code, definition) == 1, "GLSL_DEPTH24_PACK doesn't define packDepth24 on a line of its own, outside comments:\n%s",
|
||||
GLSL_DEPTH24_PACK);
|
||||
CHECK(Count(code, "packDepth24") == 1, "%u packDepth24 outside comments in GLSL_DEPTH24_PACK, expected 1:\n%s",
|
||||
Count(code, "packDepth24"), GLSL_DEPTH24_PACK);
|
||||
}
|
||||
|
||||
// The GLSL that `name`'s literal in renderer.cpp's source makes: its raw
|
||||
// string pieces, and the macros between them replaced by their text. A
|
||||
// macro this test doesn't include (another branch's splice) is left out,
|
||||
// and named.
|
||||
static bool ShaderText(const std::string& src, const char* name, std::string& glsl)
|
||||
{
|
||||
std::string start = std::string("const char* ") + name + " =";
|
||||
size_t at = src.find(start);
|
||||
CHECK(at != std::string::npos, "no %s", name);
|
||||
if (at == std::string::npos)
|
||||
return false;
|
||||
at += start.size();
|
||||
const std::pair<const char*, const char*> splices[] = {
|
||||
{ "GLSL_DEPTH24_PACK", GLSL_DEPTH24_PACK },
|
||||
{ "GLSL_SHARED_MEMORY_WRITE", GLSL_SHARED_MEMORY_WRITE },
|
||||
{ "GLSL_SHARED_MEMORY_READ", GLSL_SHARED_MEMORY_READ },
|
||||
};
|
||||
while (true)
|
||||
{
|
||||
at = src.find_first_not_of(" \t\r\n", at);
|
||||
if (at == std::string::npos)
|
||||
break;
|
||||
if (src[at] == ';')
|
||||
return true;
|
||||
if (src.compare(at, 3, "R\"(") == 0)
|
||||
{
|
||||
size_t end = src.find(")\"", at + 3);
|
||||
if (end == std::string::npos)
|
||||
break;
|
||||
glsl += src.substr(at + 3, end - at - 3);
|
||||
at = end + 2;
|
||||
continue;
|
||||
}
|
||||
size_t end = src.find_first_of(" \t\r\n;", at);
|
||||
std::string word = src.substr(at, end - at);
|
||||
bool known = false, macro = !word.empty() && word.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") == std::string::npos;
|
||||
for (auto& [splice, text] : splices)
|
||||
if (word == splice)
|
||||
glsl += text, known = true;
|
||||
if (!known && macro)
|
||||
printf(" (%s splices %s, which isn't checked here)\n", name, word.c_str());
|
||||
CHECK(known || macro, "%s: '%s' is neither a raw string nor a macro", name, word.c_str());
|
||||
if (!known && !macro)
|
||||
return false;
|
||||
at = end;
|
||||
}
|
||||
CHECK(false, "%s doesn't end", name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// renderer.cpp: the resolve shader takes the helper and packs 22 and 23
|
||||
// with it (every variant compiles from this one text). The text is the
|
||||
// shader's own, comments left out.
|
||||
static void TestRendererSource(const char* path)
|
||||
{
|
||||
printf("resolve shader in %s\n", path);
|
||||
std::ifstream file(path);
|
||||
CHECK(file.good(), "can't read %s", path);
|
||||
if (!file.good())
|
||||
return;
|
||||
std::stringstream ss;
|
||||
ss << file.rdbuf();
|
||||
std::string glsl;
|
||||
if (!ShaderText(ss.str(), "kResolveGlsl", glsl))
|
||||
return;
|
||||
const std::string code = StripGlslComments(glsl);
|
||||
CHECK(CountLines(code, GLSL_DEPTH24_PACK_DEFINITION) == 1, "kResolveGlsl doesn't define packDepth24 as GLSL_DEPTH24_PACK does");
|
||||
CHECK(Count(code, "packDepth24(float") == 1, "%u packDepth24 definitions in kResolveGlsl, expected 1", Count(code, "packDepth24(float"));
|
||||
// pack32's line for 22 and 23, and nothing else packing 24 bits.
|
||||
CHECK(Count(code, "case 22u: case 23u:") == 1, "%u case 22u/23u lines in kResolveGlsl, expected 1 (pack32)",
|
||||
Count(code, "case 22u: case 23u:"));
|
||||
CHECK(CountLines(code, "case 22u: case 23u: return packDepth24(c.r) << 8;") == 1, "formats 22/23 don't pack with packDepth24");
|
||||
size_t pack32 = code.find("uint pack32(vec4 c)"), line = code.find("case 22u: case 23u:");
|
||||
CHECK(pack32 != std::string::npos && line > pack32 && code.find("uint pack16(", pack32) > line,
|
||||
"the case 22u/23u line isn't in pack32");
|
||||
// (The untile's float(v >> 8) / 16777215.0 reads depth back; packs
|
||||
// either shift the result or pass the scale to unorm.)
|
||||
CHECK(code.find("16777215.0) <<") == std::string::npos && code.find("16777215.0)<<") == std::string::npos &&
|
||||
code.find(", 16777215.0)") == std::string::npos, "an unclamped 24-bit pack is left in kResolveGlsl");
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
TestFarPlane();
|
||||
TestTopHalf();
|
||||
TestZeroAndBelow();
|
||||
TestRoundTrip();
|
||||
TestGlslText();
|
||||
if (argc > 1)
|
||||
TestRendererSource(argv[1]);
|
||||
else
|
||||
printf("(no renderer.cpp path given: its resolve shader not checked)\n");
|
||||
printf(g_failures ? "%d FAILED\n" : "all passed\n", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
// Tests for gpu::bars (runtime/gpu/edge_bars.h): which front-end quads are
|
||||
// the police vid-cam's side bars or the intro's black frame, what they fill
|
||||
// past the 16:9 band, the stretched copy's posScale, the band clip's rows,
|
||||
// and the whole plan the renderer asks for (PlanFill) on the draws as
|
||||
// dumped. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -UNDEBUG -Iruntime tests/edge_bars_test.cpp -o build/edge_bars_test
|
||||
// build/edge_bars_test
|
||||
// The match cases run again under deliberately wrong thresholds (Planted):
|
||||
// each must fail at least one case, so a threshold nobody tests can't drift.
|
||||
// The fill, cover, colour, band-row and plan code was checked the same way,
|
||||
// by planting wrong code in a copy of the header (each mutation fails a
|
||||
// check).
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
// On Windows gpu/renderer.cpp includes the header after <windows.h>, whose
|
||||
// macros (WIN32_LEAN_AND_MEAN and NOMINMAX as platform/win32/posix.h sets
|
||||
// them) turn some words into nothing or a number: "auto near = ..." was a
|
||||
// Windows-only compile break. The header compiles here under the same ones.
|
||||
#define near
|
||||
#define far
|
||||
#define pascal
|
||||
#define cdecl
|
||||
#define IN
|
||||
#define OUT
|
||||
#define OPTIONAL
|
||||
#define CONST const
|
||||
#define TRUE 1
|
||||
#define FALSE 0
|
||||
#define DELETE (0x00010000L)
|
||||
#define ERROR 0
|
||||
#define OPAQUE 2
|
||||
#define TRANSPARENT 1
|
||||
#define ABSOLUTE 1
|
||||
#define RELATIVE 2
|
||||
#define DIFFERENCE 11
|
||||
#include <gpu/edge_bars.h>
|
||||
#undef near
|
||||
#undef far
|
||||
#undef pascal
|
||||
#undef cdecl
|
||||
#undef IN
|
||||
#undef OUT
|
||||
#undef OPTIONAL
|
||||
#undef CONST
|
||||
#undef TRUE
|
||||
#undef FALSE
|
||||
#undef DELETE
|
||||
#undef ERROR
|
||||
#undef OPAQUE
|
||||
#undef TRANSPARENT
|
||||
#undef ABSOLUTE
|
||||
#undef RELATIVE
|
||||
#undef DIFFERENCE
|
||||
|
||||
using namespace gpu::bars;
|
||||
|
||||
static int g_failures = 0;
|
||||
static bool g_quiet = false;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; if (!g_quiet) { printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } } while (0)
|
||||
|
||||
// The screen shapes (view scale x, y), as picture_fit gives them.
|
||||
struct Shape
|
||||
{
|
||||
const char* name;
|
||||
float sx, sy;
|
||||
};
|
||||
static const Shape kShapes[] = {
|
||||
{ "1280x800 (Deck)", 1.0f, 0.9f },
|
||||
{ "1200x800 (3:2)", 1.0f, 0.84375f },
|
||||
{ "1024x768 (4:3)", 1.0f, 0.75f },
|
||||
{ "2732x2048 (iPad Pro)", 1.0f, 0.750366f },
|
||||
{ "1280x720 (16:9)", 1.0f, 1.0f },
|
||||
{ "2560x1080 (21:9)", 0.75f, 1.0f },
|
||||
{ "3440x1440", 0.744186f, 1.0f },
|
||||
{ "2880x810 (32:9)", 0.5f, 1.0f },
|
||||
};
|
||||
|
||||
// A rectangle from band units at a shape, vertices in order around it
|
||||
// (the game's: (x0,y0) (x1,y0) (x1,y1) (x0,y1)).
|
||||
struct Quad
|
||||
{
|
||||
Vertex v[4];
|
||||
};
|
||||
static Quad Rectangle(float bx0, float bx1, float by0, float by1, float sx, float sy, uint32_t argb)
|
||||
{
|
||||
const float x0 = bx0 * sx, x1 = bx1 * sx, y0 = by0 * sy, y1 = by1 * sy;
|
||||
return { { { x0, y0, argb }, { x1, y0, argb }, { x1, y1, argb }, { x0, y1, argb } } };
|
||||
}
|
||||
|
||||
static const char* Name(Side s)
|
||||
{
|
||||
return s == Side::Right ? "right" : s == Side::Left ? "left" : "none";
|
||||
}
|
||||
|
||||
static void Match(const char* what, const Quad& q, float sx, float sy, Side want, const Rule& rule)
|
||||
{
|
||||
Box box{};
|
||||
const Side got = MatchSideBar(q.v, sx, sy, rule, &box);
|
||||
CHECK(got == want, "%s at x %.4f y %.4f: %s, want %s", what, sx, sy, Name(got), Name(want));
|
||||
}
|
||||
|
||||
// Every match case. Returns the failures it added (for Planted).
|
||||
static int MatchCases(const Rule& rule)
|
||||
{
|
||||
const int before = g_failures;
|
||||
constexpr uint32_t kBar = 0xFD000000;
|
||||
for (const Shape& s : kShapes)
|
||||
{
|
||||
// The two side bars, as measured at every shape (band units).
|
||||
Match("right bar", Rectangle(0.651f, 1.406f, -1.024f, 1.039f, s.sx, s.sy, kBar), s.sx, s.sy, Side::Right, rule);
|
||||
Match("left bar", Rectangle(-1.410f, -0.655f, -1.027f, 1.035f, s.sx, s.sy, kBar), s.sx, s.sy, Side::Left, rule);
|
||||
// The bezel quadrants: textured grey, one band edge.
|
||||
Match("bezel B", Rectangle(-0.001f, 0.654f, -1.027f, 0.003f, s.sx, s.sy, 0xFB7D7D7D), s.sx, s.sy, Side::None, rule);
|
||||
Match("bezel T", Rectangle(-0.657f, -0.001f, 0.003f, 1.033f, s.sx, s.sy, 0xFB7D7D7D), s.sx, s.sy, Side::None, rule);
|
||||
// ... and the same shape in the bar's black: still one edge only.
|
||||
Match("black bezel shape", Rectangle(-0.001f, 0.654f, 0.003f, 1.033f, s.sx, s.sy, kBar), s.sx, s.sy, Side::None, rule);
|
||||
// The rest of the vid cam: noise, dark overlay, rolling bar.
|
||||
Match("noise overlay", Rectangle(-0.692f, 0.709f, -1.058f, 1.050f, s.sx, s.sy, 0x3C727272), s.sx, s.sy, Side::None, rule);
|
||||
Match("noise overlay in black", Rectangle(-0.692f, 0.709f, -1.058f, 1.050f, s.sx, s.sy, kBar), s.sx, s.sy, Side::None, rule);
|
||||
Match("dark overlay", Rectangle(-0.712f, 0.668f, -0.893f, 0.908f, s.sx, s.sy, 0x3C000000), s.sx, s.sy, Side::None, rule);
|
||||
Match("rolling bar", Rectangle(-0.68f, 0.70f, -1.04f, -0.90f, s.sx, s.sy, 0x147E7E7E), s.sx, s.sy, Side::None, rule);
|
||||
// Fades and dims (the existing full-width rule's), the pause dim.
|
||||
Match("fade", Rectangle(-1.29f, 1.29f, -1.30f, 1.30f, s.sx, s.sy, 0xFF000000), s.sx, s.sy, Side::None, rule);
|
||||
Match("pause dim centre", Rectangle(-0.53f, 0.57f, -1.91f, 1.91f, s.sx, s.sy, 0xC8000000), s.sx, s.sy, Side::None, rule);
|
||||
Quad grad = Rectangle(0.569f, 1.117f, -1.905f, 1.913f, s.sx, s.sy, 0xC8000000);
|
||||
grad.v[1].argb = grad.v[2].argb = 0x00000000; // fades to nothing at the outer edge
|
||||
Match("pause dim side gradient", grad, s.sx, s.sy, Side::None, rule);
|
||||
Match("pause gradient shape in black", Rectangle(0.569f, 1.117f, -1.905f, 1.913f, s.sx, s.sy, kBar), s.sx, s.sy, Side::None, rule);
|
||||
// Graffiti wipes, brushes and splats; the minimap; small pieces.
|
||||
Match("wipe 1486D000", Rectangle(-0.34f, 1.09f, -3.30f, -0.75f, s.sx, s.sy, 0xFF000000), s.sx, s.sy, Side::None, rule);
|
||||
Match("wipe 1486D000 right", Rectangle(0.70f, 1.60f, -2.55f, 2.50f, s.sx, s.sy, 0xFF000000), s.sx, s.sy, Side::None, rule);
|
||||
Match("brush 15299000", Rectangle(-0.90f, 0.90f, -1.07f, -0.58f, s.sx, s.sy, 0xFF000000), s.sx, s.sy, Side::None, rule);
|
||||
Match("splat 15B4D000", Rectangle(-0.69f, -0.22f, -1.234f, -0.39f, s.sx, s.sy, 0xFE000000), s.sx, s.sy, Side::None, rule);
|
||||
Match("minimap piece", Rectangle(-0.73f, -0.46f, -1.02f, -0.53f, s.sx, s.sy, 0xFE7F7F7F), s.sx, s.sy, Side::None, rule);
|
||||
Match("small T piece", Rectangle(0.62f, 0.69f, 0.95f, 1.07f, s.sx, s.sy, kBar), s.sx, s.sy, Side::None, rule);
|
||||
Match("loading band", Rectangle(-1.29f, 1.29f, -0.30f, 0.30f, s.sx, s.sy, 0xFF000000), s.sx, s.sy, Side::None, rule);
|
||||
// The rest of the front-end quads catalogued while investigating.
|
||||
Match("corner bracket", Rectangle(0.568f, 0.637f, 0.723f, 0.846f, s.sx, s.sy, 0x777E7E7E), s.sx, s.sy, Side::None, rule);
|
||||
Match("corner bracket slid out", Rectangle(1.04f, 1.11f, 0.723f, 0.846f, s.sx, s.sy, 0x777E7E7E), s.sx, s.sy, Side::None, rule);
|
||||
Match("REC dot", Rectangle(-0.590f, -0.521f, 0.639f, 0.762f, s.sx, s.sy, 0xFB7E0505), s.sx, s.sy, Side::None, rule);
|
||||
Match("garage wipe 148CD000", Rectangle(-1.67f, 1.29f, -0.40f, 2.50f, s.sx, s.sy, 0xFF000000), s.sx, s.sy, Side::None, rule);
|
||||
Match("grey wipe 14CB0000", Rectangle(-1.20f, 1.97f, -1.196f, 0.18f, s.sx, s.sy, 0xFF7F7F7F), s.sx, s.sy, Side::None, rule);
|
||||
Match("grey splat 1470B000", Rectangle(0.36f, 0.70f, 0.89f, 1.64f, s.sx, s.sy, 0xFE7F7F7F), s.sx, s.sy, Side::None, rule);
|
||||
Match("loading glow 19A5A000", Rectangle(-0.29f, 0.05f, -1.03f, -0.44f, s.sx, s.sy, 0xFD2E2664), s.sx, s.sy, Side::None, rule);
|
||||
Match("loading glow 1AD58000", Rectangle(-1.29f, 1.29f, -1.05f, 1.05f, s.sx, s.sy, 0x3C000000), s.sx, s.sy, Side::None, rule);
|
||||
Match("minimap piece L", Rectangle(-1.016f, -0.73f, -0.54f, -0.04f, s.sx, s.sy, 0xFE7F7F7F), s.sx, s.sy, Side::None, rule);
|
||||
Match("menu glyph L", Rectangle(-1.05f, -0.95f, -0.70f, -0.50f, s.sx, s.sy, 0xFE7F7F7F), s.sx, s.sy, Side::None, rule);
|
||||
Match("parked additive 19B04000", Rectangle(0.80f, 1.10f, -1.40f, -1.10f, s.sx, s.sy, 0xFF000000), s.sx, s.sy, Side::None, rule);
|
||||
Match("title key art", Rectangle(-1.105f, 1.105f, -1.105f, 1.105f, s.sx, s.sy, 0xFF000000), s.sx, s.sy, Side::None, rule);
|
||||
// The FD000000 dialog boxes over the vid cam (cam_329; same colour
|
||||
// as the bars, inside the band).
|
||||
Match("cam dialog box 1", Rectangle(-0.578f, 0.586f, -0.075f, 0.462f, s.sx, s.sy, kBar), s.sx, s.sy, Side::None, rule);
|
||||
Match("cam dialog box 2", Rectangle(-0.578f, 0.586f, -0.167f, 0.569f, s.sx, s.sy, kBar), s.sx, s.sy, Side::None, rule);
|
||||
Match("cam dialog box 3", Rectangle(0.172f, 0.494f, -0.348f, -0.245f, s.sx, s.sy, kBar), s.sx, s.sy, Side::None, rule);
|
||||
// A black panel at one side inside the band (a menu's side column):
|
||||
// stops short of the band's top and bottom, so not a mask bar.
|
||||
Match("black side panel in the band", Rectangle(0.651f, 1.406f, -0.95f, 0.95f, s.sx, s.sy, kBar), s.sx, s.sy, Side::None, rule);
|
||||
}
|
||||
// Each threshold from both sides (the right bar at 4:3, one value
|
||||
// moved; the left bar mirrored), so a moved threshold fails a case.
|
||||
const float sx = 1.0f, sy = 0.75f;
|
||||
auto bar = [&](float in, float out, float bottom, float top, uint32_t argb = kBar) {
|
||||
return Rectangle(in, out, -bottom, top, sx, sy, argb);
|
||||
};
|
||||
auto mirrored = [&](float in, float out, float bottom, float top) { return Rectangle(-out, -in, -bottom, top, sx, sy, kBar); };
|
||||
Match("top 1.005", bar(0.651f, 1.406f, 1.024f, 1.005f), sx, sy, Side::None, rule);
|
||||
Match("top 1.015", bar(0.651f, 1.406f, 1.024f, 1.015f), sx, sy, Side::Right, rule);
|
||||
Match("top 1.095", bar(0.651f, 1.406f, 1.024f, 1.095f), sx, sy, Side::Right, rule);
|
||||
Match("top 1.105", bar(0.651f, 1.406f, 1.024f, 1.105f), sx, sy, Side::None, rule);
|
||||
Match("bottom 1.005", bar(0.651f, 1.406f, 1.005f, 1.039f), sx, sy, Side::None, rule);
|
||||
Match("bottom 1.105", bar(0.651f, 1.406f, 1.105f, 1.039f), sx, sy, Side::None, rule);
|
||||
Match("inner 0.495", bar(0.495f, 1.406f, 1.024f, 1.039f), sx, sy, Side::None, rule);
|
||||
Match("inner 0.505", bar(0.505f, 1.406f, 1.024f, 1.039f), sx, sy, Side::Right, rule);
|
||||
Match("inner 0.795", bar(0.795f, 1.406f, 1.024f, 1.039f), sx, sy, Side::Right, rule);
|
||||
Match("inner 0.805", bar(0.805f, 1.406f, 1.024f, 1.039f), sx, sy, Side::None, rule);
|
||||
Match("outer 1.195", bar(0.651f, 1.195f, 1.024f, 1.039f), sx, sy, Side::None, rule);
|
||||
Match("outer 1.205", bar(0.651f, 1.205f, 1.024f, 1.039f), sx, sy, Side::Right, rule);
|
||||
Match("outer 1.995", bar(0.651f, 1.995f, 1.024f, 1.039f), sx, sy, Side::Right, rule);
|
||||
Match("outer 2.005", bar(0.651f, 2.005f, 1.024f, 1.039f), sx, sy, Side::None, rule);
|
||||
Match("left inner 0.495", mirrored(0.495f, 1.410f, 1.027f, 1.035f), sx, sy, Side::None, rule);
|
||||
Match("left inner 0.805", mirrored(0.805f, 1.410f, 1.027f, 1.035f), sx, sy, Side::None, rule);
|
||||
Match("left outer 1.195", mirrored(0.655f, 1.195f, 1.027f, 1.035f), sx, sy, Side::None, rule);
|
||||
Match("left outer 2.005", mirrored(0.655f, 2.005f, 1.027f, 1.035f), sx, sy, Side::None, rule);
|
||||
Match("left top 1.105", mirrored(0.655f, 1.410f, 1.027f, 1.105f), sx, sy, Side::None, rule);
|
||||
Match("alpha EF", bar(0.651f, 1.406f, 1.024f, 1.039f, 0xEF000000), sx, sy, Side::None, rule);
|
||||
Match("alpha F0", bar(0.651f, 1.406f, 1.024f, 1.039f, 0xF0000000), sx, sy, Side::Right, rule);
|
||||
Match("alpha FF", bar(0.651f, 1.406f, 1.024f, 1.039f, 0xFF000000), sx, sy, Side::Right, rule);
|
||||
Match("blue 01", bar(0.651f, 1.406f, 1.024f, 1.039f, 0xFD000001), sx, sy, Side::None, rule);
|
||||
Match("red 01", bar(0.651f, 1.406f, 1.024f, 1.039f, 0xFD010000), sx, sy, Side::None, rule);
|
||||
Quad mixed = bar(0.651f, 1.406f, 1.024f, 1.039f);
|
||||
mixed.v[2].argb = 0xFE000000;
|
||||
Match("one vertex's colour differs", mixed, sx, sy, Side::None, rule);
|
||||
Quad off = bar(0.651f, 1.406f, 1.024f, 1.039f);
|
||||
off.v[2].x += 0.01f;
|
||||
Match("a vertex 0.01 off its corner", off, sx, sy, Side::None, rule);
|
||||
Quad nearly = bar(0.651f, 1.406f, 1.024f, 1.039f);
|
||||
nearly.v[2].x += 0.0005f;
|
||||
Match("a vertex 0.0005 off its corner", nearly, sx, sy, Side::Right, rule);
|
||||
Quad bowtie = bar(0.651f, 1.406f, 1.024f, 1.039f);
|
||||
std::swap(bowtie.v[1], bowtie.v[2]);
|
||||
Match("bow-tie order", bowtie, sx, sy, Side::None, rule);
|
||||
Quad repeated = bar(0.651f, 1.406f, 1.024f, 1.039f);
|
||||
repeated.v[3] = repeated.v[2];
|
||||
Match("a repeated vertex", repeated, sx, sy, Side::None, rule);
|
||||
return g_failures - before;
|
||||
}
|
||||
|
||||
// The same cases under a wrong rule must fail at least one.
|
||||
static void Planted(const char* what, const Rule& wrong)
|
||||
{
|
||||
const int saved = g_failures;
|
||||
g_quiet = true;
|
||||
const int failed = MatchCases(wrong);
|
||||
g_quiet = false;
|
||||
g_failures = saved;
|
||||
printf("planted %-26s %d case(s) fail\n", what, failed);
|
||||
CHECK(failed > 0, "planted %s: every case still passes", what);
|
||||
}
|
||||
|
||||
// The front end's posScale (renderer.cpp: 2 sx / w, 2 sy / h, 2 ox / w - 1,
|
||||
// 2 oy / h - 1): viewport s(640,-360) o(640,360) in the 1280x2048 target.
|
||||
static void FrontEndPs(float ps[4], float ox = 640.0f, float oy = 360.0f, float sy = -360.0f)
|
||||
{
|
||||
ps[0] = 2.0f * 640.0f / 1280.0f;
|
||||
ps[1] = 2.0f * sy / 2048.0f;
|
||||
ps[2] = 2.0f * ox / 1280.0f - 1.0f;
|
||||
ps[3] = 2.0f * oy / 2048.0f - 1.0f;
|
||||
}
|
||||
|
||||
static bool Same(const Rect& a, const Rect& b)
|
||||
{
|
||||
return a.x0 == b.x0 && a.y0 == b.y0 && a.x1 == b.x1 && a.y1 == b.y1;
|
||||
}
|
||||
|
||||
static void FillBar(const char* what, Side side, float bx0, float bx1, float sx, float sy, const float ps[4], float hostW, float hostH,
|
||||
const Rect& scissor, uint32_t wantN, const Rect* want)
|
||||
{
|
||||
const Box box{ bx0 * sx, bx1 * sx, -1.024f * sy, 1.039f * sy };
|
||||
Rect got[2]{};
|
||||
const uint32_t n = FillRects(side, box, ps, hostW, hostH, scissor, sx, sy, got);
|
||||
printf("%s: %u rect(s)", what, n);
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
printf(" [%d,%d)x[%d,%d)", got[i].x0, got[i].x1, got[i].y0, got[i].y1);
|
||||
printf("\n");
|
||||
CHECK(n == wantN, "%s: %u rects, want %u", what, n, wantN);
|
||||
for (uint32_t i = 0; i < n && i < wantN; i++)
|
||||
CHECK(Same(got[i], want[i]), "%s: rect %u [%d,%d)x[%d,%d), want [%d,%d)x[%d,%d)", what, i, got[i].x0, got[i].x1, got[i].y0,
|
||||
got[i].y1, want[i].x0, want[i].x1, want[i].y0, want[i].y1);
|
||||
}
|
||||
|
||||
// An independent oracle (pixel centres, no Edge()): a pixel of the scissor
|
||||
// is filled exactly when its centre is outside the band on the bar's side:
|
||||
// under Vert+ |y| > sy and on the bar's half (x > 0 right, x < 0 left);
|
||||
// under Hor+, when the bar ends short of the screen edge, x > sx (right) or
|
||||
// x < -sx (left). A pixel whose centre lies within 1/256 pixel of one of
|
||||
// those edges is a tie (Edge() takes it, by the rasteriser's rule) and is
|
||||
// skipped: the exact rectangles and the complement check cover ties.
|
||||
static void Oracle(const char* what, Side side, float sx, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor)
|
||||
{
|
||||
const bool right = side == Side::Right;
|
||||
const Box box = right ? Box{ 0.651f * sx, 1.406f * sx, -1.024f * sy, 1.039f * sy } : Box{ -1.410f * sx, -0.655f * sx, -1.027f * sy, 1.035f * sy };
|
||||
Rect r[2]{};
|
||||
const uint32_t n = FillRects(side, box, ps, hostW, hostH, scissor, sx, sy, r);
|
||||
const bool horPlus = sy >= 1.0f && sx < 1.0f && (right ? box.x1 < 1.0f : box.x0 > -1.0f);
|
||||
// Host pixels per clip unit, for the tie distance.
|
||||
const float pxX = std::fabs(ps[0]) * hostW * 0.5f, pxY = std::fabs(ps[1]) * hostH * 0.5f;
|
||||
auto tie = [](float d, float px) { return std::fabs(d) * px < 1.0f / 256.0f; };
|
||||
int wrong = 0, ties = 0;
|
||||
for (int32_t y = scissor.y0; y < scissor.y1; y++)
|
||||
{
|
||||
const float cy = ((float(y) + 0.5f) / hostH * 2.0f - 1.0f - ps[3]) / ps[1];
|
||||
for (int32_t x = scissor.x0; x < scissor.x1; x++)
|
||||
{
|
||||
const float cx = ((float(x) + 0.5f) / hostW * 2.0f - 1.0f - ps[2]) / ps[0];
|
||||
bool want = false, onEdge = false;
|
||||
if (sy < 1.0f)
|
||||
{
|
||||
want = std::fabs(cy) > sy && (right ? cx > 0.0f : cx < 0.0f);
|
||||
onEdge = tie(std::fabs(cy) - sy, pxY) || tie(cx, pxX);
|
||||
}
|
||||
else if (horPlus)
|
||||
{
|
||||
want = right ? cx > sx : cx < -sx;
|
||||
onEdge = tie(std::fabs(cx) - sx, pxX);
|
||||
}
|
||||
bool got = false;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
got = got || (x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1);
|
||||
if (onEdge)
|
||||
ties++;
|
||||
else
|
||||
wrong += got != want;
|
||||
}
|
||||
}
|
||||
printf("oracle %s: %d pixel(s) wrong (%d on an edge, skipped)\n", what, wrong, ties);
|
||||
CHECK(wrong == 0, "%s: %d pixels differ from the pixel-centre oracle", what, wrong);
|
||||
}
|
||||
|
||||
// The band clip's rows (gpu::bars::BandRows, which renderer.cpp clips the
|
||||
// rest of the front end with) are the rows whose centres lie inside the
|
||||
// band: checked against pixel centres, ties (within 1/256 pixel) skipped.
|
||||
static void BandOracle(const char* what, float sy, const float ps[4], float hostH, int32_t rows)
|
||||
{
|
||||
const Rows band = BandRows(-sy, sy, ps[1], ps[3], hostH);
|
||||
const float pxY = std::fabs(ps[1]) * hostH * 0.5f;
|
||||
int wrong = 0, ties = 0;
|
||||
for (int32_t y = 0; y < rows; y++)
|
||||
{
|
||||
const float cy = ((float(y) + 0.5f) / hostH * 2.0f - 1.0f - ps[3]) / ps[1];
|
||||
if (std::fabs(std::fabs(cy) - sy) * pxY < 1.0f / 256.0f)
|
||||
{
|
||||
ties++;
|
||||
continue;
|
||||
}
|
||||
wrong += (std::fabs(cy) < sy) != (y >= band.top && y < band.bottom);
|
||||
}
|
||||
printf("band rows %s: [%d,%d), %d row(s) wrong (%d on an edge, skipped)\n", what, band.top, band.bottom, wrong, ties);
|
||||
CHECK(wrong == 0, "%s: %d band rows differ from the pixel-centre oracle", what, wrong);
|
||||
}
|
||||
|
||||
// Under Vert+ the fills and the band clip's rows split the scissor's rows
|
||||
// with no gap and no overlap: a bar's half and a black frame's whole width.
|
||||
static void Complement(const char* what, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor)
|
||||
{
|
||||
const Rows band = BandRows(-sy, sy, ps[1], ps[3], hostH);
|
||||
const int32_t top = band.top, bottom = band.bottom;
|
||||
const Box box{ 0.651f, 1.406f, -1.024f * sy, 1.039f * sy };
|
||||
Rect r[2]{};
|
||||
const uint32_t n = FillRects(Side::Right, box, ps, hostW, hostH, scissor, 1.0f, sy, r);
|
||||
int wrong = 0;
|
||||
for (int32_t y = scissor.y0; y < scissor.y1; y++)
|
||||
{
|
||||
const bool band = y >= top && y < bottom;
|
||||
bool fill = false;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
fill = fill || (y >= r[i].y0 && y < r[i].y1);
|
||||
wrong += band == fill;
|
||||
}
|
||||
// The black frame's fill, every column: each pixel of the scissor is in
|
||||
// exactly one of the band's rows and the fill.
|
||||
Rect k[2]{};
|
||||
const uint32_t kn = BlackoutRects(ps, hostW, hostH, scissor, 1.0f, sy, k);
|
||||
int wrongPx = 0;
|
||||
for (int32_t y = scissor.y0; y < scissor.y1; y++)
|
||||
for (int32_t x = scissor.x0; x < scissor.x1; x++)
|
||||
{
|
||||
int in = (y >= top && y < bottom) ? 1 : 0;
|
||||
for (uint32_t i = 0; i < kn; i++)
|
||||
in += (x >= k[i].x0 && x < k[i].x1 && y >= k[i].y0 && y < k[i].y1) ? 1 : 0;
|
||||
wrongPx += in != 1;
|
||||
}
|
||||
printf("complement %s: band rows [%d,%d), %d row(s) wrong; black frame %d pixel(s) wrong\n", what, top, bottom, wrong, wrongPx);
|
||||
CHECK(wrong == 0, "%s: %d rows both or neither in the band clip and the fill", what, wrong);
|
||||
CHECK(wrongPx == 0, "%s: %d pixels both or neither in the band clip and the black frame's fill", what, wrongPx);
|
||||
}
|
||||
|
||||
static void Cover(const char* what, float bx0, float bx1, float sx, float sy, const float ps[4])
|
||||
{
|
||||
const Box b{ bx0 * sx, bx1 * sx, -1.024f * sy, 1.039f * sy };
|
||||
float q[4];
|
||||
CoverFrame(b, ps, q);
|
||||
// The box's corners land where this draw's viewport puts clip +-1.02.
|
||||
const float e = 1.02f;
|
||||
const float gx0 = b.x0 * q[0] + q[2], gx1 = b.x1 * q[0] + q[2], gy0 = b.y0 * q[1] + q[3], gy1 = b.y1 * q[1] + q[3];
|
||||
const float wx0 = -e * ps[0] + ps[2], wx1 = e * ps[0] + ps[2], wy0 = -e * ps[1] + ps[3], wy1 = e * ps[1] + ps[3];
|
||||
printf("cover %s: posScale %.5f %.5f %.5f %.5f\n", what, q[0], q[1], q[2], q[3]);
|
||||
CHECK(std::fabs(gx0 - wx0) < 1e-5f && std::fabs(gx1 - wx1) < 1e-5f, "%s: x %.6f..%.6f, want %.6f..%.6f", what, gx0, gx1, wx0, wx1);
|
||||
CHECK(std::fabs(gy0 - wy0) < 1e-5f && std::fabs(gy1 - wy1) < 1e-5f, "%s: y %.6f..%.6f, want %.6f..%.6f", what, gy0, gy1, wy0, wy1);
|
||||
CHECK(q[0] * ps[0] > 0.0f && q[1] * ps[1] > 0.0f, "%s: flipped (%.5f vs %.5f, %.5f vs %.5f)", what, q[0], ps[0], q[1], ps[1]);
|
||||
}
|
||||
|
||||
// ---- The intro's black frame (the cut between two shots, ~95 s) ----
|
||||
|
||||
// Its box in band units, as logged (NFSMW_LOG_FRONTEND "inset candidate",
|
||||
// the Deck and 32:9): x +-0.970, y +-0.939, FF000000; and the one at the
|
||||
// intro's start (~57 s): y +-0.920, FE000000.
|
||||
constexpr float kFrameX = 0.970f, kFrameY = 0.939f, kFrameY57 = 0.920f;
|
||||
constexpr uint32_t kFrameColour = 0xFF000000;
|
||||
|
||||
static void MatchFrame(const char* what, const Quad& q, float sx, float sy, bool want, const BlackoutRule& rule)
|
||||
{
|
||||
Box box{};
|
||||
const bool got = MatchBlackout(q.v, sx, sy, rule, &box);
|
||||
CHECK(got == want, "%s at x %.4f y %.4f: %s, want %s", what, sx, sy, got ? "black frame" : "none", want ? "black frame" : "none");
|
||||
}
|
||||
|
||||
// Every black-frame case. Returns the failures it added (for Planted).
|
||||
static int BlackoutCases(const BlackoutRule& rule)
|
||||
{
|
||||
const int before = g_failures;
|
||||
constexpr uint32_t kBar = 0xFD000000;
|
||||
for (const Shape& s : kShapes)
|
||||
{
|
||||
auto at = [&](float x0, float x1, float y0, float y1, uint32_t argb) { return Rectangle(x0, x1, y0, y1, s.sx, s.sy, argb); };
|
||||
MatchFrame("black frame", at(-kFrameX, kFrameX, -kFrameY, kFrameY, kFrameColour), s.sx, s.sy, true, rule);
|
||||
MatchFrame("black frame FD", at(-kFrameX, kFrameX, -kFrameY, kFrameY, kBar), s.sx, s.sy, true, rule);
|
||||
MatchFrame("black frame at ~57 s", at(-kFrameX, kFrameX, -kFrameY57, kFrameY57, 0xFE000000), s.sx, s.sy, true, rule);
|
||||
// The overlay drawn with it there (B8 alpha, past the band in x).
|
||||
MatchFrame("~57 s overlay", at(-1.104f, 1.104f, -0.978f, 0.985f, 0xB8000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("~57 s overlay shape, opaque", at(-1.104f, 1.104f, -0.978f, 0.985f, 0xFF000000), s.sx, s.sy, false, rule);
|
||||
// The title's key art (1024x512, grey vertex colour), short of the
|
||||
// band in y only: an inset candidate in the log, never a black frame.
|
||||
MatchFrame("title key art", at(-1.104f, 1.104f, -0.981f, 0.981f, 0xFF7F7F7F), s.sx, s.sy, false, rule);
|
||||
// Everything else black or nearly: the vid-cam bars, the cover
|
||||
// rule's full-band layer, the fades, the key art, the overlays.
|
||||
MatchFrame("right bar", at(0.651f, 1.406f, -1.024f, 1.039f, kBar), s.sx, s.sy, false, rule);
|
||||
MatchFrame("left bar", at(-1.410f, -0.655f, -1.027f, 1.035f, kBar), s.sx, s.sy, false, rule);
|
||||
MatchFrame("full band", at(-1.0f, 1.0f, -1.0f, 1.0f, 0xFF000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("fade", at(-1.29f, 1.29f, -1.30f, 1.30f, 0xFF000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("title key art", at(-1.105f, 1.105f, -1.105f, 1.105f, 0xFF000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("loading band", at(-1.29f, 1.29f, -0.30f, 0.30f, 0xFF000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("noise overlay in black", at(-0.692f, 0.709f, -1.058f, 1.050f, kBar), s.sx, s.sy, false, rule);
|
||||
MatchFrame("dark overlay", at(-0.712f, 0.668f, -0.893f, 0.908f, 0x3C000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("cam dialog box 2", at(-0.578f, 0.586f, -0.167f, 0.569f, kBar), s.sx, s.sy, false, rule);
|
||||
MatchFrame("pause dim centre", at(-0.53f, 0.57f, -1.91f, 1.91f, 0xC8000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("black frame in grey", at(-kFrameX, kFrameX, -kFrameY, kFrameY, 0xFF7F7F7F), s.sx, s.sy, false, rule);
|
||||
MatchFrame("black frame at 3C", at(-kFrameX, kFrameX, -kFrameY, kFrameY, 0x3C000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("frame width, full height", at(-kFrameX, kFrameX, -1.0f, 1.0f, 0xFF000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("full width, frame height", at(-1.0f, 1.0f, -kFrameY, kFrameY, 0xFF000000), s.sx, s.sy, false, rule);
|
||||
}
|
||||
// Each threshold from both sides, on the Deck and at 32:9.
|
||||
const Shape shapes[] = { { "Deck", 1.0f, 0.9f }, { "32:9", 0.5f, 1.0f } };
|
||||
for (const Shape& s : shapes)
|
||||
{
|
||||
auto frame = [&](float dx, float dy, float hx, float hy, uint32_t argb = kFrameColour) {
|
||||
return Rectangle(dx - hx, dx + hx, dy - hy, dy + hy, s.sx, s.sy, argb);
|
||||
};
|
||||
MatchFrame("centre x +0.009", frame(0.009f, 0.0f, kFrameX, kFrameY), s.sx, s.sy, true, rule);
|
||||
MatchFrame("centre x +0.011", frame(0.011f, 0.0f, kFrameX, kFrameY), s.sx, s.sy, false, rule);
|
||||
MatchFrame("centre x -0.011", frame(-0.011f, 0.0f, kFrameX, kFrameY), s.sx, s.sy, false, rule);
|
||||
MatchFrame("centre y -0.009", frame(0.0f, -0.009f, kFrameX, kFrameY), s.sx, s.sy, true, rule);
|
||||
MatchFrame("centre y -0.011", frame(0.0f, -0.011f, kFrameX, kFrameY), s.sx, s.sy, false, rule);
|
||||
MatchFrame("centre y +0.011", frame(0.0f, 0.011f, kFrameX, kFrameY), s.sx, s.sy, false, rule);
|
||||
MatchFrame("half width 0.895", frame(0.0f, 0.0f, 0.895f, kFrameY), s.sx, s.sy, false, rule);
|
||||
MatchFrame("half width 0.905", frame(0.0f, 0.0f, 0.905f, kFrameY), s.sx, s.sy, true, rule);
|
||||
MatchFrame("half width 0.985", frame(0.0f, 0.0f, 0.985f, kFrameY), s.sx, s.sy, true, rule);
|
||||
MatchFrame("half width 0.995", frame(0.0f, 0.0f, 0.995f, kFrameY), s.sx, s.sy, false, rule);
|
||||
MatchFrame("half height 0.895", frame(0.0f, 0.0f, kFrameX, 0.895f), s.sx, s.sy, false, rule);
|
||||
MatchFrame("half height 0.905", frame(0.0f, 0.0f, kFrameX, 0.905f), s.sx, s.sy, true, rule);
|
||||
MatchFrame("half height 0.985", frame(0.0f, 0.0f, kFrameX, 0.985f), s.sx, s.sy, true, rule);
|
||||
MatchFrame("half height 0.995", frame(0.0f, 0.0f, kFrameX, 0.995f), s.sx, s.sy, false, rule);
|
||||
MatchFrame("alpha EF", frame(0.0f, 0.0f, kFrameX, kFrameY, 0xEF000000), s.sx, s.sy, false, rule);
|
||||
MatchFrame("alpha F0", frame(0.0f, 0.0f, kFrameX, kFrameY, 0xF0000000), s.sx, s.sy, true, rule);
|
||||
MatchFrame("blue 01", frame(0.0f, 0.0f, kFrameX, kFrameY, 0xFF000001), s.sx, s.sy, false, rule);
|
||||
Quad mixed = frame(0.0f, 0.0f, kFrameX, kFrameY);
|
||||
mixed.v[3].argb = 0xFE000000;
|
||||
MatchFrame("one vertex's colour differs", mixed, s.sx, s.sy, false, rule);
|
||||
Quad off = frame(0.0f, 0.0f, kFrameX, kFrameY);
|
||||
off.v[2].y += 0.01f;
|
||||
MatchFrame("a vertex 0.01 off its corner", off, s.sx, s.sy, false, rule);
|
||||
Quad nearly = frame(0.0f, 0.0f, kFrameX, kFrameY);
|
||||
nearly.v[2].y += 0.0005f;
|
||||
MatchFrame("a vertex 0.0005 off its corner", nearly, s.sx, s.sy, true, rule);
|
||||
Quad bowtie = frame(0.0f, 0.0f, kFrameX, kFrameY);
|
||||
std::swap(bowtie.v[1], bowtie.v[2]);
|
||||
MatchFrame("bow-tie order", bowtie, s.sx, s.sy, false, rule);
|
||||
}
|
||||
return g_failures - before;
|
||||
}
|
||||
|
||||
static void PlantedBlackout(const char* what, const BlackoutRule& wrong)
|
||||
{
|
||||
const int saved = g_failures;
|
||||
g_quiet = true;
|
||||
const int failed = BlackoutCases(wrong);
|
||||
g_quiet = false;
|
||||
g_failures = saved;
|
||||
printf("planted %-26s %d case(s) fail\n", what, failed);
|
||||
CHECK(failed > 0, "planted %s: every case still passes", what);
|
||||
}
|
||||
|
||||
static void FillFrame(const char* what, float sx, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor,
|
||||
uint32_t wantN, const Rect* want)
|
||||
{
|
||||
Rect got[2]{};
|
||||
const uint32_t n = BlackoutRects(ps, hostW, hostH, scissor, sx, sy, got);
|
||||
printf("%s: %u rect(s)", what, n);
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
printf(" [%d,%d)x[%d,%d)", got[i].x0, got[i].x1, got[i].y0, got[i].y1);
|
||||
printf("\n");
|
||||
CHECK(n == wantN, "%s: %u rects, want %u", what, n, wantN);
|
||||
for (uint32_t i = 0; i < n && i < wantN; i++)
|
||||
CHECK(Same(got[i], want[i]), "%s: rect %u [%d,%d)x[%d,%d), want [%d,%d)x[%d,%d)", what, i, got[i].x0, got[i].x1, got[i].y0,
|
||||
got[i].y1, want[i].x0, want[i].x1, want[i].y0, want[i].y1);
|
||||
}
|
||||
|
||||
// The black frame's fill against pixel centres: a pixel of the scissor is
|
||||
// filled exactly when its centre is outside the band (|y| > sy under Vert+,
|
||||
// |x| > sx under Hor+); ties within 1/256 pixel skipped, as above.
|
||||
static void FrameOracle(const char* what, float sx, float sy, const float ps[4], float hostW, float hostH, const Rect& scissor)
|
||||
{
|
||||
Rect r[2]{};
|
||||
const uint32_t n = BlackoutRects(ps, hostW, hostH, scissor, sx, sy, r);
|
||||
const float pxX = std::fabs(ps[0]) * hostW * 0.5f, pxY = std::fabs(ps[1]) * hostH * 0.5f;
|
||||
auto tie = [](float d, float px) { return std::fabs(d) * px < 1.0f / 256.0f; };
|
||||
int wrong = 0, ties = 0;
|
||||
for (int32_t y = scissor.y0; y < scissor.y1; y++)
|
||||
{
|
||||
const float cy = ((float(y) + 0.5f) / hostH * 2.0f - 1.0f - ps[3]) / ps[1];
|
||||
for (int32_t x = scissor.x0; x < scissor.x1; x++)
|
||||
{
|
||||
const float cx = ((float(x) + 0.5f) / hostW * 2.0f - 1.0f - ps[2]) / ps[0];
|
||||
const bool want = sy < 1.0f ? std::fabs(cy) > sy : sx < 1.0f ? std::fabs(cx) > sx : false;
|
||||
const bool onEdge = sy < 1.0f ? tie(std::fabs(cy) - sy, pxY) : sx < 1.0f && tie(std::fabs(cx) - sx, pxX);
|
||||
bool got = false;
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
got = got || (x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1);
|
||||
if (onEdge)
|
||||
ties++;
|
||||
else
|
||||
wrong += got != want;
|
||||
}
|
||||
}
|
||||
printf("oracle black frame %s: %d pixel(s) wrong (%d on an edge, skipped)\n", what, wrong, ties);
|
||||
CHECK(wrong == 0, "black frame %s: %d pixels differ from the pixel-centre oracle", what, wrong);
|
||||
}
|
||||
|
||||
// ---- PlanFill on the draws as the renderer sees them ----
|
||||
|
||||
// The front end's matrix at a shape: c0 0.69 (0.92 safe area x 0.75, a 4:3
|
||||
// canvas centred in 16:9) x scaleX, c5 0.92 x scaleY (positive, as logged:
|
||||
// "c5 0.6900 (front end y 0.6900)" at 4:3), the rest 0.
|
||||
struct Matrix
|
||||
{
|
||||
float c[8];
|
||||
};
|
||||
static Matrix FrontEndMatrix(float sx, float sy)
|
||||
{
|
||||
return { { 0.69f * sx, 0.0f, 0.0f, 0.0f, 0.0f, 0.92f * sy, 0.0f, 0.0f } };
|
||||
}
|
||||
|
||||
// A quad's ePolys (words 0..3: x, y, z, colour), the positions taken back
|
||||
// through the matrix: what the game wrote for that box.
|
||||
struct EPolys
|
||||
{
|
||||
uint32_t w[4][4];
|
||||
};
|
||||
static EPolys Polys(const Quad& q, const Matrix& m)
|
||||
{
|
||||
EPolys e{};
|
||||
for (int k = 0; k < 4; k++)
|
||||
{
|
||||
const float p[3] = { q.v[k].x / m.c[0], q.v[k].y / m.c[5], 0.0f };
|
||||
std::memcpy(e.w[k], p, sizeof(p));
|
||||
e.w[k][3] = q.v[k].argb;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// The draw the S2 dump shows for a vid-cam bar (and the cut's black frame):
|
||||
// 1280x2048 vp s(640,-360) o(640,360) sc(0,0)-(1280,720) vte43F, blend
|
||||
// 07060706, t0 1AD6E000 32x32, prim 13 n4, untouched by the other rules.
|
||||
static Draw S2Draw(const Matrix& m, float sx, float sy, const float ps[4], float hostW = 1280.0f, float hostH = 2048.0f,
|
||||
Rect scissor = Rect{ 0, 0, 1280, 720 })
|
||||
{
|
||||
return Draw{ .prim = 13,
|
||||
.count = 4,
|
||||
.c = m.c,
|
||||
.frontEndY = std::fabs(m.c[5]),
|
||||
.scaleX = sx,
|
||||
.scaleY = sy,
|
||||
.frontEndShader = true,
|
||||
.leftAlone = true,
|
||||
.occlusion = false,
|
||||
.vte = 0x43F,
|
||||
.blend0 = 0x07060706,
|
||||
.t0Sampled = true,
|
||||
.t0Width = 32,
|
||||
.t0Height = 32,
|
||||
.posScale = { ps[0], ps[1], ps[2], ps[3] },
|
||||
.hostW = hostW,
|
||||
.hostH = hostH,
|
||||
.scissor = scissor };
|
||||
}
|
||||
|
||||
static const char* Name(Fill f)
|
||||
{
|
||||
return f == Fill::RightBar ? "right bar" : f == Fill::LeftBar ? "left bar" : f == Fill::Blackout ? "black frame" : "none";
|
||||
}
|
||||
|
||||
// Runs PlanFill over the ePolys; the reader fails for vertices from
|
||||
// `limit` on (past the vertex buffer) but still hands the word over, so a
|
||||
// plan that ignored the failure would act on it. `reads` counts calls.
|
||||
static Plan RunPlan(const Draw& d, const EPolys& e, uint32_t limit = 4, int* reads = nullptr)
|
||||
{
|
||||
int n = 0;
|
||||
const Plan p = PlanFill(d, [&](uint32_t k, uint32_t j, uint32_t& word) {
|
||||
n++;
|
||||
word = e.w[k & 3][j & 3];
|
||||
return k < limit;
|
||||
});
|
||||
if (reads)
|
||||
*reads = n;
|
||||
return p;
|
||||
}
|
||||
|
||||
static void PlanCase(const char* what, const Draw& d, const EPolys& e, Fill want, uint32_t wantN = 0, const Rect* wantRects = nullptr,
|
||||
uint32_t limit = 4)
|
||||
{
|
||||
const Plan p = RunPlan(d, e, limit);
|
||||
printf("plan %s: %s, %u rect(s)", what, Name(p.fill), p.rectCount);
|
||||
for (uint32_t i = 0; i < p.rectCount; i++)
|
||||
printf(" [%d,%d)x[%d,%d)", p.rects[i].x0, p.rects[i].x1, p.rects[i].y0, p.rects[i].y1);
|
||||
printf("\n");
|
||||
CHECK(p.fill == want, "%s: %s, want %s", what, Name(p.fill), Name(want));
|
||||
if (p.fill != want || want == Fill::None)
|
||||
return;
|
||||
CHECK(p.rectCount == wantN, "%s: %u rects, want %u", what, p.rectCount, wantN);
|
||||
for (uint32_t i = 0; i < p.rectCount && i < wantN; i++)
|
||||
CHECK(Same(p.rects[i], wantRects[i]), "%s: rect %u [%d,%d)x[%d,%d), want [%d,%d)x[%d,%d)", what, i, p.rects[i].x0, p.rects[i].x1,
|
||||
p.rects[i].y0, p.rects[i].y1, wantRects[i].x0, wantRects[i].x1, wantRects[i].y0, wantRects[i].y1);
|
||||
// The copy's posScale stretches the quad's own box (as the renderer
|
||||
// reads it) over the frame through the draw's posScale.
|
||||
float q[4];
|
||||
CoverFrame(p.box, d.posScale, q);
|
||||
CHECK(std::memcmp(q, p.posScale, sizeof(q)) == 0, "%s: posScale %.5f %.5f %.5f %.5f, want %.5f %.5f %.5f %.5f", what, p.posScale[0],
|
||||
p.posScale[1], p.posScale[2], p.posScale[3], q[0], q[1], q[2], q[3]);
|
||||
CHECK(p.argb == e.w[0][3], "%s: colour %08X, want %08X", what, p.argb, e.w[0][3]);
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
printf("match:\n");
|
||||
MatchCases(Rule{});
|
||||
|
||||
printf("\nguards:\n");
|
||||
{
|
||||
// The colour test the renderer asks of vertex 0 before it reads the
|
||||
// other three (BarColour): black, alpha at least minAlpha.
|
||||
CHECK(BarColour(0xFD000000), "FD000000 is a bar's colour");
|
||||
CHECK(BarColour(0xF0000000), "F0000000 is a bar's colour (minAlpha)");
|
||||
CHECK(!BarColour(0xEF000000), "EF000000 is below minAlpha");
|
||||
CHECK(!BarColour(0xFD000001) && !BarColour(0xFD000100) && !BarColour(0xFD010000), "a colour other than black");
|
||||
CHECK(!BarColour(0x00000000), "transparent black");
|
||||
// No view scale (or a negative one): nothing matches, and the box
|
||||
// is left as it was, as on any miss.
|
||||
const Quad right = Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 1.0f, 0.9f, 0xFD000000);
|
||||
const Box sentinel{ 9.0f, 9.0f, 9.0f, 9.0f };
|
||||
const float scales[][2] = { { 0.0f, 0.9f }, { 1.0f, 0.0f }, { -1.0f, 0.9f }, { 1.0f, -0.9f }, { -1.0f, -0.9f } };
|
||||
for (const auto& sc : scales)
|
||||
{
|
||||
Box b = sentinel;
|
||||
const Side got = MatchSideBar(right.v, sc[0], sc[1], Rule{}, &b);
|
||||
CHECK(got == Side::None && b.x0 == 9.0f && b.y1 == 9.0f, "view scale %.1f %.1f: %s, box %s", sc[0], sc[1], Name(got),
|
||||
b.x0 == 9.0f ? "kept" : "written");
|
||||
}
|
||||
Box b = sentinel;
|
||||
const Quad bezel = Rectangle(-0.001f, 0.654f, -1.027f, 0.003f, 1.0f, 0.9f, 0xFB7D7D7D);
|
||||
CHECK(MatchSideBar(bezel.v, 1.0f, 0.9f, Rule{}, &b) == Side::None && b.x0 == 9.0f, "a miss leaves the box as it was");
|
||||
// ... also one that fails only the last test, the sides (a black
|
||||
// column past both band edges, but neither inner nor outer edge fits).
|
||||
const Quad column = Rectangle(0.20f, 0.60f, -1.024f, 1.039f, 1.0f, 0.9f, 0xFD000000);
|
||||
CHECK(MatchSideBar(column.v, 1.0f, 0.9f, Rule{}, &b) == Side::None && b.x0 == 9.0f, "a miss on the sides leaves the box as it was");
|
||||
CHECK(MatchSideBar(right.v, 1.0f, 0.9f, Rule{}, &b) == Side::Right && b.x0 == right.v[0].x && b.x1 == right.v[1].x &&
|
||||
b.y0 == right.v[0].y && b.y1 == right.v[2].y,
|
||||
"a match writes the bar's box");
|
||||
CHECK(MatchSideBar(right.v, 1.0f, 0.9f) == Side::Right, "the box is optional");
|
||||
printf("colour, view scale and box guards checked\n");
|
||||
}
|
||||
|
||||
printf("\nplanted (each must fail a case):\n");
|
||||
auto plant = [](const char* what, auto set) { Rule r; set(r); Planted(what, r); };
|
||||
plant("pastMin 1.00", [](Rule& r) { r.pastMin = 1.00f; });
|
||||
plant("pastMin 1.02", [](Rule& r) { r.pastMin = 1.02f; });
|
||||
plant("pastMax 1.09", [](Rule& r) { r.pastMax = 1.09f; });
|
||||
plant("pastMax 1.11", [](Rule& r) { r.pastMax = 1.11f; });
|
||||
plant("innerMin 0.49", [](Rule& r) { r.innerMin = 0.49f; });
|
||||
plant("innerMin 0.51", [](Rule& r) { r.innerMin = 0.51f; });
|
||||
plant("innerMax 0.79", [](Rule& r) { r.innerMax = 0.79f; });
|
||||
plant("innerMax 0.81", [](Rule& r) { r.innerMax = 0.81f; });
|
||||
plant("outerMin 1.19", [](Rule& r) { r.outerMin = 1.19f; });
|
||||
plant("outerMin 1.21", [](Rule& r) { r.outerMin = 1.21f; });
|
||||
plant("outerMax 1.99", [](Rule& r) { r.outerMax = 1.99f; });
|
||||
plant("outerMax 2.01", [](Rule& r) { r.outerMax = 2.01f; });
|
||||
plant("minAlpha EF", [](Rule& r) { r.minAlpha = 0xEF; });
|
||||
plant("minAlpha F1", [](Rule& r) { r.minAlpha = 0xF1; });
|
||||
plant("cornerTolerance 0.02", [](Rule& r) { r.cornerTolerance = 0.02f; });
|
||||
plant("cornerTolerance 0.0001", [](Rule& r) { r.cornerTolerance = 0.0001f; });
|
||||
|
||||
printf("\nfill:\n");
|
||||
float ps[4], half[4], tiled[4], flipped[4], flippedHalf[4];
|
||||
FrontEndPs(ps);
|
||||
FrontEndPs(half, 640.5f, 360.5f); // D3D's half-pixel centre
|
||||
FrontEndPs(tiled, 640.0f, 360.0f - 256.0f); // NFSMW_TILING=1, window y offset -256
|
||||
FrontEndPs(flipped, 640.0f, 360.0f, 360.0f); // the viewport's y scale +360 (ps1 > 0)
|
||||
FrontEndPs(flippedHalf, 640.5f, 360.5f, 360.0f);
|
||||
const Rect frame{ 0, 0, 1280, 720 }, frame2{ 0, 0, 2560, 1440 }, frame3{ 0, 0, 3840, 2160 };
|
||||
{
|
||||
const Rect want[2] = { { 640, 0, 1280, 36 }, { 640, 684, 1280, 720 } };
|
||||
FillBar("Deck right, 1x", Side::Right, 0.651f, 1.406f, 1.0f, 0.9f, ps, 1280, 2048, frame, 2, want);
|
||||
FillBar("Deck right, 1x, half-pixel", Side::Right, 0.651f, 1.406f, 1.0f, 0.9f, half, 1280, 2048, frame, 2, want);
|
||||
const Rect left[2] = { { 0, 0, 640, 36 }, { 0, 684, 640, 720 } };
|
||||
FillBar("Deck left, 1x", Side::Left, -1.410f, -0.655f, 1.0f, 0.9f, ps, 1280, 2048, frame, 2, left);
|
||||
const Rect want2[2] = { { 1280, 0, 2560, 72 }, { 1280, 1368, 2560, 1440 } };
|
||||
FillBar("Deck right, 2x", Side::Right, 0.651f, 1.406f, 1.0f, 0.9f, ps, 2560, 4096, frame2, 2, want2);
|
||||
const Rect r43[2] = { { 640, 0, 1280, 90 }, { 640, 630, 1280, 720 } };
|
||||
FillBar("4:3 right", Side::Right, 0.651f, 1.406f, 1.0f, 0.75f, ps, 1280, 2048, frame, 2, r43);
|
||||
FillBar("iPad Pro right", Side::Right, 0.651f, 1.406f, 1.0f, 0.750366f, ps, 1280, 2048, frame, 2, r43);
|
||||
const Rect r32[2] = { { 640, 0, 1280, 56 }, { 640, 664, 1280, 720 } };
|
||||
FillBar("3:2 right", Side::Right, 0.651f, 1.406f, 1.0f, 0.84375f, ps, 1280, 2048, frame, 2, r32);
|
||||
// A tile (rows 256.. of the frame): only the bottom strip is in it.
|
||||
const Rect tile{ 0, 0, 1280, 464 }, t[1] = { { 640, 428, 1280, 464 } };
|
||||
FillBar("Deck right, tile at y -256", Side::Right, 0.651f, 1.406f, 1.0f, 0.9f, tiled, 1280, 2048, tile, 1, t);
|
||||
// The game's own scissor narrower than the frame: kept.
|
||||
const Rect game{ 100, 10, 1180, 700 }, g[2] = { { 640, 10, 1180, 36 }, { 640, 684, 1180, 700 } };
|
||||
FillBar("Deck right, game scissor", Side::Right, 0.651f, 1.406f, 1.0f, 0.9f, ps, 1280, 2048, game, 2, g);
|
||||
// Hor+: only past a 2.50:1 screen, the columns beyond the band.
|
||||
const Rect w329[1] = { { 1920, 0, 2560, 1440 } }, w329l[1] = { { 0, 0, 640, 1440 } };
|
||||
FillBar("32:9 right, 2x", Side::Right, 0.651f, 1.406f, 0.5f, 1.0f, ps, 2560, 4096, frame2, 1, w329);
|
||||
FillBar("32:9 left, 2x", Side::Left, -1.410f, -0.655f, 0.5f, 1.0f, ps, 2560, 4096, frame2, 1, w329l);
|
||||
FillBar("21:9 right", Side::Right, 0.651f, 1.406f, 0.75f, 1.0f, ps, 2560, 4096, frame2, 0, nullptr);
|
||||
FillBar("21:9 left", Side::Left, -1.410f, -0.655f, 0.75f, 1.0f, ps, 2560, 4096, frame2, 0, nullptr);
|
||||
FillBar("3440x1440 right", Side::Right, 0.651f, 1.406f, 0.744186f, 1.0f, ps, 2560, 4096, frame2, 0, nullptr);
|
||||
const Rect w250[1] = { { 1095, 0, 1280, 720 } };
|
||||
FillBar("2.51:1 right (bar ends at 0.9997)", Side::Right, 0.651f, 1.406f, 0.711f, 1.0f, ps, 1280, 2048, frame, 1, w250);
|
||||
FillBar("16:9", Side::Right, 0.651f, 1.406f, 1.0f, 1.0f, ps, 1280, 2048, frame, 0, nullptr);
|
||||
// Just short of 2.50:1 the bar ends past clip 1 (1.0100): the screen
|
||||
// edge test is clip 1, not the fill's reach (kFillEdge).
|
||||
FillBar("2.49:1 right (bar ends at 1.0100)", Side::Right, 0.651f, 1.406f, 0.71835f, 1.0f, ps, 1280, 2048, frame, 0, nullptr);
|
||||
FillBar("2.49:1 left (bar ends at -1.0130)", Side::Left, -1.410f, -0.655f, 0.71845f, 1.0f, ps, 1280, 2048, frame, 0, nullptr);
|
||||
// Half-pixel centres at 2x and 3x (viewport o(640.5,360.5)): the
|
||||
// first row is filled (with fills ending at clip +-1 it was not).
|
||||
const Rect h2[2] = { { 1281, 0, 2560, 73 }, { 1281, 1369, 2560, 1440 } };
|
||||
FillBar("Deck right, 2x, half-pixel", Side::Right, 0.651f, 1.406f, 1.0f, 0.9f, half, 2560, 4096, frame2, 2, h2);
|
||||
const Rect h2l[2] = { { 0, 0, 1281, 73 }, { 0, 1369, 1281, 1440 } };
|
||||
FillBar("Deck left, 2x, half-pixel", Side::Left, -1.410f, -0.655f, 1.0f, 0.9f, half, 2560, 4096, frame2, 2, h2l);
|
||||
const Rect h3[2] = { { 1921, 0, 3840, 109 }, { 1921, 2053, 3840, 2160 } };
|
||||
FillBar("Deck right, 3x, half-pixel", Side::Right, 0.651f, 1.406f, 1.0f, 0.9f, half, 3840, 6144, frame3, 2, h3);
|
||||
// 32:9 at 2x, half-pixel: the last column and the first row.
|
||||
const Rect w329h[1] = { { 1921, 0, 2560, 1440 } }, w329hl[1] = { { 0, 0, 641, 1440 } };
|
||||
FillBar("32:9 right, 2x, half-pixel", Side::Right, 0.651f, 1.406f, 0.5f, 1.0f, half, 2560, 4096, frame2, 1, w329h);
|
||||
FillBar("32:9 left, 2x, half-pixel", Side::Left, -1.410f, -0.655f, 0.5f, 1.0f, half, 2560, 4096, frame2, 1, w329hl);
|
||||
// The viewport's y scale flipped: the clip +y strip is the bottom rows.
|
||||
const Rect fl[2] = { { 640, 684, 1280, 720 }, { 640, 0, 1280, 36 } };
|
||||
FillBar("Deck right, 1x, y flipped", Side::Right, 0.651f, 1.406f, 1.0f, 0.9f, flipped, 1280, 2048, frame, 2, fl);
|
||||
FillBar("no side", Side::None, 0.651f, 1.406f, 1.0f, 0.9f, ps, 1280, 2048, frame, 0, nullptr);
|
||||
}
|
||||
Oracle("Deck right 1x", Side::Right, 1.0f, 0.9f, ps, 1280, 2048, frame);
|
||||
Oracle("Deck left 1x", Side::Left, 1.0f, 0.9f, ps, 1280, 2048, frame);
|
||||
Oracle("4:3 right 2x", Side::Right, 1.0f, 0.75f, ps, 2560, 4096, frame2);
|
||||
Oracle("3:2 left 1x", Side::Left, 1.0f, 0.84375f, ps, 1280, 2048, frame);
|
||||
Oracle("Deck right, tile", Side::Right, 1.0f, 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 });
|
||||
// D3D's half-pixel centres at 2x and 3x: clip +-1 lands a host pixel in,
|
||||
// so the first row and column are filled only because the fill reaches
|
||||
// past the frame (kFillEdge) and the scissor trims it.
|
||||
Oracle("Deck right 2x, half-pixel", Side::Right, 1.0f, 0.9f, half, 2560, 4096, frame2);
|
||||
Oracle("Deck left 2x, half-pixel", Side::Left, 1.0f, 0.9f, half, 2560, 4096, frame2);
|
||||
Oracle("Deck right 3x, half-pixel", Side::Right, 1.0f, 0.9f, half, 3840, 6144, frame3);
|
||||
Oracle("Deck left 3x, half-pixel", Side::Left, 1.0f, 0.9f, half, 3840, 6144, frame3);
|
||||
Oracle("32:9 right 2x, half-pixel", Side::Right, 0.5f, 1.0f, half, 2560, 4096, frame2);
|
||||
Oracle("32:9 left 2x, half-pixel", Side::Left, 0.5f, 1.0f, half, 2560, 4096, frame2);
|
||||
Oracle("32:9 right 1x", Side::Right, 0.5f, 1.0f, ps, 1280, 2048, frame);
|
||||
// The viewport's y scale flipped (vp s y +360: ps1 > 0): the strips swap
|
||||
// ends, the rows stay the same.
|
||||
Oracle("Deck right 1x, y flipped", Side::Right, 1.0f, 0.9f, flipped, 1280, 2048, frame);
|
||||
Oracle("Deck left 2x, y flipped, half-pixel", Side::Left, 1.0f, 0.9f, flippedHalf, 2560, 4096, frame2);
|
||||
Complement("Deck 1x", 0.9f, ps, 1280, 2048, frame);
|
||||
Complement("Deck 1x, half-pixel", 0.9f, half, 1280, 2048, frame);
|
||||
Complement("3:2 1x, half-pixel", 0.84375f, half, 1280, 2048, frame);
|
||||
Complement("iPad Pro 1x", 0.750366f, ps, 1280, 2048, frame);
|
||||
Complement("4:3 3x", 0.75f, ps, 3840, 6144, Rect{ 0, 0, 3840, 2160 });
|
||||
Complement("Deck, tile", 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 });
|
||||
Complement("Deck 2x, half-pixel", 0.9f, half, 2560, 4096, frame2);
|
||||
Complement("Deck 3x, half-pixel", 0.9f, half, 3840, 6144, frame3);
|
||||
Complement("Deck 1x, y flipped", 0.9f, flipped, 1280, 2048, frame);
|
||||
Complement("4:3 2x, y flipped, half-pixel", 0.75f, flippedHalf, 2560, 4096, frame2);
|
||||
|
||||
printf("\ncover:\n");
|
||||
Cover("Deck right", 0.651f, 1.406f, 1.0f, 0.9f, ps);
|
||||
Cover("Deck left", -1.410f, -0.655f, 1.0f, 0.9f, ps);
|
||||
Cover("Deck right, tile", 0.651f, 1.406f, 1.0f, 0.9f, tiled);
|
||||
Cover("4:3 left, half-pixel", -1.410f, -0.655f, 1.0f, 0.75f, half);
|
||||
Cover("32:9 right", 0.651f, 1.406f, 0.5f, 1.0f, ps);
|
||||
Cover("32:9 left", -1.410f, -0.655f, 0.5f, 1.0f, ps);
|
||||
Cover("Deck right, y flipped", 0.651f, 1.406f, 1.0f, 0.9f, flipped);
|
||||
Cover("Deck left, 2x, half-pixel", -1.410f, -0.655f, 1.0f, 0.9f, half);
|
||||
{
|
||||
// The copy covers the whole frame (its host rectangle contains it).
|
||||
const Box b{ 0.651f, 1.406f, -1.024f * 0.9f, 1.039f * 0.9f };
|
||||
float q[4];
|
||||
CoverFrame(b, ps, q);
|
||||
const Rect r = ClipToHost(b.x0, b.x1, b.y0, b.y1, q, 1280, 2048);
|
||||
CHECK(r.x0 <= 0 && r.y0 <= 0 && r.x1 >= 1280 && r.y1 >= 720, "Deck cover: [%d,%d)x[%d,%d) misses the frame", r.x0, r.x1, r.y0, r.y1);
|
||||
}
|
||||
|
||||
printf("\nband rows:\n");
|
||||
BandOracle("Deck 1x", 0.9f, ps, 2048, 720);
|
||||
BandOracle("Deck 1x, half-pixel", 0.9f, half, 2048, 720);
|
||||
BandOracle("3:2 1x", 0.84375f, ps, 2048, 720);
|
||||
BandOracle("4:3 2x", 0.75f, ps, 4096, 1440);
|
||||
BandOracle("iPad Pro 3x, half-pixel", 0.750366f, half, 6144, 2160);
|
||||
BandOracle("Deck 1x, y flipped", 0.9f, flipped, 2048, 720);
|
||||
BandOracle("Deck, tile", 0.9f, tiled, 2048, 464);
|
||||
{
|
||||
const Rows deck = BandRows(-0.9f, 0.9f, ps[1], ps[3], 2048), fl = BandRows(-0.9f, 0.9f, flipped[1], flipped[3], 2048);
|
||||
CHECK(deck.top == 36 && deck.bottom == 684, "Deck band rows [%d,%d), want [36,684)", deck.top, deck.bottom);
|
||||
CHECK(fl.top == 36 && fl.bottom == 684, "Deck band rows, y flipped [%d,%d), want [36,684)", fl.top, fl.bottom);
|
||||
const Rows r43 = BandRows(-0.75f, 0.75f, ps[1], ps[3], 2048);
|
||||
CHECK(r43.top == 90 && r43.bottom == 630, "4:3 band rows [%d,%d), want [90,630)", r43.top, r43.bottom);
|
||||
}
|
||||
|
||||
printf("\nblack frame:\n");
|
||||
BlackoutCases(BlackoutRule{});
|
||||
{
|
||||
// Guards: no view scale, and the box written on a match only.
|
||||
const Quad f = Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 1.0f, 0.9f, kFrameColour);
|
||||
Box b{ 9.0f, 9.0f, 9.0f, 9.0f };
|
||||
CHECK(!MatchBlackout(f.v, 0.0f, 0.9f, BlackoutRule{}, &b) && !MatchBlackout(f.v, 1.0f, -0.9f, BlackoutRule{}, &b) && b.x0 == 9.0f,
|
||||
"a black frame with no view scale");
|
||||
const Quad bar = Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 1.0f, 0.9f, 0xFD000000);
|
||||
CHECK(!MatchBlackout(bar.v, 1.0f, 0.9f, BlackoutRule{}, &b) && b.x0 == 9.0f, "a miss leaves the box as it was");
|
||||
CHECK(MatchBlackout(f.v, 1.0f, 0.9f, BlackoutRule{}, &b) && b.x0 == f.v[0].x && b.x1 == f.v[1].x && b.y0 == f.v[0].y && b.y1 == f.v[2].y,
|
||||
"a match writes the frame's box");
|
||||
// A bar is never a black frame, nor a black frame a bar.
|
||||
CHECK(MatchSideBar(f.v, 1.0f, 0.9f) == Side::None, "a black frame is not a side bar");
|
||||
}
|
||||
printf("\nplanted, black frame (each must fail a case):\n");
|
||||
auto plantFrame = [](const char* what, auto set) { BlackoutRule r; set(r); PlantedBlackout(what, r); };
|
||||
plantFrame("centreMax 0", [](BlackoutRule& r) { r.centreMax = 0.0f; });
|
||||
plantFrame("centreMax 0.02", [](BlackoutRule& r) { r.centreMax = 0.02f; });
|
||||
plantFrame("extentMin 0.89", [](BlackoutRule& r) { r.extentMin = 0.89f; });
|
||||
plantFrame("extentMin 0.91", [](BlackoutRule& r) { r.extentMin = 0.91f; });
|
||||
plantFrame("extentMax 0.98", [](BlackoutRule& r) { r.extentMax = 0.98f; });
|
||||
plantFrame("extentMax 1.00", [](BlackoutRule& r) { r.extentMax = 1.00f; });
|
||||
plantFrame("minAlpha EF", [](BlackoutRule& r) { r.minAlpha = 0xEF; });
|
||||
plantFrame("minAlpha F1", [](BlackoutRule& r) { r.minAlpha = 0xF1; });
|
||||
plantFrame("cornerTolerance 0.02", [](BlackoutRule& r) { r.cornerTolerance = 0.02f; });
|
||||
plantFrame("cornerTolerance 0.0001", [](BlackoutRule& r) { r.cornerTolerance = 0.0001f; });
|
||||
|
||||
printf("\nblack frame fill:\n");
|
||||
{
|
||||
const Rect deck[2] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 } };
|
||||
FillFrame("Deck, 1x", 1.0f, 0.9f, ps, 1280, 2048, frame, 2, deck);
|
||||
FillFrame("Deck, 1x, half-pixel", 1.0f, 0.9f, half, 1280, 2048, frame, 2, deck);
|
||||
const Rect deck2[2] = { { 0, 0, 2560, 72 }, { 0, 1368, 2560, 1440 } };
|
||||
FillFrame("Deck, 2x", 1.0f, 0.9f, ps, 2560, 4096, frame2, 2, deck2);
|
||||
const Rect deck2h[2] = { { 0, 0, 2560, 73 }, { 0, 1369, 2560, 1440 } };
|
||||
FillFrame("Deck, 2x, half-pixel", 1.0f, 0.9f, half, 2560, 4096, frame2, 2, deck2h);
|
||||
const Rect r43[2] = { { 0, 0, 1280, 90 }, { 0, 630, 1280, 720 } };
|
||||
FillFrame("4:3", 1.0f, 0.75f, ps, 1280, 2048, frame, 2, r43);
|
||||
FillFrame("iPad Pro", 1.0f, 0.750366f, ps, 1280, 2048, frame, 2, r43);
|
||||
const Rect r32[2] = { { 0, 0, 1280, 56 }, { 0, 664, 1280, 720 } };
|
||||
FillFrame("3:2", 1.0f, 0.84375f, ps, 1280, 2048, frame, 2, r32);
|
||||
const Rect t[1] = { { 0, 428, 1280, 464 } };
|
||||
FillFrame("Deck, tile at y -256", 1.0f, 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 }, 1, t);
|
||||
const Rect g[2] = { { 100, 10, 1180, 36 }, { 100, 684, 1180, 700 } };
|
||||
FillFrame("Deck, game scissor", 1.0f, 0.9f, ps, 1280, 2048, Rect{ 100, 10, 1180, 700 }, 2, g);
|
||||
const Rect fl[2] = { { 0, 684, 1280, 720 }, { 0, 0, 1280, 36 } };
|
||||
FillFrame("Deck, y flipped", 1.0f, 0.9f, flipped, 1280, 2048, frame, 2, fl);
|
||||
const Rect w329[2] = { { 1920, 0, 2560, 1440 }, { 0, 0, 640, 1440 } };
|
||||
FillFrame("32:9, 2x", 0.5f, 1.0f, ps, 2560, 4096, frame2, 2, w329);
|
||||
const Rect w329h[2] = { { 1921, 0, 2560, 1440 }, { 0, 0, 641, 1440 } };
|
||||
FillFrame("32:9, 2x, half-pixel", 0.5f, 1.0f, half, 2560, 4096, frame2, 2, w329h);
|
||||
const Rect uw[2] = { { 1120, 0, 1280, 720 }, { 0, 0, 160, 720 } };
|
||||
FillFrame("21:9", 0.75f, 1.0f, ps, 1280, 2048, frame, 2, uw);
|
||||
const Rect u3440[2] = { { 1116, 0, 1280, 720 }, { 0, 0, 164, 720 } };
|
||||
FillFrame("3440x1440", 0.744186f, 1.0f, ps, 1280, 2048, frame, 2, u3440);
|
||||
FillFrame("16:9", 1.0f, 1.0f, ps, 1280, 2048, frame, 0, nullptr);
|
||||
}
|
||||
FrameOracle("Deck 1x", 1.0f, 0.9f, ps, 1280, 2048, frame);
|
||||
FrameOracle("Deck 3x, half-pixel", 1.0f, 0.9f, half, 3840, 6144, frame3);
|
||||
FrameOracle("4:3 2x, y flipped, half-pixel", 1.0f, 0.75f, flippedHalf, 2560, 4096, frame2);
|
||||
FrameOracle("Deck, tile", 1.0f, 0.9f, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 });
|
||||
FrameOracle("32:9 2x, half-pixel", 0.5f, 1.0f, half, 2560, 4096, frame2);
|
||||
FrameOracle("21:9 1x", 0.75f, 1.0f, ps, 1280, 2048, frame);
|
||||
FrameOracle("3440x1440 2x", 0.744186f, 1.0f, ps, 2560, 4096, frame2);
|
||||
FrameOracle("16:9", 1.0f, 1.0f, ps, 1280, 2048, frame);
|
||||
Cover("black frame, Deck", -kFrameX, kFrameX, 1.0f, 0.9f, ps);
|
||||
Cover("black frame, 32:9, half-pixel", -kFrameX, kFrameX, 0.5f, 1.0f, half);
|
||||
|
||||
printf("\nplan (the draws as dumped):\n");
|
||||
{
|
||||
const Matrix deck = FrontEndMatrix(1.0f, 0.9f), w329 = FrontEndMatrix(0.5f, 1.0f);
|
||||
const EPolys rightDeck = Polys(Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 1.0f, 0.9f, 0xFD000000), deck);
|
||||
const EPolys leftDeck = Polys(Rectangle(-1.410f, -0.655f, -1.027f, 1.035f, 1.0f, 0.9f, 0xFD000000), deck);
|
||||
const EPolys frameDeck = Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 1.0f, 0.9f, kFrameColour), deck);
|
||||
const Draw d = S2Draw(deck, 1.0f, 0.9f, ps);
|
||||
// The S2 run's "edge bars" line: right bar, first [640,1280)x[0,36).
|
||||
const Rect right[2] = { { 640, 0, 1280, 36 }, { 640, 684, 1280, 720 } }, left[2] = { { 0, 0, 640, 36 }, { 0, 684, 640, 720 } };
|
||||
PlanCase("Deck right bar", d, rightDeck, Fill::RightBar, 2, right);
|
||||
PlanCase("Deck left bar", d, leftDeck, Fill::LeftBar, 2, left);
|
||||
const Rect black[2] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 } };
|
||||
PlanCase("Deck black frame", d, frameDeck, Fill::Blackout, 2, black);
|
||||
{
|
||||
const Plan p = RunPlan(d, rightDeck);
|
||||
CHECK(std::fabs(p.box.x0 - 0.651f) < 1e-5f && std::fabs(p.box.x1 - 1.406f) < 1e-5f && std::fabs(p.box.y0 + 1.024f * 0.9f) < 1e-5f &&
|
||||
std::fabs(p.box.y1 - 1.039f * 0.9f) < 1e-5f,
|
||||
"Deck right bar: box x %.5f..%.5f y %.5f..%.5f", p.box.x0, p.box.x1, p.box.y0, p.box.y1);
|
||||
}
|
||||
// At 2x (the viewport and scissor in host pixels at twice the size).
|
||||
const Rect right2[2] = { { 1280, 0, 2560, 72 }, { 1280, 1368, 2560, 1440 } };
|
||||
PlanCase("Deck right bar, 2x", S2Draw(deck, 1.0f, 0.9f, ps, 2560, 4096, frame2), rightDeck, Fill::RightBar, 2, right2);
|
||||
// 32:9 (2880x810, 2x): the bars past 2.50:1 and the black frame.
|
||||
const EPolys right329 = Polys(Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 0.5f, 1.0f, 0xFD000000), w329);
|
||||
const EPolys frame329 = Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 0.5f, 1.0f, kFrameColour), w329);
|
||||
const Draw d329 = S2Draw(w329, 0.5f, 1.0f, ps, 2560, 4096, frame2);
|
||||
const Rect r329[1] = { { 1920, 0, 2560, 1440 } }, f329[2] = { { 1920, 0, 2560, 1440 }, { 0, 0, 640, 1440 } };
|
||||
PlanCase("32:9 right bar", d329, right329, Fill::RightBar, 1, r329);
|
||||
PlanCase("32:9 black frame", d329, frame329, Fill::Blackout, 2, f329);
|
||||
// 21:9: the bars reach the screen edge (nothing to fill), the black
|
||||
// frame does not.
|
||||
const Matrix uw = FrontEndMatrix(0.75f, 1.0f);
|
||||
const Draw duw = S2Draw(uw, 0.75f, 1.0f, ps);
|
||||
PlanCase("21:9 right bar", duw, Polys(Rectangle(0.651f, 1.406f, -1.024f, 1.039f, 0.75f, 1.0f, 0xFD000000), uw), Fill::RightBar, 0, nullptr);
|
||||
const Rect fuw[2] = { { 1120, 0, 1280, 720 }, { 0, 0, 160, 720 } };
|
||||
PlanCase("21:9 black frame", duw, Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 0.75f, 1.0f, kFrameColour), uw), Fill::Blackout, 2,
|
||||
fuw);
|
||||
// 16:9 never gets here (the renderer's block needs a view scale
|
||||
// below 1), and a plan there fills nothing.
|
||||
const Matrix m169 = FrontEndMatrix(1.0f, 1.0f);
|
||||
PlanCase("16:9 black frame", S2Draw(m169, 1.0f, 1.0f, ps),
|
||||
Polys(Rectangle(-kFrameX, kFrameX, -kFrameY, kFrameY, 1.0f, 1.0f, kFrameColour), m169), Fill::Blackout, 0, nullptr);
|
||||
// A ppc/ from before the Vert+ hooks reports no front-end y scale
|
||||
// (0): Hor+ still fills (c5 the unnarrowed 0.92), a narrowed view
|
||||
// can't happen there and is refused.
|
||||
Draw old = d329;
|
||||
old.frontEndY = 0.0f;
|
||||
PlanCase("32:9 right bar, old ppc/", old, right329, Fill::RightBar, 1, r329);
|
||||
Draw oldDeck = d;
|
||||
oldDeck.frontEndY = 0.0f;
|
||||
PlanCase("Deck right bar, old ppc/ (no y scale)", oldDeck, rightDeck, Fill::None);
|
||||
// Accepted variants: blending off; c5 negative (its sign was never
|
||||
// checked); a 1x1 t0.
|
||||
Draw v = d;
|
||||
v.blend0 = 0x00010001;
|
||||
PlanCase("blend off", v, rightDeck, Fill::RightBar, 2, right);
|
||||
Matrix neg = deck;
|
||||
neg.c[5] = -neg.c[5];
|
||||
PlanCase("c5 negative", S2Draw(neg, 1.0f, 0.9f, ps), Polys(Rectangle(0.651f, 1.406f, -1.039f, 1.024f, 1.0f, 0.9f, 0xFD000000), neg),
|
||||
Fill::RightBar, 2, right);
|
||||
v = d;
|
||||
v.t0Width = v.t0Height = 1;
|
||||
PlanCase("t0 1x1", v, rightDeck, Fill::RightBar, 2, right);
|
||||
// Each register check refuses on its own (the right bar otherwise).
|
||||
auto refuse = [&](const char* what, auto change) {
|
||||
Draw x = d;
|
||||
Matrix m = deck;
|
||||
change(x, m);
|
||||
x.c = m.c;
|
||||
PlanCase(what, x, rightDeck, Fill::None);
|
||||
};
|
||||
refuse("a triangle list", [](Draw& x, Matrix&) { x.prim = 4; });
|
||||
refuse("two quads", [](Draw& x, Matrix&) { x.count = 8; });
|
||||
refuse("not the front-end shader", [](Draw& x, Matrix&) { x.frontEndShader = false; });
|
||||
refuse("taken by another rule", [](Draw& x, Matrix&) { x.leftAlone = false; });
|
||||
refuse("inside an occlusion query", [](Draw& x, Matrix&) { x.occlusion = true; });
|
||||
refuse("x translation c3", [](Draw&, Matrix& m) { m.c[3] = 0.01f; });
|
||||
refuse("c4", [](Draw&, Matrix& m) { m.c[4] = 0.01f; });
|
||||
refuse("c6", [](Draw&, Matrix& m) { m.c[6] = 0.01f; });
|
||||
refuse("y translation c7", [](Draw&, Matrix& m) { m.c[7] = 0.01f; });
|
||||
refuse("c5 not the front end's y scale", [](Draw& x, Matrix&) { x.frontEndY = 0.83f; });
|
||||
refuse("VTE x scale off", [](Draw& x, Matrix&) { x.vte = 0x43E; });
|
||||
refuse("VTE x offset off", [](Draw& x, Matrix&) { x.vte = 0x43D; });
|
||||
refuse("VTE y scale off", [](Draw& x, Matrix&) { x.vte = 0x43B; });
|
||||
refuse("VTE y offset off", [](Draw& x, Matrix&) { x.vte = 0x437; });
|
||||
refuse("additive blend", [](Draw& x, Matrix&) { x.blend0 = 0x01060106; });
|
||||
refuse("another blend", [](Draw& x, Matrix&) { x.blend0 = 0x07060705; });
|
||||
refuse("t0 not sampled", [](Draw& x, Matrix&) { x.t0Sampled = false; });
|
||||
refuse("t0 64 wide", [](Draw& x, Matrix&) { x.t0Width = 64; });
|
||||
refuse("t0 64 high", [](Draw& x, Matrix&) { x.t0Height = 64; });
|
||||
// A vertex past the vertex buffer: nothing (the reader's words are
|
||||
// still the bar's, so only the failure refuses).
|
||||
PlanCase("vertex 0 past the buffer", d, rightDeck, Fill::None, 0, nullptr, 0);
|
||||
PlanCase("vertex 3 past the buffer", d, rightDeck, Fill::None, 0, nullptr, 3);
|
||||
// Reads: one for a quad that isn't black (vertex 0's colour), none
|
||||
// when a register check refuses, 17 for a black one.
|
||||
int reads = -1;
|
||||
EPolys grey = rightDeck;
|
||||
for (auto& w : grey.w)
|
||||
w[3] = 0xFD7E7E7E;
|
||||
RunPlan(d, grey, 4, &reads);
|
||||
CHECK(reads == 1, "a grey quad: %d reads, want 1", reads);
|
||||
Draw tri = d;
|
||||
tri.prim = 4;
|
||||
RunPlan(tri, rightDeck, 4, &reads);
|
||||
CHECK(reads == 0, "a refused draw: %d reads, want 0", reads);
|
||||
RunPlan(d, rightDeck, 4, &reads);
|
||||
CHECK(reads == 17, "a bar: %d reads, want 17", reads);
|
||||
}
|
||||
|
||||
printf("\n%s (%d failure%s)\n", g_failures ? "FAILED" : "all passed", g_failures, g_failures == 1 ? "" : "s");
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
// Tests for install/placement.h: where "Choose a folder..." puts the game in a
|
||||
// picked folder, what it refuses, the notes it adds, and what the installer
|
||||
// says about a recorded install that isn't usable. First the pure rules
|
||||
// (PlaceInPicked, PlaceExact, DescribeRecordedInstall) on planted facts, then
|
||||
// the same through the disk (ProbeFolder, CheckPickedFolder, FindGameInstall)
|
||||
// in a scratch folder with a sandbox HOME. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/install_folder_test.cpp runtime/install/{placement,installer,locate,manifest,result,disc_source,sha256}.cpp -o build/install_folder_test
|
||||
// (on macOS add -framework CoreFoundation)
|
||||
// build/install_folder_test [folder]
|
||||
// The disk checks run in a new folder made inside [folder] (default: the
|
||||
// current folder; not /tmp, which is in memory on SteamOS and refused), and
|
||||
// only that one is deleted afterwards.
|
||||
// INSTALL_FOLDER_TEST_ICLOUD=<a folder in iCloud Drive> (macOS) also checks
|
||||
// that ProbeFolder sees it as such.
|
||||
#include <install/installer.h>
|
||||
#include <install/locate.h>
|
||||
#include <install/placement.h>
|
||||
#include <user/paths.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using namespace install;
|
||||
|
||||
static bool Has(const std::string& text, const char* part)
|
||||
{
|
||||
return text.find(part) != std::string::npos;
|
||||
}
|
||||
|
||||
static FolderFacts Facts(const char* path, FolderState state, FolderState subfolder = FolderState::Missing)
|
||||
{
|
||||
FolderFacts f;
|
||||
f.path = path;
|
||||
f.state = state;
|
||||
f.subfolder.state = subfolder;
|
||||
f.fileSystem = "ext4";
|
||||
return f;
|
||||
}
|
||||
|
||||
static PlacementContext Context(bool macOS = false)
|
||||
{
|
||||
PlacementContext c;
|
||||
c.home = macOS ? "/Users/p" : "/home/p";
|
||||
c.bundle = macOS ? "/Applications/SpeedBreaker.app" : "/home/p/Games/SpeedBreaker";
|
||||
c.installs = { macOS ? "/Users/p/Library/Application Support/SpeedBreaker/game" : "/home/p/.local/share/speedbreaker/game",
|
||||
"/run/media/p/SD/speedbreaker/game" };
|
||||
c.largestFile = 653262848; // the real manifest's NFS/ZZDATA7.BIN
|
||||
c.macOS = macOS;
|
||||
return c;
|
||||
}
|
||||
|
||||
static void PureRules()
|
||||
{
|
||||
const PlacementContext onLinux = Context(false), mac = Context(true);
|
||||
const std::string sub = std::string("/") + kPickedSubfolder;
|
||||
|
||||
// An empty or new folder is used as it is; any other gets the subfolder.
|
||||
FolderChoice c = PlaceInPicked(Facts("/home/p/Games2", FolderState::Empty), onLinux);
|
||||
CHECK(c.result.Ok() && c.gameDir == "/home/p/Games2" && c.subfolder == SubfolderReason::None, "empty: as is: %s %s",
|
||||
c.gameDir.c_str(), c.result.message.c_str());
|
||||
CHECK(SubfolderExplanation(c).empty(), "nothing to explain");
|
||||
c = PlaceInPicked(Facts("/home/p/Games", FolderState::Other), onLinux);
|
||||
CHECK(c.result.Ok() && c.gameDir == "/home/p/Games" + sub && c.subfolder == SubfolderReason::NotEmpty,
|
||||
"not empty: subfolder: %s", c.gameDir.c_str());
|
||||
CHECK(Has(SubfolderExplanation(c), "Games already holds other files"), "%s", SubfolderExplanation(c).c_str());
|
||||
CHECK(c.picked == "/home/p/Games", "picked kept: %s", c.picked.c_str());
|
||||
c = PlaceInPicked(Facts("/home/p/Games/", FolderState::Other), onLinux);
|
||||
CHECK(c.gameDir == "/home/p/Games" + sub, "a trailing slash: %s", c.gameDir.c_str());
|
||||
// The subfolder must be new, empty or a previous install.
|
||||
for (FolderState ok : { FolderState::Missing, FolderState::Empty, FolderState::Install })
|
||||
{
|
||||
c = PlaceInPicked(Facts("/home/p/Games", FolderState::Other, ok), onLinux);
|
||||
CHECK(c.result.Ok(), "subfolder state %d is fine: %s", int(ok), c.result.message.c_str());
|
||||
}
|
||||
c = PlaceInPicked(Facts("/home/p/Games", FolderState::Other, FolderState::Other), onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty && Has(c.result.message, kPickedSubfolder), "subfolder with other files: %s",
|
||||
c.result.message.c_str());
|
||||
c = PlaceInPicked(Facts("/home/p/Games", FolderState::Other, FolderState::NotAFolder), onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty && Has(c.result.message, "is a file"), "a file named like the subfolder");
|
||||
c = PlaceInPicked(Facts("/home/p/Games", FolderState::Other, FolderState::Unreadable), onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable, "an unreadable subfolder");
|
||||
// A previous install is replaced where it is.
|
||||
c = PlaceInPicked(Facts("/home/p/old", FolderState::Install), onLinux);
|
||||
CHECK(c.result.Ok() && c.gameDir == "/home/p/old" && c.subfolder == SubfolderReason::None, "previous install: as is");
|
||||
|
||||
// Empty, but not usable as it is. The top of a drive gets the drive's
|
||||
// own install folder, the one its entry on the To row uses (InstallDestinations).
|
||||
const fs::path drive = fs::path(kUserFolderName) / "game";
|
||||
CHECK(SubfolderName(true) == drive && SubfolderName(false) == kPickedSubfolder, "subfolder names: %s, %s",
|
||||
SubfolderName(true).c_str(), SubfolderName(false).c_str());
|
||||
FolderFacts f = Facts("/run/media/p/USB", FolderState::Empty);
|
||||
f.driveTop = true;
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.Ok() && c.gameDir == "/run/media/p/USB" / drive && c.subfolder == SubfolderReason::DriveTop, "drive top: %s",
|
||||
c.gameDir.c_str());
|
||||
CHECK(Has(SubfolderExplanation(c), "top of the drive USB"), "%s", SubfolderExplanation(c).c_str());
|
||||
f.state = FolderState::Install; // even holding an install: a drive's top can't be renamed
|
||||
CHECK(PlaceInPicked(f, onLinux).gameDir == "/run/media/p/USB" / drive, "drive top with an install");
|
||||
f.state = FolderState::Other;
|
||||
f.subfolder.state = FolderState::Other;
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty && Has(c.result.message, drive.c_str()), "the drive's folder taken: %s",
|
||||
c.result.message.c_str());
|
||||
f.subfolder.state = FolderState::Install;
|
||||
CHECK(PlaceInPicked(f, onLinux).result.Ok(), "the drive's folder holding an install: replaced");
|
||||
f = Facts("/home/p", FolderState::Empty);
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.Ok() && c.gameDir == "/home/p" + sub && c.subfolder == SubfolderReason::Home, "home, even empty: %s",
|
||||
c.gameDir.c_str());
|
||||
c = PlaceInPicked(Facts("/home/p", FolderState::Other), onLinux);
|
||||
CHECK(c.gameDir == "/home/p" + sub && c.subfolder == SubfolderReason::Home, "home: subfolder");
|
||||
f = Facts("/mnt/data/game", FolderState::Empty);
|
||||
f.parentWritable = false;
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.Ok() && c.gameDir == "/mnt/data/game" + sub && c.subfolder == SubfolderReason::ParentReadOnly,
|
||||
"parent read-only: subfolder: %s", c.gameDir.c_str());
|
||||
CHECK(Has(SubfolderExplanation(c), "can't write next to game"), "%s", SubfolderExplanation(c).c_str());
|
||||
// Hidden files only, and macOS won't let it be moved aside ("deny
|
||||
// delete" in its ACL: fresh ~/Music, ~/Pictures): Install would fail
|
||||
// after the copy, so a subfolder. Truly empty, it is replaced (that works).
|
||||
f = Facts("/Users/p/Music", FolderState::Empty);
|
||||
f.hidden = true;
|
||||
f.movable = false;
|
||||
c = PlaceInPicked(f, Context(true));
|
||||
CHECK(c.result.Ok() && c.gameDir == "/Users/p/Music" + sub && c.subfolder == SubfolderReason::KeptInPlace,
|
||||
"hidden files, can't be moved: subfolder: %s %s", c.gameDir.c_str(), c.result.message.c_str());
|
||||
CHECK(Has(SubfolderExplanation(c), "macOS keeps Music in place"), "%s", SubfolderExplanation(c).c_str());
|
||||
f.movable = true;
|
||||
CHECK(PlaceInPicked(f, Context(true)).gameDir == "/Users/p/Music", "hidden files, movable: as is");
|
||||
f.hidden = false;
|
||||
f.movable = false;
|
||||
CHECK(PlaceInPicked(f, Context(true)).gameDir == "/Users/p/Music", "truly empty, deny delete: as is (replaced)");
|
||||
f.hidden = true;
|
||||
c = PlaceExact(f, true, Context(true));
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "keeps /Users/p/Music in place"),
|
||||
"exact (--dest), hidden files, can't be moved: %s", c.result.message.c_str());
|
||||
|
||||
// A folder that is there but can't be written: Install replaces it or
|
||||
// moves it aside, which macOS refuses after the whole copy.
|
||||
f = Facts("/home/p/ro", FolderState::Empty);
|
||||
f.writable = false;
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "can't write to /home/p/ro."),
|
||||
"empty, not writable: %s", c.result.message.c_str());
|
||||
f.state = FolderState::Install;
|
||||
CHECK(PlaceInPicked(f, onLinux).result.error == Error::DestinationNotWritable, "a previous install, not writable");
|
||||
CHECK(PlaceExact(f, true, onLinux).result.error == Error::DestinationNotWritable, "exact, not writable");
|
||||
f.state = FolderState::Other; // the subfolder is made in it: CheckDestination's to refuse
|
||||
CHECK(PlaceInPicked(f, onLinux).result.Ok(), "not empty, not writable: placement leaves it to CheckDestination");
|
||||
f.writable = true;
|
||||
f.subfolder.state = FolderState::Empty;
|
||||
f.subfolder.writable = false;
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, kPickedSubfolder), "the subfolder, not "
|
||||
"writable: %s", c.result.message.c_str());
|
||||
f.subfolder.state = FolderState::Missing;
|
||||
CHECK(PlaceInPicked(f, onLinux).result.Ok(), "a missing subfolder's writability doesn't count");
|
||||
|
||||
// A link where the subfolder goes would take the game elsewhere.
|
||||
f = Facts("/home/p/Games", FolderState::Other, FolderState::Empty);
|
||||
f.subfolder.link = true;
|
||||
f.subfolder.target = "/home/p/Games/SpeedBreaker/inside";
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty && Has(c.result.message, "is a link to /home/p/Games/SpeedBreaker/inside"),
|
||||
"a link as the subfolder: %s", c.result.message.c_str());
|
||||
// One that leads nowhere says where it points, not that it's a link to itself.
|
||||
f = Facts("/home/p/Games", FolderState::Other, FolderState::Missing);
|
||||
f.subfolder.link = true;
|
||||
f.subfolder.dangling = true;
|
||||
f.subfolder.target = "/home/p/gone";
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty && Has(c.result.message, "is a link to /home/p/gone, which isn't there")
|
||||
&& !Has(c.result.message, "Choose that folder itself"), "a link leading nowhere: %s", c.result.message.c_str());
|
||||
f.subfolder.target.clear();
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty && Has(c.result.message, "a link that leads nowhere"),
|
||||
"a link leading nowhere, target unknown: %s", c.result.message.c_str());
|
||||
// One to a file: not "choose that folder".
|
||||
f = Facts("/home/p/Games", FolderState::Other, FolderState::NotAFolder);
|
||||
f.subfolder.link = true;
|
||||
f.subfolder.target = "/home/p/notes.txt";
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty && Has(c.result.message, "is a link to a file, /home/p/notes.txt")
|
||||
&& !Has(c.result.message, "Choose that folder itself"), "a link to a file: %s", c.result.message.c_str());
|
||||
|
||||
// What the picker can return but can't be used.
|
||||
c = PlaceInPicked(Facts("/home/p/gone", FolderState::Missing), onLinux);
|
||||
CHECK(c.result.error == Error::NotFound, "missing");
|
||||
c = PlaceInPicked(Facts("/home/p/file.txt", FolderState::NotAFolder), onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty, "a file");
|
||||
f = Facts("/Users/p/Documents/x", FolderState::Unreadable);
|
||||
f.error = EPERM;
|
||||
c = PlaceInPicked(f, mac);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "Privacy & Security"), "macOS EPERM: %s",
|
||||
c.result.message.c_str());
|
||||
f.error = EACCES;
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "can't read"), "EACCES: %s", c.result.message.c_str());
|
||||
|
||||
// Never: the system's root, SpeedBreaker's own folder or inside it, inside an install.
|
||||
f = Facts("/", FolderState::Other);
|
||||
f.driveTop = true;
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "root folder"), "/: %s", c.result.message.c_str());
|
||||
c = PlaceInPicked(Facts("/home/p/Games/SpeedBreaker", FolderState::Other), onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "/home/p/Games/SpeedBreaker is SpeedBreaker's own folder")
|
||||
&& !Has(c.result.message, kPickedSubfolder), "the bundle (named as picked, not its subfolder): %s", c.result.message.c_str());
|
||||
c = PlaceInPicked(Facts("/home/p/Games/SpeedBreaker/lib/x", FolderState::Empty), onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "inside SpeedBreaker's own folder, /home/p/Games/SpeedBreaker."),
|
||||
"inside the bundle: %s", c.result.message.c_str());
|
||||
c = PlaceInPicked(Facts("/home/p/Games/SpeedBreakerX", FolderState::Empty), onLinux);
|
||||
CHECK(c.result.Ok(), "a sibling whose name starts like the bundle's: %s", c.result.message.c_str());
|
||||
c = PlaceInPicked(Facts("/Applications/SpeedBreaker.app/Contents", FolderState::Other), mac);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "inside the SpeedBreaker app"), "the Mac app: %s",
|
||||
c.result.message.c_str());
|
||||
c = PlaceInPicked(Facts("/home/p/.local/share/speedbreaker/game/NFS", FolderState::Other), onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "/game/NFS is inside the game installed in")
|
||||
&& !Has(c.result.message, kPickedSubfolder), "inside the default install: %s", c.result.message.c_str());
|
||||
c = PlaceInPicked(Facts("/run/media/p/SD/speedbreaker/game/Movies", FolderState::Empty), onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable, "inside the recorded install");
|
||||
// The install folder itself, holding other files (the game would go in a
|
||||
// new folder inside it): not "inside" itself.
|
||||
c = PlaceInPicked(Facts("/home/p/.local/share/speedbreaker/game", FolderState::Other), onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable
|
||||
&& Has(c.result.message, "/home/p/.local/share/speedbreaker/game is the game's own install folder")
|
||||
&& !Has(c.result.message, "inside the game installed"), "the install folder itself, not an install: %s",
|
||||
c.result.message.c_str());
|
||||
c = PlaceInPicked(Facts("/home/p/.local/share/speedbreaker/game", FolderState::Install), onLinux);
|
||||
CHECK(c.result.Ok() && c.gameDir == "/home/p/.local/share/speedbreaker/game", "the install itself: replaced: %s", c.result.message.c_str());
|
||||
|
||||
// Drives formatted for Windows: fine, with the right word about them.
|
||||
f = Facts("/run/media/p/CARD", FolderState::Other);
|
||||
f.fileSystem = "vfat";
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.Ok() && c.notes.size() == 1 && Has(c.notes[0], "FAT32") && Has(c.notes[0], "only reads"), "FAT32: %s",
|
||||
c.notes.empty() ? "no note" : c.notes[0].c_str());
|
||||
PlacementContext big = onLinux;
|
||||
big.largestFile = 5000000000ull;
|
||||
c = PlaceInPicked(f, big);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "4 GB"), "FAT32 and a 5 GB file: %s",
|
||||
c.result.message.c_str());
|
||||
big.largestFile = (1ull << 32) - 1;
|
||||
CHECK(PlaceInPicked(f, big).result.Ok(), "FAT32 holds 4 GiB - 1 byte");
|
||||
big.largestFile = 1ull << 32;
|
||||
CHECK(PlaceInPicked(f, big).result.error == Error::DestinationNotWritable, "but not 4 GiB");
|
||||
PlacementContext steamOS = onLinux;
|
||||
steamOS.steamOS = true;
|
||||
f.fileSystem = "exfat";
|
||||
c = PlaceInPicked(f, steamOS);
|
||||
CHECK(c.result.Ok() && c.notes.size() == 1 && Has(c.notes[0], "exFAT") && Has(c.notes[0], "Game Mode"), "exFAT on SteamOS: %s",
|
||||
c.notes.empty() ? "no note" : c.notes[0].c_str());
|
||||
f.fileSystem = "fuseblk";
|
||||
c = PlaceInPicked(f, onLinux);
|
||||
CHECK(c.result.Ok() && c.notes.size() == 1 && Has(c.notes[0], "NTFS"), "ntfs-3g");
|
||||
f.fileSystem = "ext4";
|
||||
CHECK(PlaceInPicked(f, steamOS).notes.empty(), "ext4: nothing to say");
|
||||
// In memory: gone at the next restart.
|
||||
f = Facts("/tmp/games", FolderState::Empty);
|
||||
f.fileSystem = "tmpfs";
|
||||
c = PlaceInPicked(f, steamOS);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "/tmp/games is kept in memory (tmpfs)")
|
||||
&& Has(c.result.message, "SteamOS restarts"), "tmpfs: %s", c.result.message.c_str());
|
||||
f.state = FolderState::Missing;
|
||||
CHECK(PlaceExact(f, true, onLinux).result.error == Error::DestinationNotWritable, "tmpfs, exact (--dest, made on it)");
|
||||
CHECK(IsMemoryFileSystem("ramfs") && !IsMemoryFileSystem("ext4") && !IsMemoryFileSystem(""), "memory file systems");
|
||||
// --dest into a folder not there yet gets the notes of the drive it would be made on.
|
||||
f.fileSystem = "exfat";
|
||||
c = PlaceExact(f, true, steamOS);
|
||||
CHECK(c.result.Ok() && c.notes.size() == 1 && Has(c.notes[0], "exFAT"), "--dest new on exFAT: a note (%zu)", c.notes.size());
|
||||
f = Facts("/Volumes/WIN", FolderState::Other);
|
||||
f.driveTop = true;
|
||||
f.fileSystem = "ntfs";
|
||||
c = PlaceInPicked(f, mac);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "macOS can only read"), "NTFS on macOS: %s",
|
||||
c.result.message.c_str());
|
||||
CHECK(IsWindowsFileSystem("msdos") && IsWindowsFileSystem("ntfs3") && !IsWindowsFileSystem("apfs") && !IsWindowsFileSystem("btrfs"),
|
||||
"Windows formats");
|
||||
|
||||
// macOS asks before an app uses Desktop, Documents, Downloads or another drive.
|
||||
f = Facts("/Users/p/Documents/Games", FolderState::Other);
|
||||
f.fileSystem = "apfs";
|
||||
c = PlaceInPicked(f, mac);
|
||||
CHECK(c.result.Ok() && c.notes.size() == 1 && Has(c.notes[0], "Allow"), "Documents: a privacy note");
|
||||
f.path = "/Users/p/Games";
|
||||
CHECK(PlaceInPicked(f, mac).notes.empty(), "~/Games: no note");
|
||||
f.path = "/Volumes/USB";
|
||||
f.driveTop = true;
|
||||
f.fileSystem = "exfat";
|
||||
c = PlaceInPicked(f, mac);
|
||||
CHECK(c.result.Ok() && c.gameDir == "/Volumes/USB" / drive && c.notes.size() == 2, "an exFAT drive on the Mac: %s, %zu notes",
|
||||
c.gameDir.c_str(), c.notes.size());
|
||||
f = Facts("/home/p/Documents/Games", FolderState::Other);
|
||||
CHECK(PlaceInPicked(f, onLinux).notes.empty(), "no privacy note on Linux");
|
||||
|
||||
// macOS: never into iCloud Drive or another cloud folder (the system's
|
||||
// word, or ~/Library's cloud folders); elsewhere there's no such thing.
|
||||
f = Facts("/Users/p/Documents/Games", FolderState::Other);
|
||||
f.fileSystem = "apfs";
|
||||
f.cloud = true;
|
||||
c = PlaceInPicked(f, mac);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "/Users/p/Documents/Games is kept in iCloud Drive")
|
||||
&& !Has(c.result.message, kPickedSubfolder), "iCloud (Documents kept there): %s", c.result.message.c_str());
|
||||
CHECK(PlaceExact(f, true, mac).result.error == Error::DestinationNotWritable, "iCloud, exact (--dest)");
|
||||
f.cloud = false;
|
||||
for (const char* path : { "/Users/p/Library/Mobile Documents/com~apple~CloudDocs/Games", "/Users/p/Library/CloudStorage/Dropbox/Games" })
|
||||
{
|
||||
f.path = path;
|
||||
c = PlaceInPicked(f, mac);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "cloud"), "%s: %s", path, c.result.message.c_str());
|
||||
}
|
||||
f.path = "/Users/p/Library/Mobile Documents/com~apple~CloudDocs/Games";
|
||||
f.cloud = true;
|
||||
CHECK(PlaceInPicked(f, onLinux).result.Ok(), "no cloud rule off macOS");
|
||||
|
||||
// The recorded folder, offered again as it is.
|
||||
FolderFacts g = Facts("/run/media/p/SD2/games/sb", FolderState::Missing);
|
||||
c = PlaceExact(g, false, onLinux);
|
||||
CHECK(c.result.error == Error::NotFound && Has(c.result.message, "isn't there now"), "a drive that isn't connected: %s",
|
||||
c.result.message.c_str());
|
||||
c = PlaceExact(g, true, onLinux);
|
||||
CHECK(c.result.Ok() && c.gameDir == g.path, "missing, but its folder is there: made again");
|
||||
c = PlaceExact(Facts("/home/p", FolderState::Other), true, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "home folder"), "home: %s", c.result.message.c_str());
|
||||
c = PlaceExact(Facts("/home/p/Games/SpeedBreaker/game", FolderState::Missing), true, onLinux);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable, "inside the bundle");
|
||||
g = Facts("/run/media/p/USB", FolderState::Install);
|
||||
g.driveTop = true;
|
||||
CHECK(PlaceExact(g, true, onLinux).result.error == Error::DestinationNotWritable, "a whole drive");
|
||||
|
||||
// IsAtOrInside.
|
||||
CHECK(IsAtOrInside("/a/b", "/a") && IsAtOrInside("/a", "/a") && IsAtOrInside("/a/", "/a") && IsAtOrInside("/a/b/../c", "/a"),
|
||||
"inside");
|
||||
CHECK(!IsAtOrInside("/ab", "/a") && !IsAtOrInside("/", "/a") && !IsAtOrInside("/a/../b", "/a") && !IsAtOrInside("", "/a"),
|
||||
"outside");
|
||||
|
||||
// The hint when the folder picker can't be shown: one line, with the command.
|
||||
std::string hint = FolderPickerHint(true, false, true, "~/Games/SpeedBreaker/speedbreaker.sh", "~/Downloads/nfsmw.iso");
|
||||
CHECK(Has(hint, "Game Mode") && Has(hint, "Desktop Mode") &&
|
||||
Has(hint, "~/Games/SpeedBreaker/speedbreaker.sh --install ~/Downloads/nfsmw.iso --dest <folder>") && !Has(hint, "\n"),
|
||||
"Game Mode: %s", hint.c_str());
|
||||
hint = FolderPickerHint(false, false, true, "speedbreaker.sh", "<image>");
|
||||
CHECK(Has(hint, "portal or zenity") && !Has(hint, "Game Mode") && Has(hint, "--dest <folder>"), "no dialog: %s", hint.c_str());
|
||||
// A picker that failed some other way (zenity refusing an option, say)
|
||||
// doesn't blame a missing one.
|
||||
hint = FolderPickerHint(false, false, false, "speedbreaker.sh", "<image>");
|
||||
CHECK(!Has(hint, "zenity") && Has(hint, "couldn't open") && Has(hint, "--dest <folder>"), "dialog failed: %s", hint.c_str());
|
||||
hint = FolderPickerHint(true, true, true, "/Applications/SpeedBreaker.app/Contents/MacOS/SpeedBreaker", "~/Games/nfsmw.iso");
|
||||
CHECK(Has(hint, "Terminal") && !Has(hint, "Game Mode") && Has(hint, "SpeedBreaker.app/Contents/MacOS/SpeedBreaker --install"),
|
||||
"macOS: %s", hint.c_str());
|
||||
|
||||
// What the installer says about a recorded install it passed over.
|
||||
RecordedInstall r;
|
||||
r.path = "/run/media/p/SD/x";
|
||||
CHECK(DescribeRecordedInstall(r, "~/x", false).empty(), "none");
|
||||
r.state = RecordedState::Usable;
|
||||
CHECK(DescribeRecordedInstall(r, "~/x", false).empty(), "usable");
|
||||
r.state = RecordedState::Missing;
|
||||
std::string text = DescribeRecordedInstall(r, "/run/media/p/SD/x", false);
|
||||
CHECK(Has(text, "/run/media/p/SD/x") && Has(text, "isn't there now") && Has(text, "connect it"), "missing: %s", text.c_str());
|
||||
r.state = RecordedState::NotAllowed;
|
||||
r.error = EPERM;
|
||||
CHECK(Has(DescribeRecordedInstall(r, "~/Documents/g", true), "Privacy & Security"), "macOS privacy");
|
||||
CHECK(Has(DescribeRecordedInstall(r, "~/g", false), "permissions"), "Linux permissions");
|
||||
r.state = RecordedState::NotCurrent;
|
||||
CHECK(Has(DescribeRecordedInstall(r, "~/g", false), "incomplete or from an older version"), "not current");
|
||||
}
|
||||
|
||||
// --- Through the disk -------------------------------------------------------
|
||||
|
||||
static void Write(const fs::path& file, const std::string& text)
|
||||
{
|
||||
fs::create_directories(file.parent_path());
|
||||
std::ofstream(file, std::ios::binary) << text;
|
||||
}
|
||||
|
||||
static void DiskRules(const fs::path& root, const char* argv0)
|
||||
{
|
||||
// A sandbox HOME, so the default install and the record are in the scratch folder.
|
||||
fs::path home = root / "home";
|
||||
fs::create_directories(home);
|
||||
setenv("HOME", home.c_str(), 1);
|
||||
unsetenv("XDG_DATA_HOME");
|
||||
unsetenv("NFSMW_GAME_DIR");
|
||||
SetProgramPath(argv0);
|
||||
chdir(root.c_str()); // no ./game/files here for FindGameInstall to find first
|
||||
|
||||
// A small manifest: one default.xex of 16 bytes.
|
||||
static const ManifestFile files[] = { { "default.xex", 16, "00" } };
|
||||
Manifest manifest{ "test-1", 0x454107D9, files, 16 };
|
||||
Manifest huge = manifest;
|
||||
huge.totalBytes = 1ull << 62;
|
||||
auto marker = [&](const fs::path& dir) {
|
||||
Write(dir / "default.xex", std::string(16, 'x'));
|
||||
Write(dir / kInstallMarker, "manifest_version = \"test-1\"\n");
|
||||
};
|
||||
const std::string sub = kPickedSubfolder;
|
||||
|
||||
// The file system is named (mountinfo on Linux, statfs on macOS); the
|
||||
// context is this machine's (printed: it differs from host to host).
|
||||
FolderFacts probed = ProbeFolder(root);
|
||||
CHECK(probed.state == FolderState::Other && !probed.fileSystem.empty(), "the scratch folder (holding home/): state %d, "
|
||||
"file system '%s'", int(probed.state), probed.fileSystem.c_str());
|
||||
CHECK(!probed.cloud, "the scratch folder isn't in iCloud Drive");
|
||||
PlacementContext context = CurrentPlacementContext(manifest);
|
||||
printf("[context] file system %s, steamOS %d, macOS %d, bundle %s, home %s\n", probed.fileSystem.c_str(), context.steamOS,
|
||||
context.macOS, context.bundle.c_str(), context.home.c_str());
|
||||
// A folder known to be in iCloud Drive (macOS, by hand: it reads only the
|
||||
// folder's own metadata), and one known not to be.
|
||||
if (const char* cloud = std::getenv("INSTALL_FOLDER_TEST_ICLOUD"); cloud && *cloud)
|
||||
{
|
||||
FolderFacts f = ProbeFolder(cloud);
|
||||
CHECK(f.cloud, "%s is in iCloud Drive", cloud);
|
||||
FolderFacts g = ProbeFolder(fs::path(cloud) / "no-such-folder/below");
|
||||
CHECK(g.cloud, "and a folder that isn't there yet below it");
|
||||
printf("[context] %s: in iCloud Drive %d (a missing folder below it: %d)\n", cloud, f.cloud, g.cloud);
|
||||
}
|
||||
|
||||
fs::create_directories(root / "empty");
|
||||
FolderChoice c = CheckPickedFolder(root / "empty", manifest);
|
||||
CHECK(c.result.Ok() && c.gameDir == fs::canonical(root / "empty") && c.info.required > 16, "empty: as is: %s %s",
|
||||
c.gameDir.c_str(), c.result.message.c_str());
|
||||
Write(root / "hidden/.DS_Store", "x");
|
||||
c = CheckPickedFolder(root / "hidden", manifest);
|
||||
CHECK(c.result.Ok() && c.subfolder == SubfolderReason::None, "only hidden files: as is");
|
||||
Write(root / "full/notes.txt", "x");
|
||||
c = CheckPickedFolder(root / "full", manifest);
|
||||
CHECK(c.result.Ok() && c.gameDir == fs::canonical(root / "full") / sub, "not empty: subfolder: %s", c.gameDir.c_str());
|
||||
Write(root / "full2/notes.txt", "x");
|
||||
Write(root / "full2" / sub / "other.txt", "x");
|
||||
c = CheckPickedFolder(root / "full2", manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty, "subfolder not empty: %s", c.result.message.c_str());
|
||||
Write(root / "full3/notes.txt", "x");
|
||||
marker(root / "full3" / sub);
|
||||
c = CheckPickedFolder(root / "full3", manifest);
|
||||
CHECK(c.result.Ok() && c.info.replacing, "subfolder holding a previous install: replaced");
|
||||
marker(root / "prev");
|
||||
c = CheckPickedFolder(root / "prev", manifest);
|
||||
CHECK(c.result.Ok() && c.gameDir == fs::canonical(root / "prev") && c.info.replacing, "a previous install: replaced in place");
|
||||
c = CheckPickedFolder(root / "nothing-here", manifest);
|
||||
CHECK(c.result.error == Error::NotFound, "missing");
|
||||
Write(root / "file", "x");
|
||||
c = CheckPickedFolder(root / "file", manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty, "a file");
|
||||
fs::create_directories(root / "empty2");
|
||||
fs::create_directory_symlink(root / "empty2", root / "link");
|
||||
c = CheckPickedFolder(root / "link", manifest);
|
||||
CHECK(c.result.Ok() && c.gameDir == fs::canonical(root / "empty2"), "a link: resolved: %s", c.gameDir.c_str());
|
||||
c = CheckPickedFolder(root / "empty", huge);
|
||||
CHECK(c.result.error == Error::NotEnoughSpace, "free space: %s", c.result.message.c_str());
|
||||
|
||||
// A link named like the subfolder (here into a folder the rules would
|
||||
// refuse): the rules checked the link's path, and Install followed it.
|
||||
Write(root / "linked/notes.txt", "x");
|
||||
fs::create_directories(home / "Library/Mobile Documents/elsewhere");
|
||||
fs::create_directory_symlink(home / "Library/Mobile Documents/elsewhere", root / "linked" / sub);
|
||||
c = CheckPickedFolder(root / "linked", manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty && Has(c.result.message, "is a link to"), "a link as the subfolder: %s",
|
||||
c.result.message.c_str());
|
||||
fs::remove(root / "linked" / sub);
|
||||
fs::create_directories(root / "linked" / sub);
|
||||
CHECK(CheckPickedFolder(root / "linked", manifest).result.Ok(), "the same, a real folder: fine");
|
||||
// A link named like the subfolder that leads nowhere (absolute, and
|
||||
// relative to its folder): named by where it points.
|
||||
Write(root / "dangle/notes.txt", "x");
|
||||
fs::create_directory_symlink(root / "nowhere", root / "dangle" / sub);
|
||||
FolderFacts df = ProbeFolder(root / "dangle");
|
||||
CHECK(df.subfolder.link && df.subfolder.dangling && df.subfolder.target == root / "nowhere", "a link leading nowhere: "
|
||||
"probed: link %d dangling %d target %s", df.subfolder.link, df.subfolder.dangling, df.subfolder.target.c_str());
|
||||
c = CheckPickedFolder(root / "dangle", manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty && Has(c.result.message, ((root / "nowhere").string() + ", which isn't there").c_str()),
|
||||
"a link leading nowhere: %s", c.result.message.c_str());
|
||||
fs::remove(root / "dangle" / sub);
|
||||
fs::create_directory_symlink("../nowhere2", root / "dangle" / sub);
|
||||
df = ProbeFolder(root / "dangle");
|
||||
CHECK(df.subfolder.dangling && df.subfolder.target == root / "nowhere2", "a relative link leading nowhere: %s",
|
||||
df.subfolder.target.c_str());
|
||||
fs::remove(root / "dangle" / sub);
|
||||
fs::create_directory_symlink(root / "empty2", root / "dangle" / sub);
|
||||
df = ProbeFolder(root / "dangle");
|
||||
CHECK(df.subfolder.link && !df.subfolder.dangling && df.subfolder.target == fs::canonical(root / "empty2"),
|
||||
"a link that leads somewhere isn't dangling: %s", df.subfolder.target.c_str());
|
||||
fs::remove(root / "dangle" / sub);
|
||||
|
||||
// --dest into a folder not there yet: the file system it would be made
|
||||
// on (so its notes and rules apply).
|
||||
FolderFacts fresh = ProbeFolder(root / "not/yet");
|
||||
CHECK(fresh.state == FolderState::Missing && fresh.fileSystem == probed.fileSystem, "a folder not there yet: file system "
|
||||
"'%s' (its drive's: '%s')", fresh.fileSystem.c_str(), probed.fileSystem.c_str());
|
||||
#ifdef __linux__
|
||||
// In memory (tmpfs): refused, and nothing made there.
|
||||
if (ProbeFolder("/dev/shm").fileSystem == "tmpfs")
|
||||
{
|
||||
fs::path shm = fs::path("/dev/shm") / ("install_folder_test." + std::to_string(getpid())) / "game";
|
||||
c = CheckExactFolder(shm, true, manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "kept in memory"), "--dest on tmpfs: %s",
|
||||
c.result.message.c_str());
|
||||
CHECK(!fs::exists(shm.parent_path()), "nothing made on tmpfs");
|
||||
}
|
||||
else
|
||||
printf("[context] /dev/shm isn't tmpfs here: the tmpfs check is the pure one only\n");
|
||||
#endif
|
||||
|
||||
// Permissions (meaningless as root).
|
||||
if (geteuid() != 0)
|
||||
{
|
||||
Write(root / "ro/notes.txt", "x");
|
||||
chmod((root / "ro").c_str(), 0555);
|
||||
c = CheckPickedFolder(root / "ro", manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable, "can't write the picked folder: %s", c.result.message.c_str());
|
||||
chmod((root / "ro").c_str(), 0755);
|
||||
fs::create_directories(root / "rop/e");
|
||||
chmod((root / "rop").c_str(), 0555);
|
||||
c = CheckPickedFolder(root / "rop/e", manifest);
|
||||
CHECK(c.result.Ok() && c.subfolder == SubfolderReason::ParentReadOnly && c.gameDir == fs::canonical(root / "rop/e") / sub,
|
||||
"empty, parent read-only: subfolder: %s %s", c.gameDir.c_str(), c.result.message.c_str());
|
||||
chmod((root / "rop").c_str(), 0755);
|
||||
fs::create_directories(root / "nr");
|
||||
chmod((root / "nr").c_str(), 0);
|
||||
c = CheckPickedFolder(root / "nr", manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable, "unreadable: %s", c.result.message.c_str());
|
||||
chmod((root / "nr").c_str(), 0755);
|
||||
// Empty, but not writable: refused before the copy (macOS refuses
|
||||
// to replace it at the end), picked or exact.
|
||||
fs::create_directories(root / "ro-empty");
|
||||
chmod((root / "ro-empty").c_str(), 0555);
|
||||
c = CheckPickedFolder(root / "ro-empty", manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "can't write to"), "empty, read-only: %s",
|
||||
c.result.message.c_str());
|
||||
c = CheckExactFolder(root / "ro-empty", true, manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable, "--dest empty, read-only: %s", c.result.message.c_str());
|
||||
chmod((root / "ro-empty").c_str(), 0755);
|
||||
}
|
||||
#if defined(__APPLE__)
|
||||
// Hidden files only, with macOS's "deny delete" for everyone (as fresh
|
||||
// ~/Music and ~/Pictures have): Install can't move it aside, so a subfolder.
|
||||
Write(root / "acl/.localized", "");
|
||||
if (system(("chmod +a 'group:everyone deny delete' '" + (root / "acl").string() + "'").c_str()) == 0)
|
||||
{
|
||||
c = CheckPickedFolder(root / "acl", manifest);
|
||||
CHECK(c.result.Ok() && c.subfolder == SubfolderReason::KeptInPlace && c.gameDir == root / "acl" / sub,
|
||||
"hidden files, deny delete: subfolder: %s %s", c.gameDir.c_str(), c.result.message.c_str());
|
||||
system(("chmod -N '" + (root / "acl").string() + "'").c_str());
|
||||
}
|
||||
else
|
||||
CHECK(false, "couldn't set an ACL on %s", (root / "acl").c_str());
|
||||
c = CheckPickedFolder(root / "acl", manifest);
|
||||
CHECK(c.result.Ok() && c.subfolder == SubfolderReason::None, "hidden files, no ACL: as is");
|
||||
#endif
|
||||
|
||||
// Inside the default install, or the recorded one; SpeedBreaker's own folder.
|
||||
Write(DefaultInstallPath() / "NFS/x.bin", "x");
|
||||
c = CheckPickedFolder(DefaultInstallPath() / "NFS", manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "inside the game installed"),
|
||||
"inside the default install: %s", c.result.message.c_str());
|
||||
CHECK(c.gameDir == fs::canonical(DefaultInstallPath() / "NFS") / sub, "(its subfolder)");
|
||||
marker(root / "rec");
|
||||
CHECK(RecordInstallPath(root / "rec"), "record");
|
||||
CHECK(RecordedInstallPath() == fs::absolute(root / "rec").lexically_normal(), "recorded: %s",
|
||||
RecordedInstallPath() ? RecordedInstallPath()->c_str() : "nothing");
|
||||
// The same folder however it is spelled: `--dest dir/` (tab completion),
|
||||
// or through a link. The record is the folder itself.
|
||||
CHECK(RecordInstallPath((root / "rec").string() + "/") && RecordedInstallPath() == root / "rec", "recorded with a "
|
||||
"trailing slash: %s", RecordedInstallPath() ? RecordedInstallPath()->c_str() : "nothing");
|
||||
fs::create_directory_symlink(root / "rec", root / "rec-link");
|
||||
CHECK(SameFolder(root / "rec-link", root / "rec") && SameFolder(root / "rec/", root / "rec") && !SameFolder(root / "rec", root / "prev"),
|
||||
"SameFolder");
|
||||
CHECK(RecordInstallPath(root / "rec-link") && RecordedInstallPath() == root / "rec", "recorded through a link: %s",
|
||||
RecordedInstallPath() ? RecordedInstallPath()->c_str() : "nothing");
|
||||
fs::remove(root / "rec-link");
|
||||
CHECK(RecordInstallPath(DefaultInstallPath().string() + "/") && !RecordedInstallPath(), "the default, with a trailing "
|
||||
"slash: no record");
|
||||
// A record v0.1.0 wrote for `--dest dir/` reads as the folder.
|
||||
Write(DefaultInstallPath().parent_path() / "game-location", (root / "rec").string() + "/\n");
|
||||
CHECK(RecordedInstallPath() == root / "rec", "an old record with a trailing slash: %s",
|
||||
RecordedInstallPath() ? RecordedInstallPath()->c_str() : "nothing");
|
||||
CHECK(RecordInstallPath(root / "rec") && RecordedInstallPath() == root / "rec", "recorded again");
|
||||
fs::create_directories(root / "rec/Movies");
|
||||
c = CheckPickedFolder(root / "rec/Movies", manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable, "inside the recorded install: %s", c.result.message.c_str());
|
||||
fs::path bundle = BundleFolder();
|
||||
CHECK(!bundle.empty() && fs::exists(bundle), "the test's own folder: %s", bundle.c_str());
|
||||
c = CheckPickedFolder(bundle, manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "SpeedBreaker's own folder"),
|
||||
"the program's folder: %s", c.result.message.c_str());
|
||||
|
||||
// The command line's --dest: exactly that folder (missing folders above it
|
||||
// are made), with the same refusals.
|
||||
c = CheckExactFolder(bundle / "game", true, manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "inside SpeedBreaker's own folder"),
|
||||
"--dest in the program's folder: %s", c.result.message.c_str());
|
||||
c = CheckExactFolder(DefaultInstallPath() / "NFS/new", true, manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "inside the game installed"),
|
||||
"--dest inside the default install: %s", c.result.message.c_str());
|
||||
c = CheckExactFolder(home, true, manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotWritable && Has(c.result.message, "home folder"), "--dest home: %s",
|
||||
c.result.message.c_str());
|
||||
c = CheckExactFolder(root / "made/on/demand", true, manifest);
|
||||
CHECK(c.result.Ok() && c.subfolder == SubfolderReason::None && c.gameDir == root / "made/on/demand", "--dest new: %s %s",
|
||||
c.gameDir.c_str(), c.result.message.c_str());
|
||||
c = CheckExactFolder(root / "made/on/demand", false, manifest);
|
||||
CHECK(c.result.error == Error::NotFound, "the same, recorded (its drive gone): %s", c.result.message.c_str());
|
||||
c = CheckExactFolder(root / "full", true, manifest);
|
||||
CHECK(c.result.error == Error::DestinationNotEmpty, "--dest not empty: no subfolder, refused: %s", c.result.message.c_str());
|
||||
|
||||
// The recorded install from the start: usable, then gone, then back.
|
||||
CHECK(CheckRecordedInstall(manifest).state == RecordedState::Usable, "recorded: usable");
|
||||
std::optional<GameInstall> found = FindGameInstall(manifest);
|
||||
CHECK(found && found->path == fs::absolute(root / "rec").lexically_normal() && found->marker, "found where recorded: %s",
|
||||
found ? found->path.c_str() : "nothing");
|
||||
fs::rename(root / "rec", root / "rec-unplugged");
|
||||
CHECK(CheckRecordedInstall(manifest).state == RecordedState::Missing, "recorded: missing");
|
||||
CHECK(!FindGameInstall(manifest), "missing: not found (the installer runs, with the notice)");
|
||||
CHECK(RecordedInstallPath().has_value(), "the record is kept for when the drive is back");
|
||||
c = CheckExactFolder(root / "rec", false, manifest);
|
||||
CHECK(c.result.Ok() && c.gameDir == fs::absolute(root / "rec").lexically_normal(), "offered again: its folder is there");
|
||||
c = CheckExactFolder(root / "unplugged-drive/speedbreaker/game", false, manifest);
|
||||
CHECK(c.result.error == Error::NotFound, "offered again on a drive that isn't there: %s", c.result.message.c_str());
|
||||
CHECK(!fs::exists(root / "unplugged-drive"), "nothing was created where the drive mounts");
|
||||
fs::rename(root / "rec-unplugged", root / "rec");
|
||||
found = FindGameInstall(manifest);
|
||||
CHECK(found && found->path == fs::absolute(root / "rec").lexically_normal(), "back: found again");
|
||||
fs::remove(root / "rec" / kInstallMarker);
|
||||
CHECK(CheckRecordedInstall(manifest).state == RecordedState::NotCurrent, "recorded: not current");
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
PureRules();
|
||||
|
||||
// The disk checks' own new folder, made inside the one given (which is
|
||||
// never emptied or deleted: a mistyped argument must lose nothing).
|
||||
std::error_code ec;
|
||||
fs::path base = argc > 1 ? fs::path(argv[1]) : fs::current_path();
|
||||
fs::create_directories(base, ec);
|
||||
base = fs::weakly_canonical(fs::absolute(base), ec);
|
||||
// The program's own folder is refused (that's a rule under test), so the
|
||||
// scratch folder can't be in it.
|
||||
SetProgramPath(argv[0]);
|
||||
if (IsAtOrInside(base, BundleFolder()))
|
||||
{
|
||||
printf("the folder %s is inside the test's own folder %s: give one outside it\n", base.c_str(), BundleFolder().c_str());
|
||||
return 2;
|
||||
}
|
||||
std::string pattern = (base / "install_folder_test.XXXXXX").string();
|
||||
if (!mkdtemp(pattern.data()))
|
||||
{
|
||||
printf("can't make a folder in %s: %s\n", base.c_str(), strerror(errno));
|
||||
return 2;
|
||||
}
|
||||
fs::path root = fs::canonical(pattern);
|
||||
DiskRules(root, argv[0]);
|
||||
// Leave nothing read-only behind, then clean up (only the folder made here).
|
||||
for (const char* dir : { "ro", "rop", "nr", "ro-empty" })
|
||||
chmod((root / dir).c_str(), 0755);
|
||||
fs::remove_all(root, ec);
|
||||
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// Tests for gpu::memplan (runtime/gpu/memory_plan.h): where guest memory and
|
||||
// the upload ring go on the devices we know. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/memory_plan_test.cpp -o build/memory_plan_test
|
||||
// build/memory_plan_test
|
||||
// The memory types are the devices' own: the Steam Machine's from vulkaninfo
|
||||
// (2026-10-09), the others from their logs' chosen types (flags) and their
|
||||
// drivers' type lists (RADV, NVIDIA's Linux driver, ANV, Turnip, MoltenVK).
|
||||
#include <gpu/memory_plan.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace gpu::memplan;
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
constexpr uint64_t MB = 1ull << 20, GB = 1ull << 30;
|
||||
|
||||
// "name:type,..." (import has no type; a place whose heap is too small for
|
||||
// it is marked "!": tried only as a last resort).
|
||||
static std::string Text(const Plan& p, bool ring)
|
||||
{
|
||||
std::string s;
|
||||
for (const Candidate& c : p.order)
|
||||
{
|
||||
if (!s.empty())
|
||||
s += ",";
|
||||
s += Name(c.place, ring);
|
||||
if (c.place != Place::kImport)
|
||||
s += ":" + std::to_string(c.type);
|
||||
if (!c.fits)
|
||||
s += "!";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static void Expect(const char* what, const Plan& p, bool ring, const char* want, const char* whyHas = nullptr)
|
||||
{
|
||||
std::string got = Text(p, ring);
|
||||
printf("%-58s %s (%s)\n", what, got.c_str(), p.why.c_str());
|
||||
CHECK(got == want, "%s: %s, want %s", what, got.c_str(), want);
|
||||
if (whyHas)
|
||||
CHECK(p.why.find(whyHas) != std::string::npos, "%s: why \"%s\" lacks \"%s\"", what, p.why.c_str(), whyHas);
|
||||
}
|
||||
|
||||
// --- The devices -------------------------------------------------------
|
||||
|
||||
// Steam Machine: RADV NAVI33, discrete, resizable BAR (vulkaninfo).
|
||||
static Memory SteamMachine()
|
||||
{
|
||||
return { { { 0x1, 1 }, { 0x1, 1 }, { 0x6, 0 }, { 0x7, 1 }, { 0x7, 1 }, { 0xE, 0 }, { 0xE, 0 }, { 0xC1, 1 }, { 0xC6, 0 },
|
||||
{ 0xC7, 1 }, { 0xCE, 0 } },
|
||||
{ 16621477888ull, 8573157376ull } };
|
||||
}
|
||||
|
||||
// Steam Deck: RADV VANGOGH, integrated. RADV's type list for an APU (the
|
||||
// log: copy in type 2, flags 6; ring in type 3, flags 7); its device-local
|
||||
// heap's budget reads 5.7 GB, the carve-out is 1 GB: either.
|
||||
static Memory SteamDeck(uint64_t vramHeap)
|
||||
{
|
||||
return { { { 0x1, 1 }, { 0x1, 1 }, { 0x6, 0 }, { 0x7, 1 }, { 0x7, 1 }, { 0xE, 0 }, { 0xE, 0 } }, { 7 * GB, vramHeap } };
|
||||
}
|
||||
|
||||
// Steam Frame: Turnip (Adreno 750), unified memory, no host-memory import
|
||||
// (the log: copy and ring in type 0, flags 7).
|
||||
static Memory SteamFrame()
|
||||
{
|
||||
return { { { 0x7, 0 }, { 0xF, 0 }, { 0xB, 0 } }, { 11 * GB } };
|
||||
}
|
||||
|
||||
// Apple silicon (MoltenVK): private, shared (the log: ring in type 1, flags
|
||||
// F), memoryless.
|
||||
static Memory Apple()
|
||||
{
|
||||
return { { { 0x1, 0 }, { 0xF, 0 }, { 0x11, 0 } }, { 10922 * MB } };
|
||||
}
|
||||
|
||||
// NVIDIA's Linux driver without resizable BAR (GTX 1080 Ti; an RTX 4070
|
||||
// with it off): VRAM, system memory, and the 256 MB BAR window.
|
||||
static Memory NvidiaNoRebar()
|
||||
{
|
||||
return { { { 0x0, 1 }, { 0x1, 0 }, { 0x6, 1 }, { 0xE, 1 }, { 0x7, 2 } }, { 11 * GB, 15 * GB, 256 * MB } };
|
||||
}
|
||||
|
||||
// The same with resizable BAR: the window is all of VRAM.
|
||||
static Memory NvidiaRebar()
|
||||
{
|
||||
return { { { 0x0, 1 }, { 0x1, 0 }, { 0x6, 1 }, { 0xE, 1 }, { 0x7, 2 } }, { 12 * GB, 15 * GB, 12 * GB } };
|
||||
}
|
||||
|
||||
// RADV on a discrete GPU without resizable BAR: VRAM split into the part the
|
||||
// CPU can't map and the 256 MB it can.
|
||||
static Memory RadvNoRebar()
|
||||
{
|
||||
return { { { 0x1, 1 }, { 0x1, 1 }, { 0x6, 0 }, { 0x7, 2 }, { 0x7, 2 }, { 0xE, 0 }, { 0xE, 0 } }, { 15 * GB, 7936 * MB, 256 * MB } };
|
||||
}
|
||||
|
||||
// ANV on Arrow Lake (issue #2's Zenbook): one system-memory heap, every type
|
||||
// device-local (the log: ring in type 0, flags 7).
|
||||
static Memory AnvArrowLake()
|
||||
{
|
||||
return { { { 0x7, 0 }, { 0xF, 0 } }, { 15 * GB } };
|
||||
}
|
||||
|
||||
static GuestInput Input(Gpu gpu, bool importable, uint32_t driver = 0, const char* shared = nullptr, const char* shadow = nullptr)
|
||||
{
|
||||
GuestInput in;
|
||||
in.gpu = gpu;
|
||||
in.importable = importable;
|
||||
in.importRefusal = ImportRefusal(driver);
|
||||
in.overrides = ParseOverrides(shared, shadow, nullptr);
|
||||
return in;
|
||||
}
|
||||
|
||||
// The copy's type when the copy is used, for PlanRing (kNoType: imported).
|
||||
static uint32_t CopyType(const Plan& guest)
|
||||
{
|
||||
return guest.order.front().place == Place::kImport ? kNoType : guest.order.front().type;
|
||||
}
|
||||
|
||||
// --- Tests -------------------------------------------------------------
|
||||
|
||||
// The devices that play today keep exactly what v0.1.0 chose.
|
||||
static void Unchanged()
|
||||
{
|
||||
printf("unchanged:\n");
|
||||
{
|
||||
Memory m = SteamMachine();
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kDiscrete, true));
|
||||
Expect("Steam Machine, guest memory", g, false, "copy-vram:3,import,copy-system:2,copy-cached:5", "resizable BAR");
|
||||
Expect("Steam Machine, ring", PlanRing(m, ~0u, CopyType(g)), true, "ring-vram:3,ring-cached:5,ring-system:2");
|
||||
}
|
||||
for (uint64_t vram : { 1 * GB, 5692 * MB })
|
||||
{
|
||||
Memory m = SteamDeck(vram);
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kIntegrated, true));
|
||||
// RADV refuses the import (the guest's memory is a shared mapping),
|
||||
// so the copy lands in type 2, as the Deck's log says.
|
||||
Expect("Steam Deck, guest memory", g, false, "import,copy-system:2,copy-cached:5,copy-vram:3");
|
||||
Expect("Steam Deck, ring (copy in type 2)", PlanRing(m, ~0u, 2), true, "ring-vram:3,ring-cached:5,ring-system:2");
|
||||
}
|
||||
{
|
||||
Memory m = SteamFrame();
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kIntegrated, false));
|
||||
Expect("Steam Frame, guest memory", g, false, "copy-vram:0", "can't import");
|
||||
Expect("Steam Frame, ring", PlanRing(m, ~0u, CopyType(g)), true, "ring-vram:0,ring-cached:1");
|
||||
}
|
||||
{
|
||||
Memory m = Apple();
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kIntegrated, true, 14)); // VK_DRIVER_ID_MOLTENVK
|
||||
Expect("Apple, guest memory", g, false, "import,copy-vram:1");
|
||||
Expect("Apple, ring", PlanRing(m, ~0u, CopyType(g)), true, "ring-vram:1,ring-cached:1");
|
||||
}
|
||||
// A software rasterizer (llvmpipe: one cached host-visible type).
|
||||
{
|
||||
Memory m{ { { 0xF, 0 } }, { 16 * GB } };
|
||||
Expect("llvmpipe, guest memory", PlanGuestMemory(m, Input(Gpu::kOther, true)), false, "import,copy-vram:0");
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #2, A: a discrete GPU without resizable BAR.
|
||||
static void NoRebar()
|
||||
{
|
||||
printf("no resizable BAR:\n");
|
||||
{
|
||||
Memory m = NvidiaNoRebar();
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kDiscrete, true, 4)); // VK_DRIVER_ID_NVIDIA_PROPRIETARY
|
||||
Expect("NVIDIA 256 MB BAR, guest memory", g, false, "import,copy-system:2,copy-cached:3,copy-vram:4!", "no resizable BAR");
|
||||
// What issue #2's commenter found to work: import, the ring cached.
|
||||
Expect("NVIDIA 256 MB BAR, ring (imported)", PlanRing(m, ~0u, kNoType), true, "ring-cached:3,ring-system:2,ring-vram:4!",
|
||||
"too small");
|
||||
Expect("NVIDIA 256 MB BAR, ring (copy in type 2)", PlanRing(m, ~0u, 2), true, "ring-cached:3,ring-system:2,ring-vram:4!");
|
||||
}
|
||||
{
|
||||
Memory m = NvidiaRebar();
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kDiscrete, true, 4));
|
||||
Expect("NVIDIA resizable BAR, guest memory", g, false, "copy-vram:4,import,copy-system:2,copy-cached:3");
|
||||
Expect("NVIDIA resizable BAR, ring", PlanRing(m, ~0u, CopyType(g)), true, "ring-vram:4,ring-cached:3,ring-system:2");
|
||||
}
|
||||
{
|
||||
Memory m = RadvNoRebar();
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kDiscrete, true));
|
||||
Expect("RADV 256 MB BAR, guest memory", g, false, "import,copy-system:2,copy-cached:5,copy-vram:3!");
|
||||
Expect("RADV 256 MB BAR, ring (copy in type 2)", PlanRing(m, ~0u, 2), true, "ring-cached:5,ring-system:2,ring-vram:3!");
|
||||
}
|
||||
// A 512 MB window: the copy doesn't fit, the ring does.
|
||||
{
|
||||
Memory m = NvidiaNoRebar();
|
||||
m.heaps[2] = 512 * MB;
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kDiscrete, true, 4));
|
||||
Expect("NVIDIA 512 MB BAR, guest memory", g, false, "import,copy-system:2,copy-cached:3,copy-vram:4!");
|
||||
Expect("NVIDIA 512 MB BAR, ring (imported)", PlanRing(m, ~0u, kNoType), true, "ring-vram:4,ring-cached:3,ring-system:2");
|
||||
}
|
||||
// Both in one heap: the copy fits, the ring beside it doesn't.
|
||||
{
|
||||
Memory m = SteamMachine();
|
||||
m.heaps[1] = 1300 * MB; // half: 650 MB, the copy 512, both 704
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kDiscrete, true));
|
||||
Expect("1300 MB BAR, guest memory", g, false, "copy-vram:3,import,copy-system:2,copy-cached:5");
|
||||
Expect("1300 MB BAR, ring beside the copy", PlanRing(m, ~0u, CopyType(g)), true, "ring-cached:5,ring-system:2,ring-vram:3!",
|
||||
"beside the copy");
|
||||
Expect("1300 MB BAR, ring (copy went elsewhere)", PlanRing(m, ~0u, 2), true, "ring-vram:3,ring-cached:5,ring-system:2");
|
||||
}
|
||||
// A driver without host-memory import: the copy in system memory.
|
||||
{
|
||||
Memory m = NvidiaNoRebar();
|
||||
Expect("NVIDIA 256 MB BAR, no import", PlanGuestMemory(m, Input(Gpu::kDiscrete, false, 4)), false,
|
||||
"copy-system:2,copy-cached:3,copy-vram:4!", "can't import");
|
||||
}
|
||||
// No CPU-mapped VRAM at all.
|
||||
{
|
||||
Memory m{ { { 0x1, 0 }, { 0x6, 1 }, { 0xE, 1 } }, { 8 * GB, 16 * GB } };
|
||||
Expect("no CPU-mapped VRAM, guest memory", PlanGuestMemory(m, Input(Gpu::kDiscrete, true)), false,
|
||||
"import,copy-system:1,copy-cached:2", "without CPU-mapped VRAM");
|
||||
Expect("no CPU-mapped VRAM, ring", PlanRing(m, ~0u, kNoType), true, "ring-cached:2,ring-system:1");
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #2, B: Intel's Linux driver loses the device with imported memory.
|
||||
static void Intel()
|
||||
{
|
||||
printf("Intel:\n");
|
||||
CHECK(ImportRefusal(6) != nullptr, "ANV (VK_DRIVER_ID_INTEL_OPEN_SOURCE_MESA) imports");
|
||||
for (uint32_t driver : { 0u, 1u, 3u, 4u, 5u, 13u, 14u }) // unknown, AMD Windows, RADV, NVIDIA, Intel Windows, llvmpipe, MoltenVK
|
||||
CHECK(ImportRefusal(driver) == nullptr, "driver %u refused", driver);
|
||||
Memory m = AnvArrowLake();
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kIntegrated, true, 6));
|
||||
Expect("ANV, guest memory", g, false, "copy-vram:0", "Intel's Linux driver");
|
||||
Expect("ANV, ring", PlanRing(m, ~0u, CopyType(g)), true, "ring-vram:0,ring-cached:1");
|
||||
// NFSMW_SHARED_MEMORY=import still imports (to check a newer kernel).
|
||||
Expect("ANV, NFSMW_SHARED_MEMORY=import", PlanGuestMemory(m, Input(Gpu::kIntegrated, true, 6, "import")), false,
|
||||
"import,copy-vram:0", "NFSMW_SHARED_MEMORY=import");
|
||||
// A discrete Arc card keeps its copy in VRAM with resizable BAR, and
|
||||
// doesn't import without it.
|
||||
Memory arc{ { { 0x1, 0 }, { 0x7, 0 }, { 0x6, 1 }, { 0xE, 1 } }, { 8 * GB, 16 * GB } };
|
||||
Expect("Arc, resizable BAR", PlanGuestMemory(arc, Input(Gpu::kDiscrete, true, 6)), false, "copy-vram:1,copy-system:2,copy-cached:3");
|
||||
arc.heaps[0] = 256 * MB;
|
||||
Expect("Arc, 256 MB BAR", PlanGuestMemory(arc, Input(Gpu::kDiscrete, true, 6)), false, "copy-system:2,copy-cached:3,copy-vram:1!",
|
||||
"Intel's Linux driver");
|
||||
}
|
||||
|
||||
// The environment's overrides still lead, and the default order follows.
|
||||
static void EnvOverrides()
|
||||
{
|
||||
printf("overrides:\n");
|
||||
Memory sm = SteamMachine();
|
||||
Expect("SM, NFSMW_SHARED_MEMORY=import", PlanGuestMemory(sm, Input(Gpu::kDiscrete, true, 0, "import")), false,
|
||||
"import,copy-vram:3,copy-system:2,copy-cached:5");
|
||||
Expect("SM, NFSMW_SHARED_MEMORY=shadow", PlanGuestMemory(sm, Input(Gpu::kDiscrete, true, 0, "shadow")), false,
|
||||
"copy-vram:3,copy-system:2,copy-cached:5");
|
||||
Expect("SM, NFSMW_SHADOW_MEMORY=gtt", PlanGuestMemory(sm, Input(Gpu::kDiscrete, true, 0, nullptr, "gtt")), false,
|
||||
"copy-system:2,import,copy-vram:3,copy-cached:5");
|
||||
Expect("Deck, NFSMW_SHADOW_MEMORY=vram", PlanGuestMemory(SteamDeck(GB), Input(Gpu::kIntegrated, true, 0, nullptr, "vram")), false,
|
||||
"import,copy-vram:3,copy-system:2,copy-cached:5");
|
||||
Expect("Deck, shadow + vram", PlanGuestMemory(SteamDeck(GB), Input(Gpu::kIntegrated, true, 0, "shadow", "vram")), false,
|
||||
"copy-vram:3,copy-system:2,copy-cached:5");
|
||||
// Issue #2's commenter on an NVIDIA card without resizable BAR.
|
||||
Expect("NVIDIA, NFSMW_SHARED_MEMORY=import", PlanGuestMemory(NvidiaNoRebar(), Input(Gpu::kDiscrete, true, 4, "import")), false,
|
||||
"import,copy-system:2,copy-cached:3,copy-vram:4!");
|
||||
// NFSMW_SHADOW_MEMORY=vram where it can't fit: tried first anyway.
|
||||
Expect("NVIDIA, NFSMW_SHADOW_MEMORY=vram", PlanGuestMemory(NvidiaNoRebar(), Input(Gpu::kDiscrete, true, 4, nullptr, "vram")), false,
|
||||
"copy-vram:4!,import,copy-system:2,copy-cached:3");
|
||||
Overrides o = ParseOverrides(nullptr, nullptr, "cached");
|
||||
CHECK(o.ring == Place::kCached, "NFSMW_RING_MEMORY=cached");
|
||||
Expect("SM ring, NFSMW_RING_MEMORY=cached", PlanRing(sm, ~0u, 3, o.ring), true, "ring-cached:5,ring-vram:3,ring-system:2");
|
||||
// MoltenVK has no cached type outside device memory: as before, the
|
||||
// ring goes to its shared type.
|
||||
Expect("Apple ring, NFSMW_RING_MEMORY=cached", PlanRing(Apple(), ~0u, kNoType, Place::kCached), true, "ring-cached:1,ring-vram:1");
|
||||
Expect("NVIDIA ring, NFSMW_RING_MEMORY=vram", PlanRing(NvidiaNoRebar(), ~0u, kNoType, ParseOverrides(nullptr, nullptr, "vram").ring),
|
||||
true, "ring-vram:4!,ring-cached:3,ring-system:2");
|
||||
Expect("SM ring, NFSMW_RING_MEMORY=system", PlanRing(sm, ~0u, 3, ParseOverrides(nullptr, nullptr, "system").ring), true,
|
||||
"ring-system:2,ring-vram:3,ring-cached:5");
|
||||
CHECK(ParseOverrides("nonsense", "nonsense", "nonsense").shared == Overrides::Shared::kAuto, "unknown values are the default");
|
||||
}
|
||||
|
||||
// The test hook (NFSMW_TEST_BAR_HEAP_MB): the Steam Machine plans as a GPU
|
||||
// without resizable BAR, exactly as RADV's split heaps would.
|
||||
static void TestHook()
|
||||
{
|
||||
printf("NFSMW_TEST_BAR_HEAP_MB:\n");
|
||||
Memory m = SteamMachine();
|
||||
ShrinkMappedVram(m, 256 * MB);
|
||||
CHECK(m.heaps[1] == 256 * MB && m.heaps[0] == 16621477888ull, "heaps %llu, %llu", (unsigned long long)m.heaps[0],
|
||||
(unsigned long long)m.heaps[1]);
|
||||
Plan g = PlanGuestMemory(m, Input(Gpu::kDiscrete, true));
|
||||
Expect("SM at 256 MB, guest memory", g, false, "import,copy-system:2,copy-cached:5,copy-vram:3!", "no resizable BAR");
|
||||
Expect("SM at 256 MB, ring (copy in type 2)", PlanRing(m, ~0u, 2), true, "ring-cached:5,ring-system:2,ring-vram:3!");
|
||||
// The Deck's choice, planned on the Steam Machine (NFSMW_TEST_GPU_KIND=integrated).
|
||||
Expect("SM as integrated", PlanGuestMemory(SteamMachine(), Input(Gpu::kIntegrated, true)), false,
|
||||
"import,copy-system:2,copy-cached:5,copy-vram:3");
|
||||
}
|
||||
|
||||
// Memory types a buffer can't use, and the special ones, are never planned.
|
||||
static void Types()
|
||||
{
|
||||
printf("types:\n");
|
||||
Memory sm = SteamMachine();
|
||||
CHECK(FindType(sm, ~0u, Place::kVram, false) == 3, "first CPU-mapped VRAM type");
|
||||
CHECK(FindType(sm, ~(1u << 3), Place::kVram, false) == 4, "type 3 not allowed");
|
||||
CHECK(FindType(sm, (1u << 9), Place::kVram, false) == kNoType, "AMD's device-coherent type");
|
||||
CHECK(FindType(sm, ~0u, Place::kSystem, false) == 2, "system");
|
||||
CHECK(FindType(sm, ~0u, Place::kCached, false) == 5, "cached");
|
||||
CHECK(FindType(Apple(), ~0u, Place::kCached, false) == kNoType, "no cached system memory on Apple");
|
||||
CHECK(FindType(Apple(), ~0u, Place::kCached, true) == 1, "the ring's cached type on Apple");
|
||||
CHECK(Fits(sm, 3, kGuestBytes, kRingBytes), "the Steam Machine's VRAM holds both");
|
||||
CHECK(!Fits(NvidiaNoRebar(), 4, kRingBytes), "a 256 MB window holds 192 MB at most at half");
|
||||
CHECK(Size(256 * MB) == "256 MB" && Size(8573157376ull) == "7.98 GB" && Size(16621477888ull) == "15 GB", "sizes %s %s %s",
|
||||
Size(256 * MB).c_str(), Size(8573157376ull).c_str(), Size(16621477888ull).c_str());
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
Unchanged();
|
||||
NoRebar();
|
||||
Intel();
|
||||
EnvOverrides();
|
||||
TestHook();
|
||||
Types();
|
||||
if (g_failures)
|
||||
{
|
||||
printf("%d FAILED\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -59,14 +59,26 @@ int main()
|
||||
Fill(2560, 1080, 0.75f, 1.0f, 2560, 1080); // Hor+
|
||||
Fill(3440, 1440, 0.7442f, 1.0f, 3440, 1440);
|
||||
Fill(2796, 1290, 0.8202f, 1.0f, 2796, 1290); // iPhone 15 Pro Max
|
||||
Fill(1280, 800, 1.0f, 1.0f, 1280, 720); // Steam Deck: 16:10 keeps its bars (kTallMinStretch)
|
||||
Fill(3024, 1890, 1.0f, 1.0f, 3024, 1701); // 16:10 Mac fullscreen, likewise
|
||||
Fill(1280, 800, 1.0f, 0.9f, 1280, 800); // Steam Deck: 16:10 is Vert+ too (kTallMinStretch 1.01)
|
||||
Fill(3024, 1890, 1.0f, 0.9f, 3024, 1890); // 16:10 Mac fullscreen, likewise
|
||||
Fill(2160, 1440, 1.0f, 0.84375f, 2160, 1440); // 3:2: Vert+
|
||||
Fill(2732, 2048, 1.0f, 0.7504f, 2732, 2048); // iPad Pro 12.9"
|
||||
Fill(1024, 768, 1.0f, 0.75f, 1024, 768);
|
||||
Fill(1280, 1024, 1.0f, 0.75f, 1280, 960); // narrower than 4:3: a 4:3 picture, letterboxed
|
||||
Fill(720, 1280, 1.0f, 0.75f, 720, 540); // portrait
|
||||
|
||||
printf("Fill Screen on 16:9 and within 1%% of it: letterboxed exactly as before Vert+:\n");
|
||||
{
|
||||
// kTallMinStretch 1.01 lets 16:10 fill; 16:9 itself, and screens a
|
||||
// hair wider (1366x768) or narrower (1360x768) than it, must not.
|
||||
const uint32_t nearWide[][2] = { { 1280, 720 }, { 1920, 1080 }, { 2560, 1440 }, { 3840, 2160 }, { 1366, 768 }, { 1360, 768 } };
|
||||
for (const auto& s : nearWide)
|
||||
{
|
||||
Letterbox(s[0], s[1], 1280.0f, 720.0f, FitPicture(s[0], s[1], 1280.0f, 720.0f, true, true), "Fill Screen");
|
||||
Letterbox(s[0], s[1], 2560.0f, 1440.0f, FitPicture(s[0], s[1], 2560.0f, 1440.0f, true, true), "Fill Screen (2x2 image)");
|
||||
}
|
||||
}
|
||||
|
||||
printf("16:9 setting (or no hooks at all), and no Vert+ hooks:\n");
|
||||
const uint32_t screens[][2] = { { 1280, 720 }, { 1366, 768 }, { 2560, 1080 }, { 3440, 1440 }, { 1280, 800 }, { 2732, 2048 },
|
||||
{ 1024, 768 }, { 1280, 1024 }, { 720, 1280 }, { 1920, 1080 }, { 3840, 2160 } };
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
// Tests for update/check.h: Check for Updates' logic. Versions (Semantic
|
||||
// Versioning), the strict JSON reader for GitHub's release, the HTTP answer
|
||||
// as curl prints it, the release notes as plain lines, and the result of
|
||||
// every outcome. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/update_check_test.cpp runtime/update/check.cpp -o build/update_check_test
|
||||
// build/update_check_test
|
||||
#include <update/check.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
using namespace update;
|
||||
using K = version::NoteLine::Kind;
|
||||
|
||||
static int Cmp(const char* a, const char* b)
|
||||
{
|
||||
Semver x, y;
|
||||
if (!ParseSemver(a, x) || !ParseSemver(b, y))
|
||||
return 99;
|
||||
return CompareSemver(x, y);
|
||||
}
|
||||
|
||||
static void Versions()
|
||||
{
|
||||
Semver v;
|
||||
CHECK(ParseSemver("0.1.2", v) && v.major == 0 && v.minor == 1 && v.patch == 2 && v.pre.empty() && v.build.empty(), "0.1.2");
|
||||
CHECK(ParseSemver("v10.20.30", v) && v.major == 10 && v.minor == 20 && v.patch == 30, "v10.20.30");
|
||||
CHECK(ParseSemver("1.0.0-rc.1+build.5", v) && v.pre.size() == 2 && v.pre[0] == "rc" && v.pre[1] == "1" && v.build == "build.5",
|
||||
"pre-release and build");
|
||||
CHECK(ParseSemver("1.0.0-x-y.0", v) && v.pre.size() == 2 && v.pre[0] == "x-y", "hyphens in identifiers");
|
||||
CHECK(FormatSemver(v) == "1.0.0-x-y.0", "%s", FormatSemver(v).c_str());
|
||||
for (const char* bad : { "", "v", "1", "1.2", "1.2.3.4", "01.2.3", "1.02.3", "1.2.03", "1..3", "1.2.3-", "1.2.3+", "1.2.3-01",
|
||||
"1.2.3-a..b", "1.2.3-a_b", "1.2.3 ", " 1.2.3", "1.2.x", "-1.2.3", "1.2.3-\xC3\xA9", "1234567890123456.0.0", "vv1.2.3" })
|
||||
CHECK(!ParseSemver(bad, v), "'%s' isn't a version", bad);
|
||||
CHECK(ParseSemver("1.2.3-0", v) && ParseSemver("1.2.3+01", v), "a lone 0, and leading zeros in build metadata, are fine");
|
||||
|
||||
CHECK(Cmp("0.1.1", "0.1.1") == 0, "equal");
|
||||
CHECK(Cmp("0.1.2", "0.1.1") == 1 && Cmp("0.1.1", "0.1.2") == -1, "patch");
|
||||
CHECK(Cmp("0.2.0", "0.1.9") == 1, "minor beats patch");
|
||||
CHECK(Cmp("1.0.0", "0.99.99") == 1, "major beats minor");
|
||||
CHECK(Cmp("0.10.0", "0.9.0") == 1, "numbers, not text");
|
||||
CHECK(Cmp("v0.1.0", "0.1.0") == 0, "the v doesn't count");
|
||||
CHECK(Cmp("1.0.0+a", "1.0.0+b") == 0, "build metadata doesn't count");
|
||||
// semver.org's own order.
|
||||
const char* order[] = { "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11",
|
||||
"1.0.0-rc.1", "1.0.0", "1.0.1-0", "1.0.1" };
|
||||
for (size_t i = 0; i + 1 < std::size(order); i++)
|
||||
{
|
||||
CHECK(Cmp(order[i], order[i + 1]) == -1, "%s < %s", order[i], order[i + 1]);
|
||||
CHECK(Cmp(order[i + 1], order[i]) == 1, "%s > %s", order[i + 1], order[i]);
|
||||
}
|
||||
CHECK(Cmp("1.0.0-2", "1.0.0-10") == -1, "numeric identifiers compare as numbers");
|
||||
CHECK(Cmp("1.0.0-a10", "1.0.0-a9") == -1, "alphanumeric ones as text");
|
||||
}
|
||||
|
||||
// A release as GitHub's API returns it (abridged), with members named like
|
||||
// ours in nested objects that must not be read.
|
||||
static const char kRelease[] = R"({
|
||||
"url": "https://api.github.com/repos/SpeedBreakerProject/speedbreaker/releases/1",
|
||||
"html_url": "https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/v0.1.2",
|
||||
"id": 255474781, "size": -1.5e+3, "zero": 0, "frac": 0.25, "neg": -0,
|
||||
"author": { "login": "x", "name": "not the release", "tag_name": "v9.9.9", "site_admin": false },
|
||||
"tag_name": "v0.1.2",
|
||||
"target_commitish": "main",
|
||||
"name": "SpeedBreaker v0.1.2 \u2014 \"Rockport\" \ud83c\udfce",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": [ { "name": "SpeedBreaker-macos.dmg", "body": "no", "size": 123 }, [], {}, null, true ],
|
||||
"body": "## What's new\r\n\r\n- Caf\u00e9 \\ path\/x\ttab\n- r\u00e9sum\u00e9 \u20ac10 \uD83D\uDE00\nend",
|
||||
"reactions": {"+1": 3}
|
||||
})";
|
||||
|
||||
static void Json()
|
||||
{
|
||||
Release r;
|
||||
std::string error;
|
||||
CHECK(ParseRelease(kRelease, r, error), "the release: %s", error.c_str());
|
||||
CHECK(r.hasTag && r.tag == "v0.1.2", "tag: '%s' (not the nested one)", r.tag.c_str());
|
||||
CHECK(r.url == "https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/v0.1.2", "html_url: '%s'", r.url.c_str());
|
||||
CHECK(r.name == "SpeedBreaker v0.1.2 \xE2\x80\x94 \"Rockport\" \xF0\x9F\x8F\x8E", "name, \\u escapes and a surrogate pair: '%s'",
|
||||
r.name.c_str());
|
||||
CHECK(r.body == "## What's new\r\n\r\n- Caf\xC3\xA9 \\ path/x\ttab\n- r\xC3\xA9sum\xC3\xA9 \xE2\x82\xAC" "10 \xF0\x9F\x98\x80\nend",
|
||||
"body: '%s'", r.body.c_str());
|
||||
CHECK(!r.draft && !r.prerelease, "flags");
|
||||
|
||||
CHECK(ParseRelease(R"({"tag_name":"v1.0.0","name":null,"body":null,"draft":true,"prerelease":true})", r, error) && r.name.empty() &&
|
||||
r.body.empty() && r.draft && r.prerelease,
|
||||
"nulls and true flags: %s", error.c_str());
|
||||
error = "from an earlier answer";
|
||||
CHECK(ParseRelease("{}", r, error), "an error left from before doesn't fail a good answer");
|
||||
CHECK(ParseRelease("\xEF\xBB\xBF{}", r, error) == false, "a byte-order mark isn't JSON");
|
||||
CHECK(ParseRelease(" \n{ } \r\n", r, error) && !r.hasTag, "an empty object: no tag (%s)", error.c_str());
|
||||
CHECK(ParseRelease(R"({"body":"raw UTF-8: )" "\xC3\xA9\xE2\x82\xAC\xF0\x9F\x8F\x8E" R"("})", r, error) &&
|
||||
r.body == "raw UTF-8: \xC3\xA9\xE2\x82\xAC\xF0\x9F\x8F\x8E",
|
||||
"raw UTF-8 kept: %s", error.c_str());
|
||||
CHECK(ParseRelease(R"({"body":"\u0041\u00DF\u6771\uD801\uDC37"})", r, error) && r.body == "A\xC3\x9F\xE6\x9D\xB1\xF0\x90\x90\xB7",
|
||||
"upper-case hex, 1-4 byte encodings: '%s'", r.body.c_str());
|
||||
|
||||
// Malformed answers: each is refused, and says why.
|
||||
struct Bad { const char* json; const char* why; };
|
||||
const Bad bad[] = {
|
||||
{ "", "empty" },
|
||||
{ " ", "empty" },
|
||||
{ "[]", "not a JSON object" },
|
||||
{ "\"tag_name\"", "not a JSON object" },
|
||||
{ "<html>rate limited</html>", "not a JSON object" },
|
||||
{ "{", "a missing member name" },
|
||||
{ "{\"tag_name\":\"v1.0.0\"", "a missing ',' or '}'" },
|
||||
{ "{\"tag_name\":\"v1.0.0\",}", "a missing member name" },
|
||||
{ "{\"tag_name\" \"v1.0.0\"}", "a missing ':'" },
|
||||
{ "{\"tag_name\":\"v1.0.0}", "an unterminated string" },
|
||||
{ "{\"tag_name\":\"v1.0.0\"} x", "more after the JSON" },
|
||||
{ "{\"tag_name\":\"v1.0.0\"}{}", "more after the JSON" },
|
||||
{ "{\"a\":[1,2,]}", "an unexpected ']'" },
|
||||
{ "{\"a\":[1 2]}", "a missing ',' or ']'" },
|
||||
{ "{\"a\":01}", "a missing ',' or '}'" },
|
||||
{ "{\"a\":1.}", "a bad number" },
|
||||
{ "{\"a\":.5}", "an unexpected '.'" },
|
||||
{ "{\"a\":-}", "a bad number" },
|
||||
{ "{\"a\":1e}", "a bad number" },
|
||||
{ "{\"a\":tru}", "an unknown word" },
|
||||
{ "{\"a\":nul}", "an unknown word" },
|
||||
{ "{\"a\":'x'}", "an unexpected '''" },
|
||||
{ "{\"body\":\"a\nb\"}", "a control character in a string" },
|
||||
{ "{\"body\":\"tab\there\"}", "a control character in a string" },
|
||||
{ "{\"body\":\"\\x41\"}", "a bad escape" },
|
||||
{ "{\"body\":\"\\u12\"}", "a bad \\u escape" },
|
||||
{ "{\"body\":\"\\u12G4\"}", "a bad \\u escape" },
|
||||
{ "{\"body\":\"\\ud83c\"}", "an unpaired surrogate" },
|
||||
{ "{\"body\":\"\\ud83cx\"}", "an unpaired surrogate" },
|
||||
{ "{\"body\":\"\\ud83c\\u0041\"}", "an unpaired surrogate" },
|
||||
{ "{\"body\":\"\\udfce\"}", "an unpaired surrogate" },
|
||||
{ "{\"body\":\"\xC0\x80\"}", "invalid UTF-8" }, // an overlong NUL
|
||||
{ "{\"body\":\"\xE0\x80\xAF\"}", "invalid UTF-8" }, // an overlong '/'
|
||||
{ "{\"body\":\"\xED\xA0\x80\"}", "invalid UTF-8" }, // a surrogate in UTF-8
|
||||
{ "{\"body\":\"\xF4\x90\x80\x80\"}", "invalid UTF-8" }, // past U+10FFFF
|
||||
{ "{\"body\":\"\xF5\x80\x80\x80\"}", "invalid UTF-8" },
|
||||
{ "{\"body\":\"\xC3\"}", "invalid UTF-8" }, // cut short
|
||||
{ "{\"body\":\"\x80\"}", "invalid UTF-8" },
|
||||
{ "{\"tag_name\":5}", "\"tag_name\" of the wrong type" },
|
||||
{ "{\"tag_name\":null}", "\"tag_name\" of the wrong type" },
|
||||
{ "{\"tag_name\":[\"v1.0.0\"]}", "\"tag_name\" of the wrong type" },
|
||||
{ "{\"draft\":\"false\"}", "\"draft\" of the wrong type" },
|
||||
{ "{\"prerelease\":0}", "\"prerelease\" of the wrong type" },
|
||||
{ "{\"body\":{}}", "\"body\" of the wrong type" },
|
||||
{ "{\"tag_name\":\"v1.0.0\",\"tag_name\":\"v9.0.0\"}", "a second \"tag_name\"" },
|
||||
{ "{\"tag_name\":\"v1.0.0\",\"x\":{\"a\":", "a missing value" },
|
||||
};
|
||||
for (const Bad& b : bad)
|
||||
{
|
||||
error.clear();
|
||||
Release out;
|
||||
out.tag = "untouched";
|
||||
const bool ok = ParseRelease(b.json, out, error);
|
||||
CHECK(!ok, "accepted: %s", b.json);
|
||||
CHECK(error.find(b.why) != std::string::npos, "%s: '%s', expected '%s'", b.json, error.c_str(), b.why);
|
||||
CHECK(out.tag == "untouched", "a refused answer leaves the release alone: %s", b.json);
|
||||
}
|
||||
// Nesting: 64 levels are fine, 65 aren't (and a deep one can't overflow the stack).
|
||||
auto nested = [](int levels) {
|
||||
std::string json = "{\"a\":";
|
||||
for (int i = 1; i < levels; i++)
|
||||
json += i % 2 ? "[" : "{\"b\":";
|
||||
json += "0";
|
||||
for (int i = levels - 1; i >= 1; i--)
|
||||
json += i % 2 ? "]" : "}";
|
||||
return json + "}";
|
||||
};
|
||||
CHECK(ParseRelease(nested(64), r, error), "64 levels: %s", error.c_str());
|
||||
error.clear();
|
||||
CHECK(!ParseRelease(nested(65), r, error) && error.find("deeper than 64") != std::string::npos, "65 levels: '%s'", error.c_str());
|
||||
error.clear();
|
||||
CHECK(!ParseRelease(std::string(100000, '['), r, error), "100,000 open brackets");
|
||||
CHECK(error.find("not a JSON object") != std::string::npos, "%s", error.c_str());
|
||||
error.clear();
|
||||
CHECK(!ParseRelease("{\"a\":" + std::string(100000, '['), r, error) && error.find("deeper than 64") != std::string::npos,
|
||||
"100,000 nested arrays: '%s'", error.c_str());
|
||||
}
|
||||
|
||||
static void Http()
|
||||
{
|
||||
HttpResponse h;
|
||||
// curl --dump-header - on HTTP/2, a proxy's CONNECT first.
|
||||
std::string raw = "HTTP/1.1 200 Connection established\r\n\r\n"
|
||||
"HTTP/2 403 \r\nContent-Type: application/json\r\nX-RateLimit-Remaining: 0\r\nx-ratelimit-reset: 1700000600 \r\n\r\n"
|
||||
"{\"message\":\"API rate limit exceeded\"}";
|
||||
CHECK(ParseHttpResponse(raw, h), "curl's output");
|
||||
CHECK(h.status == 403, "the last block's status: %d", h.status);
|
||||
CHECK(Header(h, "x-ratelimit-remaining") == "0" && Header(h, "X-RATELIMIT-RESET") == "1700000600", "headers, any case, trimmed");
|
||||
CHECK(Header(h, "content-length").empty(), "an absent header");
|
||||
CHECK(h.body == "{\"message\":\"API rate limit exceeded\"}", "body: '%s'", h.body.c_str());
|
||||
CHECK(ParseHttpResponse("HTTP/1.1 200 OK\nA: b\n\n{}\n", h) && h.status == 200 && h.body == "{}\n" && Header(h, "a") == "b",
|
||||
"LF line ends (a hand-written canned file)");
|
||||
CHECK(ParseHttpResponse("HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 204 No Content\r\n\r\n", h) && h.status == 204 && h.body.empty(),
|
||||
"an interim answer, then no body");
|
||||
for (const char* bad : { "", "{}", "HTTP/1.1 200 OK\r\nA: b\r\n", "HTTP/1.1 2000 OK\r\n\r\n", "HTTP/1.1 20 OK\r\n\r\n", "HTTP/1.1\r\n\r\n",
|
||||
"HTTP/1.1 abc\r\n\r\n", "HTTP/1.1 200 OK\r\nno colon\r\n\r\n", "HTTP/1.1 200 OK\r\n: empty name\r\n\r\n" })
|
||||
CHECK(!ParseHttpResponse(bad, h), "'%s' isn't an answer", bad);
|
||||
}
|
||||
|
||||
static std::string Dump(const Notes& notes)
|
||||
{
|
||||
std::string out;
|
||||
for (const auto& line : notes.lines)
|
||||
{
|
||||
out += line.kind == K::Heading ? "H:" : line.kind == K::Bullet ? "B:" : line.kind == K::Gap ? "~" : "T:";
|
||||
out += line.text;
|
||||
out += "|";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static void NotesCleanup()
|
||||
{
|
||||
const char* md =
|
||||
"<!-- release-template: keep this -->\r\n"
|
||||
"## What's new in **v0.1.2**\r\n"
|
||||
"\r\n"
|
||||
"- The Steam Deck now *fills* its 16:10 screen ([#12](https://github.com/x/y/issues/12)).\r\n"
|
||||
" It costs nothing: `kTallMinStretch` is __1.01__.\r\n"
|
||||
"* Sunlit roads ~~dark~~ fixed! \r\n"
|
||||
"+ [ ] a task\n"
|
||||
"- [x] done task\n"
|
||||
"\n"
|
||||
"1. Unpack over `~/Games/SpeedBreaker`.\n"
|
||||
"2) Keep snake_case_names, 2 * 3 and a_b *as they are*.\n"
|
||||
"\n"
|
||||
"Setext heading\n"
|
||||
"==============\n"
|
||||
"> Quoted **text** & an entity © A &bogus; &\n"
|
||||
"Line with <b>tags</b> and a<br>break, <https://example.com/a>, and \\*escaped\\*.\n"
|
||||
"\n"
|
||||
"<details><summary>Checksums</summary>\n"
|
||||
"\n"
|
||||
"| File | SHA-256 |\n"
|
||||
"|---|:---:|\n"
|
||||
"| SpeedBreaker-macos.dmg | `abc123` |\n"
|
||||
"</details>\n"
|
||||
"\n"
|
||||
"```sh\n"
|
||||
"tar -xf *.tar.xz # stays as **it** is\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
"---\n"
|
||||
"[ref]: https://example.com/ref\n"
|
||||
"See [the page][ref]. Ctrl\x01" "chars\x7F gone, bidi \xE2\x80\xAE" "kept out\xE2\x80\xAC, \xC2\xA0nbsp.\n"
|
||||
"### Closing hashes ###\n"
|
||||
"#not a heading\n";
|
||||
Notes notes = CleanNotes(md);
|
||||
const std::string got = Dump(notes);
|
||||
const std::string want =
|
||||
"H:What's new in v0.1.2|~|"
|
||||
"B:The Steam Deck now fills its 16:10 screen (#12). It costs nothing: kTallMinStretch is 1.01.|"
|
||||
"B:Sunlit roads dark fixed! shot|" // the image's alt text, as GitHub shows it when it can't load
|
||||
"B:a task|B:done task|~|"
|
||||
"T:1. Unpack over ~/Games/SpeedBreaker.|"
|
||||
"T:2. Keep snake_case_names, 2 * 3 and a_b as they are.|~|"
|
||||
"H:Setext heading|"
|
||||
"T:Quoted text & an entity \xC2\xA9 A &bogus; & Line with tags and a break, https://example.com/a, and *escaped*.|~|"
|
||||
"T:Checksums|~|"
|
||||
"T:File | SHA-256|T:SpeedBreaker-macos.dmg | abc123|~|"
|
||||
"T:tar -xf *.tar.xz # stays as **it** is|~|"
|
||||
"T:See the page. Ctrlchars gone, bidi kept out, nbsp.|"
|
||||
"H:Closing hashes|T:#not a heading|";
|
||||
CHECK(got == want, "\n got %s\n want %s", got.c_str(), want.c_str());
|
||||
CHECK(!notes.truncated, "not truncated");
|
||||
|
||||
// Caps: characters and lines, cut on a character boundary.
|
||||
std::string many;
|
||||
for (int i = 0; i < 400; i++)
|
||||
many += "- item " + std::to_string(i) + "\n";
|
||||
Notes capped = CleanNotes(many, 100000, 150);
|
||||
CHECK(capped.lines.size() == 150 && capped.truncated, "150 lines at most: %zu", capped.lines.size());
|
||||
std::string euros;
|
||||
for (int i = 0; i < 100; i++)
|
||||
euros += "\xE2\x82\xAC"; // 3 bytes each
|
||||
Notes cut = CleanNotes(euros, 10);
|
||||
CHECK(cut.truncated && cut.lines.size() == 1 && cut.lines[0].text == "\xE2\x82\xAC\xE2\x82\xAC...", "cut whole: '%s'",
|
||||
cut.lines.empty() ? "" : cut.lines[0].text.c_str());
|
||||
Notes words = CleanNotes("alpha beta gamma delta epsilon zeta eta theta", 30);
|
||||
CHECK(words.truncated && words.lines.size() == 1 && words.lines[0].text == "alpha beta gamma delta...", "cut at a space: '%s'",
|
||||
words.lines.empty() ? "" : words.lines[0].text.c_str());
|
||||
Notes total = CleanNotes("first line is here\n\nsecond line is longer than the rest", 30);
|
||||
CHECK(total.truncated && Dump(total) == "T:first line is here|~|T:second li...|", "a total, not a per-line cap: '%s'",
|
||||
Dump(total).c_str());
|
||||
Notes joined = CleanNotes("one two three\nfour five six", 18);
|
||||
CHECK(joined.truncated && Dump(joined) == "T:one two three f...|", "a joined line is capped too: '%s'", Dump(joined).c_str());
|
||||
CHECK(CleanNotes("").lines.empty() && CleanNotes("\n\n<!-- x -->\n---\n").lines.empty(), "nothing to show");
|
||||
CHECK(Dump(CleanNotes("a\n<!-- never closed\nb")) == "T:a|", "an unclosed comment hides the rest");
|
||||
CHECK(Dump(CleanNotes("```\nno end\n- not a bullet")) == "T:no end|T:- not a bullet|", "an unclosed fence: code to the end");
|
||||
std::string big(200000, 'x');
|
||||
Notes huge = CleanNotes(big);
|
||||
CHECK(huge.truncated && huge.lines.size() == 1 && huge.lines[0].text.size() <= 6000, "a 200 KB body: %zu bytes",
|
||||
huge.lines.empty() ? 0 : huge.lines[0].text.size());
|
||||
CHECK(Dump(CleanNotes(std::string("a\xFF\xC3 b\xC3\xA9"))) == "T:a b\xC3\xA9|", "stray bytes dropped");
|
||||
// Unclosed marks on one long line stay linear (each looks a bounded way
|
||||
// ahead): four times the text takes about four times as long, not sixteen.
|
||||
for (char mark : { '[', '<', '`', '&', '(', '*', '_' })
|
||||
{
|
||||
auto time = [mark](size_t size) {
|
||||
double best = 1e9;
|
||||
for (int run = 0; run < 3; run++)
|
||||
{
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
Notes marks = CleanNotes(std::string(size, mark));
|
||||
best = std::min(best, std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count());
|
||||
CHECK(marks.lines.size() <= 1, "%zu bytes of '%c': %zu lines", size, mark, marks.lines.size());
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const double small = time(16384), large = time(65536);
|
||||
CHECK(large < 8 * small + 20 && large < 2000, "'%c': 16 KB %.1f ms, 64 KB %.1f ms", mark, small, large);
|
||||
}
|
||||
|
||||
CHECK(CleanLine("SpeedBreaker **v0.1.2**\n\tfinal", 120) == "SpeedBreaker v0.1.2 final", "a name: '%s'",
|
||||
CleanLine("SpeedBreaker **v0.1.2**\n\tfinal", 120).c_str());
|
||||
CHECK(CleanLine("abcdefghij", 8) == "abcde...", "a capped name: '%s'", CleanLine("abcdefghij", 8).c_str());
|
||||
}
|
||||
|
||||
static Fetched Answer(int status, std::string body, std::vector<std::pair<std::string, std::string>> headers = {})
|
||||
{
|
||||
Fetched f;
|
||||
f.transport = Transport::Ok;
|
||||
f.response.status = status;
|
||||
f.response.body = std::move(body);
|
||||
f.response.headers = std::move(headers);
|
||||
return f;
|
||||
}
|
||||
|
||||
static std::string ReleaseJson(const char* tag, const char* extra = "")
|
||||
{
|
||||
return std::string(R"({"tag_name":")") + tag +
|
||||
R"(","name":"SpeedBreaker )" + tag + R"(","html_url":"https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/)" + tag +
|
||||
R"(","body":"### Fixed\n- Shadows","draft":false,"prerelease":false)" + extra + "}";
|
||||
}
|
||||
|
||||
static void Outcomes()
|
||||
{
|
||||
const int64_t now = 1700000000;
|
||||
Result r = Evaluate(Answer(200, ReleaseJson("v0.1.1")), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::UpToDate && r.headline == "You're up to date (v0.1.1)", "up to date: '%s'", r.headline.c_str());
|
||||
CHECK(r.latest == "0.1.1" && r.log == "up to date: running v0.1.1, the latest release is v0.1.1", "log: '%s'", r.log.c_str());
|
||||
|
||||
r = Evaluate(Answer(200, ReleaseJson("v0.1.2")), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::Available && r.headline == "v0.1.2 is available" && r.message == "You have v0.1.1.", "available: '%s' '%s'",
|
||||
r.headline.c_str(), r.message.c_str());
|
||||
CHECK(r.url == "https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/v0.1.2" && r.name == "SpeedBreaker v0.1.2", "page, name");
|
||||
CHECK(Dump(r.notes) == "H:Fixed|B:Shadows|", "notes: %s", Dump(r.notes).c_str());
|
||||
CHECK(r.log == "v0.1.2 is available (running v0.1.1): https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/v0.1.2", "log: '%s'",
|
||||
r.log.c_str());
|
||||
|
||||
r = Evaluate(Answer(200, ReleaseJson("v0.1.0")), "0.0.9", now);
|
||||
CHECK(r.outcome == Outcome::Available && r.headline == "v0.1.0 is available", "NFSMW_TEST_VERSION's case: '%s'", r.headline.c_str());
|
||||
r = Evaluate(Answer(200, ReleaseJson("v0.10.0")), "0.9.0", now);
|
||||
CHECK(r.outcome == Outcome::Available, "0.10.0 is newer than 0.9.0");
|
||||
r = Evaluate(Answer(200, ReleaseJson("v0.1.0")), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::UpToDate && r.message == "The newest release is v0.1.0; this build is newer.", "ahead: '%s'", r.message.c_str());
|
||||
r = Evaluate(Answer(200, ReleaseJson("v0.2.0")), "0.2.0-rc.1", now);
|
||||
CHECK(r.outcome == Outcome::Available, "a release after its pre-release");
|
||||
|
||||
// Drafts and pre-releases are never offered.
|
||||
r = Evaluate(Answer(200, ReleaseJson("v0.2.0-beta.1")), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::UpToDate && r.latest.empty() && r.log.find("pre-release (ignored)") != std::string::npos,
|
||||
"a pre-release tag: '%s'", r.log.c_str());
|
||||
r = Evaluate(Answer(200, R"({"tag_name":"v0.9.0","prerelease":true})"), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::UpToDate, "prerelease: true");
|
||||
r = Evaluate(Answer(200, R"({"tag_name":"v0.9.0","draft":true})"), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::UpToDate, "draft: true");
|
||||
|
||||
// The download page: only this project's releases.
|
||||
r = Evaluate(Answer(200, R"({"tag_name":"v0.9.0","html_url":"https://evil.example/releases/"})"), "0.1.1", now);
|
||||
CHECK(r.url == kReleasesPage, "another site: '%s'", r.url.c_str());
|
||||
for (const char* bad : { "javascript:alert(1)", "https://github.com/SpeedBreakerProject/speedbreakerx/releases/tag/v1",
|
||||
"https://github.com/SpeedBreakerProject/speedbreaker/releases/", "https://github.com/SpeedBreakerProject/speedbreaker/releases/../../x",
|
||||
"https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/v1 x", "https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/\"x",
|
||||
"https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/\xC3\xA9", "" })
|
||||
CHECK(SafePageUrl(bad) == kReleasesPage, "'%s' isn't used", bad);
|
||||
CHECK(SafePageUrl("https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/v0.1.2") ==
|
||||
"https://github.com/SpeedBreakerProject/speedbreaker/releases/tag/v0.1.2",
|
||||
"the release's page is");
|
||||
|
||||
// Rate limits: GitHub's 60 an hour.
|
||||
r = Evaluate(Answer(403, R"({"message":"API rate limit exceeded for 1.2.3.4."})",
|
||||
{ { "x-ratelimit-remaining", "0" }, { "x-ratelimit-reset", std::to_string(now + 1381) } }),
|
||||
"0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::RateLimited && r.headline == "Couldn't check for updates", "rate-limited");
|
||||
CHECK(r.message == "GitHub's limit of 60 checks an hour is used up. Try again in 24 minutes.", "'%s'",
|
||||
r.message.c_str());
|
||||
CHECK(r.log == "check failed: rate-limited (HTTP 403, try again in 24 minutes)", "'%s'", r.log.c_str());
|
||||
r = Evaluate(Answer(403, "{}", { { "X-RateLimit-Remaining", "0" }, { "X-RateLimit-Reset", std::to_string(now + 60) } }), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::RateLimited && r.message.ends_with("Try again in 1 minute."), "the header alone says so: '%s'", r.message.c_str());
|
||||
r = Evaluate(Answer(429, "", { { "retry-after", "60" } }), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::RateLimited && r.message.ends_with("Try again in 1 minute."), "429: '%s'", r.message.c_str());
|
||||
r = Evaluate(Answer(403, R"({"message":"API rate limit exceeded"})", { { "x-ratelimit-reset", "garbage" } }), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::RateLimited && r.message.ends_with("Try again later."), "no reset time: '%s'", r.message.c_str());
|
||||
r = Evaluate(Answer(403, R"({"message":"Forbidden"})", { { "x-ratelimit-remaining", "59" } }), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::ServerError && r.message == "GitHub answered with an error (HTTP 403). Try again later.", "a 403 that isn't a limit");
|
||||
CHECK(r.log == "check failed: HTTP 403: Forbidden", "'%s'", r.log.c_str());
|
||||
r = Evaluate(Answer(404, R"({"message":"Not Found"})"), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::ServerError && r.message.starts_with("GitHub found no release"), "404");
|
||||
r = Evaluate(Answer(502, "<html>Bad gateway</html>"), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::ServerError && r.log == "check failed: HTTP 502", "502: '%s'", r.log.c_str());
|
||||
|
||||
// Answers that can't be read.
|
||||
r = Evaluate(Answer(200, "{\"tag_name\": \"v0.1.2\""), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::Unreadable && r.message == "GitHub's answer couldn't be read. Try again later.", "truncated JSON");
|
||||
CHECK(r.log.starts_with("check failed: unreadable answer: a missing ',' or '}' at byte"), "'%s'", r.log.c_str());
|
||||
r = Evaluate(Answer(200, "{}"), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::Unreadable && r.log == "check failed: unreadable answer: no tag_name", "'%s'", r.log.c_str());
|
||||
r = Evaluate(Answer(200, R"({"tag_name":"latest\u0007"})"), "0.1.1", now);
|
||||
CHECK(r.outcome == Outcome::Unreadable && r.log == "check failed: unreadable answer: the tag \"latest\" isn't a version", "'%s'",
|
||||
r.log.c_str());
|
||||
r = Evaluate(Answer(200, ReleaseJson("v0.1.2")), "0.1.1-dirty+x y", now);
|
||||
CHECK(r.outcome == Outcome::Unreadable, "this build's own version doesn't parse");
|
||||
|
||||
// The transport's failures: one line each.
|
||||
struct T { Transport t; Outcome o; const char* message; const char* log; };
|
||||
const T transports[] = {
|
||||
{ Transport::Offline, Outcome::Offline, "No connection to GitHub. Check that this device is online.",
|
||||
"check failed: offline (curl exit 6)" },
|
||||
{ Transport::Timeout, Outcome::Timeout, "GitHub didn't answer within 10 seconds. Try again later.", "check failed: timed out (curl exit 6)" },
|
||||
{ Transport::Secure, Outcome::Offline, "Couldn't connect to GitHub securely. Check the date and time.",
|
||||
"check failed: secure connection failed (curl exit 6)" },
|
||||
{ Transport::TooLarge, Outcome::Unreadable, "GitHub's answer was too large to read. Try again later.",
|
||||
"check failed: answer too large (curl exit 6)" },
|
||||
{ Transport::Garbled, Outcome::Unreadable, "GitHub's answer couldn't be read. Try again later.", "check failed: unreadable answer (curl exit 6)" },
|
||||
{ Transport::Unavailable, Outcome::Unavailable, "curl exit 6", "check failed: not available (curl exit 6)" },
|
||||
{ Transport::Failed, Outcome::Offline, "The check couldn't be made (curl exit 6). Try again later.", "check failed: failed (curl exit 6)" },
|
||||
};
|
||||
for (const T& t : transports)
|
||||
{
|
||||
Fetched f;
|
||||
f.transport = t.t;
|
||||
f.detail = "curl exit 6";
|
||||
r = Evaluate(f, "0.1.1", now);
|
||||
CHECK(r.outcome == t.o && r.message == t.message && r.log == t.log && r.headline == "Couldn't check for updates", "transport %d: '%s' / '%s'",
|
||||
int(t.t), r.message.c_str(), r.log.c_str());
|
||||
CHECK(r.message.find('\n') == std::string::npos && r.url == kReleasesPage, "one line, the releases page");
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
Versions();
|
||||
Json();
|
||||
Http();
|
||||
NotesCleanup();
|
||||
Outcomes();
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
Reference in new issue
Block a user