SpeedBreaker v0.1.2

Co-authored-by: Lydacious <81742991+lydacious@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-10-11 06:51:57 +00:00
1 parent a4a72ce103
commit e128baca5e
71 files changed
+9012 -423

No files matched your search

+57
View File
@@ -4,6 +4,63 @@ 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.2
### What's new
- Internal Resolution 1.5x (1080p), in Settings > Graphics: a much sharper picture than 1x, with
a little over half the pixels of 2x: for a GPU with room above 1x but not for 2x. Thanks to the
Reddit player who asked for it.
- Laptops with two GPUs: the game now runs on the dedicated graphics card (NVIDIA or AMD), not on
the one built into the processor. On Linux with NVIDIA's driver, the launcher also turns on
NVIDIA's PRIME render offload: thanks to lydacious for the idea and the first version (GitHub
pull request #6).
- Portable mode on Steam Deck, Steam Machine and Linux: put a file named `portable.txt` next to
`speedbreaker.sh`, and SpeedBreaker keeps your settings, saves, logs and caches in that folder,
and installs the game there too. Move or copy the whole folder to another machine and play on
(GitHub issue #1).
- Taller and wider screens: the sun's glare is round again (it was stretched up to 1.33 times
taller on the Steam Deck, 4:3 and 3:2, and wider on ultrawides); no faint line of a letterboxed
movie shows in the black past the 16:9 picture; and the race results screen is black past its
16:9 picture, as on a 16:9 TV, instead of showing the road behind it.
- iPhone: after a screenshot, the picture no longer shrinks into a corner of the screen with the
settings menu blown up.
- Experimental: an IPA for iPhone and iPad, to sideload with your own Apple Account.
### More about it
- 1.5x renders 2.25 times the pixels of 1x, where 2x renders 4 times. Auto still chooses between
whole scales (1x, 2x or 3x) as before.
- The game picks a dedicated (discrete) GPU over a built-in (integrated) one, and of two alike the
one with more video memory. The log's `[video] Vulkan devices:` line lists every GPU it found,
the one it uses, and why. To use another, put `NFSMW_GPU=<its number or part of its name>` in
the launch options, e.g. `NFSMW_GPU=intel %command%`. On the Steam Deck, Steam Machine, Steam
Frame, a Mac, an iPhone or an iPad (one GPU each) nothing changes. The launcher sets
`__NV_PRIME_RENDER_OFFLOAD=1` only on a PC where NVIDIA's driver runs one GPU beside another and
NVIDIA's Vulkan driver is installed, and keeps a value you set yourself.
- `speedbreaker.sh --portable on` copies what you have now (settings, saves and the installed game)
into the folder and turns portable mode on; `--portable off` copies them back. Both copy and
never delete: your old files stay until you remove them. Not on the Mac, where an app can't keep
its data inside itself.
- The IPA: the game asks iOS for two memory entitlements, and a free Apple Account can give at most
one of them, with some tools only: with a free account it may not start, or iOS may close it in
busy scenes. scripts/ios/README.md says which tools keep what. When iOS refuses the memory, the
game now says why on screen instead of closing.
- iPhone and iPad: on iOS 27 the screenshot screen could leave the game laid out as if the phone
were held upright, until the game was closed. The game now checks its picture against the
screen after coming back from the screenshot screen, the app switcher or Control Center, and
puts it right if they disagree; an iPhone, which plays only in landscape, never draws upright.
- iPhone and iPad: Internal Resolution's Auto now decides chip by chip, and the log says what it
chose and why. Every chip still gets 1x (720p) under Auto, the iPhone 18 Pro Max included: in a
long race on a charger it got hot even at 1x. 1.5x and 2x are in Settings > Graphics > Internal
Resolution. The log also records the battery level and charging state.
- After a macOS, SteamOS or graphics driver update, the first drive can stutter for a moment while
the game rebuilds its shaders for the new system. It happens once, and the game's log now says
so.
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
unpack the new download over the old folder.
## v0.1.1
### What's new
+1 -1
View File
@@ -66,7 +66,7 @@ endif()
# apps' versions, the SteamOS bundles' VERSION file and the release tag
# (v<version>). CHANGELOG.md has an entry for each version (the in-game
# "What's new" note is built from it).
project(SpeedBreaker VERSION 0.1.1 LANGUAGES C CXX)
project(SpeedBreaker VERSION 0.1.2 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
+46 -14
View File
@@ -60,8 +60,8 @@ and a settings menu you can open mid-race.
## Highlights
- **Up to 4K.** The 3D scene renders at 1x, 2x or 3x the console's 720p (3x is 3840x2160).
*Auto* picks a scale for your screen and device.
- **Up to 4K.** The 3D scene renders at 1x, 1.5x, 2x or 3x the console's 720p (1.5x is 1080p,
3x is 3840x2160). *Auto* picks a scale for your screen and device.
- **Ultrawide done right.** On 21:9 screens the view gets wider instead of stretched, and the
HUD and the rear-view mirror stay in place. A 4:3 iPad, a 3:2 screen or the 16:10 screens of
the Steam Deck and many Macs show more above and below instead, and fill the screen.
@@ -111,9 +111,9 @@ belongs to its owners.</sub>
|---|---|---|
| **Steam Deck** and **Steam Machine** (SteamOS) | [Download][dl-steamos-x86_64]<br>`SpeedBreaker-steamos-x86_64.tar.xz` | Steam Deck OLED (SteamOS 3.9.2). Steam Machine (SteamOS 3.8) on a 3440x1440 ultrawide. The Steam Deck LCD hasn't been tested yet. |
| **Steam Frame** (SteamOS, arm64) | [Download][dl-steamos-arm64]<br>`SpeedBreaker-steamos-arm64.tar.xz` | Steam Frame (SteamOS 0.3.0). It plays as a big flat screen in the headset; it is not a VR mode. |
| **Linux** (x86-64) | [Download][dl-steamos-x86_64] the x86-64 SteamOS build, `SpeedBreaker-steamos-x86_64.tar.xz`, or [build from source](#building-from-source) | SteamOS only so far. Other distributions and NVIDIA or Intel GPUs are untested: reports welcome. |
| **Mac** (Apple silicon) | [Download][dl-macos]<br>`SpeedBreaker-macos.dmg` | MacBook Pro (M1 Pro) and Mac Studio (M4 Max), on macOS 26; macOS 15.6 (Sequoia) in a virtual machine. There is no Intel Mac build. |
| **iPhone** and **iPad** | [Build it yourself](#iphone-and-ipad) with Xcode | iPhone 15 Pro Max (iOS 27). iPad Pro 12.9-inch (M2, iPadOS 27) with a Backbone Pro. Not on the App Store. |
| **Linux** (x86-64) | [Download][dl-steamos-x86_64] the x86-64 SteamOS build, `SpeedBreaker-steamos-x86_64.tar.xz`, or [build from source](#building-from-source) | SteamOS here. Players report it working on Fedora 44 (GeForce GTX 1080 Ti) and Ubuntu 24.04 (Intel Arc 140T). Other setups: reports welcome. |
| **Mac** (Apple silicon) | [Download][dl-macos]<br>`SpeedBreaker-macos.dmg` | MacBook Pro (M1 Pro) on macOS 27 and Mac Studio (M4 Max) on macOS 26; macOS 15.6 (Sequoia) in a virtual machine. There is no Intel Mac build. |
| **iPhone** and **iPad** | [Sideload the IPA](#iphone-and-ipad) (experimental), `SpeedBreaker-<version>-ios.ipa` on the [release page][releases], or [build it yourself](#iphone-and-ipad) with Xcode | iPhone 15 Pro Max and iPhone 18 Pro Max (iOS 27). iPad Pro 12.9-inch (M2, iPadOS 27) with a Backbone Pro. Not on the App Store. The IPA hasn't been tested with a free Apple Account yet. |
| **Windows** | Coming soon | |
| **Android** | Coming soon | |
@@ -146,7 +146,8 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
the extracted disc files. No title update is needed. SpeedBreaker doesn't provide game files
or disc images, or link to them.
2. **Get SpeedBreaker.** [Download it](#download) for Steam Deck, Steam Machine and Steam Frame
(SteamOS), Linux or Mac. On iPhone and iPad you build it yourself. Then follow the
(SteamOS), Linux or Mac. On iPhone and iPad you sideload the experimental IPA or build it
yourself. Then follow the
[setup for your platform](#setup-by-platform).
3. **Start it and install.** On first run, SpeedBreaker looks for your disc image, checks that
it is the right game and version, then copies and verifies every file. This takes a few
@@ -212,6 +213,13 @@ right of the screen).
**Uninstall:** delete `~/Games/SpeedBreaker/`, `~/.local/share/speedbreaker/` (this deletes your
saves too) and `~/.cache/speedbreaker/`, and remove the shortcut from Steam.
**Portable mode:** to keep everything in the SpeedBreaker folder (saves, settings, logs, shader
caches and the installed game), so the whole folder can be moved or copied to another Steam Deck,
Steam Machine or Linux PC, make an empty file named `portable.txt` next to `speedbreaker.sh`. That
starts fresh; to bring your current settings, saves and game along, run
`~/Games/SpeedBreaker/speedbreaker.sh --portable on` in Konsole instead, and `--portable off` to
go back. Both copy and never delete. The folder's README says more.
### Steam Frame
The Steam Frame has its own download. You set it up on the Frame's desktop: open the
@@ -261,6 +269,11 @@ screen. The installer puts the game in `~/.local/share/speedbreaker/game`, or on
any folder you pick with *Choose a folder...* (it needs a file chooser portal or zenity; without
one, use `--dest`, below).
On a laptop with two GPUs, SpeedBreaker uses the dedicated one (NVIDIA or AMD); with NVIDIA's
driver the launcher also turns on PRIME render offload. The log's `[video] Vulkan devices:` line
says which GPU it chose and why. To pick another, start it with `NFSMW_GPU=<its number or part of
its name>`, e.g. `NFSMW_GPU=intel %command%` in Steam's launch options.
### Mac
1. **Download and install.** Download [`SpeedBreaker-macos.dmg`][dl-macos], open it, and drag
@@ -286,9 +299,18 @@ Display > Fullscreen switches to full screen. SpeedBreaker needs macOS 15 (Sequo
### iPhone and iPad
There's no download for iPhone and iPad, and SpeedBreaker isn't on the App Store. You build it
yourself with Xcode on a Mac and install it on your own device, signed with your own Apple
developer account: [scripts/ios/README.md](scripts/ios/README.md) walks you through it.
SpeedBreaker isn't on the App Store. There are two ways to get it onto your iPhone or iPad:
- **Sideload the IPA (experimental).** Each release has an IPA, `SpeedBreaker-<version>-ios.ipa`,
on the [release page][releases]. A sideloading tool such as AltStore signs it with your own Apple
Account and installs it. The game asks iOS for more memory than most apps, and a free Apple
Account can't give all of it, so with a free account it may not start, or iOS may close it in
busy scenes. When iOS refuses the memory, the game says so on screen. It hasn't been tested with
a free Apple Account yet: read
[Sideloading the IPA](scripts/ios/README.md#sideloading-the-ipa-experimental) first, and please
report how it goes.
- **Build it yourself** with Xcode on a Mac, signed with your own Apple developer account:
[scripts/ios/README.md](scripts/ios/README.md) walks you through it.
Once SpeedBreaker is on your device:
@@ -370,7 +392,7 @@ Each page has a *Reset page* button.
| Sharpening | 0.00 to 1.00 | 0.30 (0 turns it off) |
| Anisotropic Filtering | Auto / Off / 2x / 4x / 8x / 16x / Original | Auto: 16x on dedicated graphics cards and M-series iPads, 4x elsewhere. Original uses each texture's own setting, as on the 360. |
| Mipmaps | On / Off | On, so distant textures don't shimmer |
| Internal Resolution | Auto / 1x (720p) / 2x (1440p) / 3x (2160p) | Auto: enough to cover your screen, within what the device can afford. On iPhone and iPad, Auto always picks 1x. |
| Internal Resolution | Auto / 1x (720p) / 1.5x (1080p) / 2x (1440p) / 3x (2160p) | Auto: enough to cover your screen, within what the device can afford (1x, 2x or 3x). On iPhone and iPad, Auto always picks 1x. |
**Audio:** Master Volume (100%), Mute (Off), and Mute in Background (Off), which silences the
game while its window isn't in front.
@@ -393,8 +415,11 @@ online, and only when you press it). The page also shows which build you're runn
let Frame Rate *Auto* fall back to a steady 30.
- **30 fps is a real option.** It's a steady 30 at full game speed, and the smooth choice in the
Steam Frame headset.
- **Between 1x and 2x,** try 1.5x (1080p): much sharper than 1x for a little over half the work
of 2x.
- **On iPhone and iPad,** keep Internal Resolution at Auto (1x). On the tested iPad Pro (M2), 2x
is too slow for races. Screen Resolution 75% saves more.
is too slow for races. The iPhone 18 Pro Max mostly holds 60 fps at 1.5x, but gets hot within
about ten minutes, and Frame Rate Auto then drops to 30. Screen Resolution 75% saves more.
- **Docked Deck or Steam Machine:** set the shortcut's Game Resolution to Native.
- **See what's happening:** Settings > Advanced > Performance Overlay.
@@ -441,6 +466,10 @@ or later on Apple silicon.
**The game stutters on my Steam Deck OLED.** Set the screen to 60 Hz (see
[Steam Deck setup](#steam-deck-and-steam-machine)).
**My laptop runs the game on the slow built-in graphics.** Since v0.1.2 SpeedBreaker picks the
dedicated GPU by itself. If it still doesn't, see the `[video] Vulkan devices:` line in
`last-run.log` and set `NFSMW_GPU` (see [Linux](#linux)).
**The picture is small or stretched on my TV or monitor in Game Mode.** Set the shortcut's
Properties > General > Game Resolution to Native.
@@ -474,7 +503,9 @@ Touch only works in SpeedBreaker's own screens; a three-finger tap opens the set
| Shader caches (safe to delete) | `~/.cache/speedbreaker/` | `~/Library/Caches/SpeedBreaker/` | Inside the app |
Deleting that folder, or deleting the iPhone or iPad app, deletes your saves. A game you
installed in a folder of your own stays there until you delete that folder.
installed in a folder of your own stays there until you delete that folder. In
[portable mode](#steam-deck-and-steam-machine) (SteamOS and Linux) all of it is in the
SpeedBreaker folder instead: `data/`, `data/game` and `cache/`.
### Reporting a bug
@@ -482,7 +513,7 @@ installed in a folder of your own stays there until you delete that folder.
2. A notice says where the zip file went: your **Desktop** (on SteamOS, pick it up in Desktop
Mode, or in the Desktop app on a Steam Frame), the **SpeedBreaker** folder in the Files app on
iPhone and iPad, or the data folder above when there is no Desktop. Its name says which
version you were running, for example `speedbreaker-bug-report-v0.1.1-<date>_<time>.zip`.
version you were running, for example `speedbreaker-bug-report-v0.1.2-<date>_<time>.zip`.
3. Open an [issue][issues], attach the zip, and say what you were doing, what you expected, what
happened, and roughly how long into the session it happened.
@@ -516,7 +547,7 @@ native program for x86-64 or arm64. A runtime written for this project stands in
overlay are drawn with Dear ImGui.
- **Audio:** XMA decoding done the way the hardware does it, frame by frame, with a patched,
decoder-only FFmpeg.
- **Resolution:** the 3D scene renders at 1x, 2x or 3x; the bloom and haze stay at the original
- **Resolution:** the 3D scene renders at 1x, 1.5x, 2x or 3x; the bloom and haze stay at the original
resolution, so the game keeps its colour grade.
- **Frame pacing:** the game's clock locks to the display's measured refresh (58.5 to 61.5 Hz),
one game step per refresh. 30 fps runs two game steps per frame.
@@ -537,6 +568,7 @@ SpeedBreaker --install <image or folder> [--dest <dir>] # install (into exactl
SpeedBreaker --verify <image or folder> # check every file without copying
SpeedBreaker --find-images # list the disc images it can find
SpeedBreaker --where # show where the game is installed
speedbreaker.sh --portable on|off # SteamOS and Linux: portable mode (copies, never deletes)
```
Without `--dest` the game goes to the default folder, as from the installer screen. `--dest`
+3 -3
View File
@@ -34,10 +34,10 @@ collected when the bundle is made).
| Project | Where | License | Text |
|---|---|---|---|
| **SDL3**: Copyright (C) 1997-2026 Sam Lantinga | Windows, input, audio and the app's lifecycle. In the Mac app and both SteamOS downloads. It includes HIDAPI (controllers; Copyright (c) 2010 Alan Ott, Signal 11 Software; under its BSD-style license, one of the three it offers) and yuv2rgb (Copyright (c) 2016 Adrien Descamps). | zlib; HIDAPI and yuv2rgb: BSD-3-Clause | [`LICENSES/SDL3-Zlib.txt`](LICENSES/SDL3-Zlib.txt); [`LICENSES/HIDAPI-BSD-3-Clause.txt`](LICENSES/HIDAPI-BSD-3-Clause.txt); [`LICENSES/yuv2rgb-BSD-3-Clause.txt`](LICENSES/yuv2rgb-BSD-3-Clause.txt) |
| **glslang**: The Khronos Group Inc. and others | Compiles the game's shaders while it runs. In the Mac app and both SteamOS downloads. | BSD-3-Clause and others (its own list) | [`LICENSES/glslang.txt`](LICENSES/glslang.txt) |
| **SDL3**: Copyright (C) 1997-2026 Sam Lantinga | Windows, input, audio and the app's lifecycle. In the Mac app, both SteamOS downloads and the iPhone and iPad IPA. It includes HIDAPI (controllers; Copyright (c) 2010 Alan Ott, Signal 11 Software; under its BSD-style license, one of the three it offers) and yuv2rgb (Copyright (c) 2016 Adrien Descamps). | zlib; HIDAPI and yuv2rgb: BSD-3-Clause | [`LICENSES/SDL3-Zlib.txt`](LICENSES/SDL3-Zlib.txt); [`LICENSES/HIDAPI-BSD-3-Clause.txt`](LICENSES/HIDAPI-BSD-3-Clause.txt); [`LICENSES/yuv2rgb-BSD-3-Clause.txt`](LICENSES/yuv2rgb-BSD-3-Clause.txt) |
| **glslang**: The Khronos Group Inc. and others | Compiles the game's shaders while it runs. In the Mac app, both SteamOS downloads and the iPhone and iPad IPA (without its optimizer). | BSD-3-Clause and others (its own list) | [`LICENSES/glslang.txt`](LICENSES/glslang.txt) |
| **SPIRV-Tools**: The Khronos Group Inc. and others | Used by glslang. In the Mac app and both SteamOS downloads. | Apache-2.0 | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt) |
| **MoltenVK** (with SPIRV-Cross): Copyright (c) 2015-2026 The Brenwill Workshop Ltd., The Khronos Group Inc. | Vulkan on Apple's Metal. In the Mac app (and an iPhone or iPad build). It includes cereal (Copyright (c) 2013-2022 Randolph Voorhies, Shane Grant; BSD-3-Clause), which saves its shader conversions, and SPIRV-Tools (below). | Apache-2.0; cereal: BSD-3-Clause | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt); [`LICENSES/cereal-BSD-3-Clause.txt`](LICENSES/cereal-BSD-3-Clause.txt) |
| **MoltenVK** (with SPIRV-Cross): Copyright (c) 2015-2026 The Brenwill Workshop Ltd., The Khronos Group Inc. | Vulkan on Apple's Metal. In the Mac app and the iPhone and iPad IPA (and an iPhone or iPad build). It includes cereal (Copyright (c) 2013-2022 Randolph Voorhies, Shane Grant; BSD-3-Clause), which saves its shader conversions, and SPIRV-Tools (below). | Apache-2.0; cereal: BSD-3-Clause | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt); [`LICENSES/cereal-BSD-3-Clause.txt`](LICENSES/cereal-BSD-3-Clause.txt) |
| **Vulkan Loader**: The Khronos Group Inc., Valve Corporation, LunarG, Inc. | In the Mac app (SteamOS has its own). It includes cJSON (Copyright (c) 2009-2017 Dave Gamble and cJSON contributors). | Apache-2.0; cJSON: MIT | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt); [`LICENSES/cJSON-MIT.txt`](LICENSES/cJSON-MIT.txt) |
| **Vulkan-Headers** and **SPIRV-Headers**: The Khronos Group Inc. | Headers the game, the Vulkan Loader, glslang and SPIRV-Tools are built with. | Apache-2.0 or MIT (Vulkan-Headers); MIT (SPIRV-Headers) | [`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt) or [`LICENSES/Vulkan-Headers-MIT.txt`](LICENSES/Vulkan-Headers-MIT.txt); [`LICENSES/SPIRV-Headers.txt`](LICENSES/SPIRV-Headers.txt) |
| **GNU C Library** (glibc) and **GCC's runtime libraries** (libstdc++, libgcc_s) | In the SteamOS x86-64 download only, from the Arch Linux packages it was built with (the download's `licenses/README.txt` names each package and version). The Steam Frame download uses SteamOS's own. | glibc: LGPL-2.1-or-later. GCC's runtime: GPL-3.0 with the GCC Runtime Library Exception | in the download's `licenses/` |
+2 -1
View File
@@ -57,7 +57,8 @@ date and time it started, next to any `crash-*.txt` and `hang-*.txt`
reports:
- Linux / SteamOS: `~/.local/share/speedbreaker/logs/`
(`$XDG_DATA_HOME/speedbreaker/logs/` if that is set)
(`$XDG_DATA_HOME/speedbreaker/logs/` if that is set; in portable mode,
`data/logs/` in the game's folder, next to `portable.txt`)
- macOS: `~/Library/Application Support/SpeedBreaker/logs/`
On SteamOS the launcher also leaves the latest session's output in
+110
View File
@@ -0,0 +1,110 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See glare.h.
//
// Two function overrides (the generated functions are weak; no ppc/
// regeneration):
// - sub_82225730, the sun code (projects the sun, counts its occlusion,
// draws its glow): keeps its view argument for the glow. r4 is the view's
// entry in the per-view table (0x82C3F590 + id * 384; logged: view 1 at
// 0x82C3F710, view 4 at 0x82C3FB90), its id at +0, which the code itself
// tests for the reflection views 4 and 5.
// - sub_822251C8, the quad builder: after the game builds the glow's quad
// (called from the sun code, ctx.lr = glare::kGlowCall) in a view the
// projection hook widens, its corners are fitted to the view scales.
// NFSMW_GLARE_FIT=0 leaves the glow as the game builds it (an A/B switch).
#include <stdafx.h>
#include "glare.h"
#include "ultrawide.h"
#include <kernel/function.h>
extern "C" PPC_FUNC(__imp__sub_82225730);
extern "C" PPC_FUNC(__imp__sub_822251C8);
namespace
{
const bool s_fit = [] {
const char* v = std::getenv("NFSMW_GLARE_FIT");
const bool on = !(v && v[0] == '0');
if (!on)
fprintf(stderr, "[glare] NFSMW_GLARE_FIT=0: the sun's glow is drawn as the game builds it (stretched off 16:9)\n");
return on;
}();
// The view the sun code is drawing for (its r4), on the thread running
// it; 0 outside it.
thread_local uint32_t t_sunView = 0;
float LoadFloat(uint8_t* base, uint32_t address)
{
const uint32_t bits = PPC_LOAD_U32(address);
float f;
memcpy(&f, &bits, sizeof(f));
return f;
}
void StoreFloat(uint8_t* base, uint32_t address, float f)
{
uint32_t bits;
memcpy(&bits, &f, sizeof(bits));
PPC_STORE_U32(address, bits);
}
}
PPC_FUNC(sub_82225730)
{
const uint32_t outer = t_sunView;
t_sunView = ctx.r4.u32;
__imp__sub_82225730(ctx, base);
t_sunView = outer;
}
PPC_FUNC(sub_822251C8)
{
const uint32_t lr = static_cast<uint32_t>(ctx.lr), verts = ctx.r5.u32;
__imp__sub_822251C8(ctx, base);
if (lr != game::glare::kGlowCall || !t_sunView)
return;
const float sx = game::AspectScaleX(), sy = game::AspectScaleY();
const uint32_t id = PPC_LOAD_U32(t_sunView);
const bool fit = s_fit && (sx != 1.0f || sy != 1.0f) && game::glare::WidenedViewId(id);
// Each view's first glow (and again when the scales change), in every
// log: the view, its corners as built, and what was done.
{
static std::mutex mutex;
static std::unordered_map<uint32_t, uint64_t> logged;
uint32_t sxBits, syBits;
memcpy(&sxBits, &sx, 4);
memcpy(&syBits, &sy, 4);
const uint64_t key = (uint64_t(sxBits) << 32) ^ syBits ^ (fit ? 1ull << 63 : 0);
std::lock_guard lock(mutex);
if (logged.size() < 64)
{
auto [it, added] = logged.try_emplace(id, ~key);
if (it->second != key)
{
it->second = key;
fprintf(stderr, "[glare] sun glow in view %u: corners (%.1f,%.1f) (%.1f,%.1f) (%.1f,%.1f) (%.1f,%.1f); view scale x %.4f y %.4f: %s\n",
id, LoadFloat(base, verts), LoadFloat(base, verts + 4), LoadFloat(base, verts + 16),
LoadFloat(base, verts + 20), LoadFloat(base, verts + 32), LoadFloat(base, verts + 36), LoadFloat(base, verts + 48),
LoadFloat(base, verts + 52), sx, sy,
fit ? "fitted to the view scale" : !s_fit ? "left as built (NFSMW_GLARE_FIT=0)"
: (sx == 1.0f && sy == 1.0f) ? "left as built (16:9)" : "left as built (a view the projection hook doesn't widen)");
}
}
}
if (!fit)
return;
// The corners: x, y at +0 and +4 of four 16-byte vertices.
float x[4], y[4];
for (uint32_t i = 0; i < 4; i++)
{
x[i] = LoadFloat(base, verts + i * 16);
y[i] = LoadFloat(base, verts + i * 16 + 4);
}
game::glare::FitSprite(x, y, sx, sy);
for (uint32_t i = 0; i < 4; i++)
{
StoreFloat(base, verts + i * 16, x[i]);
StoreFloat(base, verts + i * 16 + 4, y[i]);
}
}
+63
View File
@@ -0,0 +1,63 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The sun's glare on a screen of another shape than 16:9 (game/glare.cpp
// hooks it; tests/glare_fit_test.cpp checks this header alone).
//
// The glare is a screen-space sprite, not a view's geometry: the sun code
// (sub_82225730) projects the sun through the view, turns the result into
// pixels of the 1280x720 frame (view 0's target), and its quad builder
// (sub_822251C8) makes a rotated square of a fixed size in those pixels
// around that point: v0 = (cx - a, cy - b), v1 = (cx + b, cy - a),
// v2 = (cx + a, cy + b), v3 = (cx - b, cy + a), a = R (cos + sin),
// b = R (cos - sin). A pixel-to-clip matrix of its own (not the front
// end's) draws it. Under Hor+ and Vert+ the presenter stretches the frame by
// 1/scaleX in x or 1/scaleY in y (video::PlacePicture), so a square of frame
// pixels came out 1/scaleX wide or 1/scaleY tall: 1.33x taller at 4:3,
// 1.11x on a 16:10 Steam Deck, 1.34x wider at 3440x1440. The view's own
// geometry doesn't, because its projection was narrowed by the same scale
// (game/ultrawide.cpp).
//
// The fix scales the glow's corners about their centre by the view scales,
// x by scaleX and y by scaleY (one of them is 1), after the game built them:
// the stretch then gives back the square the game meant, with its angle, at
// the size it has against the 16:9 band (the world's, the HUD's). The centre,
// the sun's projected position, is already right. Only the glow is changed,
// and only in the views the projection hook widens: the sun's occlusion
// quad (the same builder, called from sub_82225438) keeps its 32x32 pixels,
// so the share of it that is visible is still counted against the game's
// expected 2048 samples; and in the environment cube's faces (views 18-23,
// square targets sampled by direction, never stretched) the glow stays as
// the game draws it.
#pragma once
#include <cstdint>
namespace game::glare
{
// Return addresses of sub_822251C8's two calls (ctx.lr at entry): the
// glow, from the sun code sub_82225730, and the occlusion quad, from
// sub_82225438.
constexpr uint32_t kGlowCall = 0x82225A40;
constexpr uint32_t kOcclusionQuadCall = 0x822254B0;
// The views game/ultrawide.cpp widens (the projection hook's set): the
// player's camera (1), 2, the road reflection (4, 5) and 17. The rest
// (the shadow maps 13-16, the environment cube's faces 18-23) keep the
// 16:9 projection.
inline bool WidenedViewId(uint32_t id)
{
return id == 1 || id == 2 || id == 4 || id == 5 || id == 17;
}
// The sprite's four corners (frame pixels), scaled about their centre:
// x by scaleX, y by scaleY. The centre is the mean of the corners (the
// builder's quad is symmetric about it).
inline void FitSprite(float x[4], float y[4], float scaleX, float scaleY)
{
const float cx = (x[0] + x[1] + x[2] + x[3]) * 0.25f, cy = (y[0] + y[1] + y[2] + y[3]) * 0.25f;
for (int i = 0; i < 4; i++)
{
x[i] = cx + (x[i] - cx) * scaleX;
y[i] = cy + (y[i] - cy) * scaleY;
}
}
}
+4 -2
View File
@@ -41,6 +41,7 @@
// of the build failing on an undefined symbol.
#include <stdafx.h>
#include "ultrawide.h"
#include "glare.h"
#include <kernel/function.h>
@@ -88,11 +89,12 @@ namespace
fprintf(stderr, "[uw] view %u: target %ux%u%s\n", id, w, h, widened ? ", widened" : "");
}
// The view at `view` (guest address) is one the game widens for 16:9.
// The view at `view` (guest address) is one the game widens for 16:9
// (game/glare.h keeps the set: the sun's glow is fitted in the same views).
bool WidenedView(uint32_t view)
{
uint32_t id = ByteSwap(*static_cast<const uint32_t*>(g_memory.Translate(view + 4)));
bool widen = id == 1 || id == 2 || id == 4 || id == 5 || id == 17;
bool widen = game::glare::WidenedViewId(id);
LogView(id, widen);
return widen;
}
+149 -5
View File
@@ -312,6 +312,104 @@ namespace gpu::bars
return n;
}
// A full-band backdrop: the results screen's picture after a race (the
// "WINNER" polaroid over asphalt and leaves; the title's and Controls'
// key art have the same shape) is one opaque textured quad of the front
// end, centred, x +-1.104 and y +-0.981 of the 16:9 band. On a 16:9
// screen it hides the world (a thin border of it shows at the top and
// bottom, as on the console, where a TV's overscan hid it); past the band
// (Hor+ beside it, Vert+ above and below it) the world rendered behind
// it showed: the race's road and cars at the sides of the results on an
// iPhone (2868x1320). What a 16:9 screen shows of it is kept, the art's
// own pixels too; everything else past the band (what no 16:9 screen
// shows) is filled black, as a 16:9 picture's letterbox. Thresholds in
// band units. The nearest draws it must not take: the fades and dims
// (+-1.29: flat, and the widen, stretch and cover rules' own), the pause
// dim (C8 alpha) and its gradients, the vid cam's noise and dark overlays
// (3C alpha, x +-0.70), the overlay at the intro's start (x +-1.104, B8
// alpha), the black frames (the 32x32 white texture), a menu backdrop the
// cover rule zooms under Vert+ (not left alone), the HUD's pieces (none
// covers the band).
struct BackdropRule
{
float centreMax = 0.01f; // |the box's centre|, each axis
float extentMinX = 0.99f; // half its width: the whole band's (to 1%)
float extentMinY = 0.95f; // half its height: nearly all of it
float extentMax = 1.20f; // each half extent: short of the fades' +-1.29
uint32_t minAlpha = 0xF0; // vertex alpha, all four
uint32_t minTexels = 128; // t0 at least this wide and tall (not the 32x32 white texture)
float cornerTolerance = 1e-3f;
};
// A backdrop: one axis-aligned rectangle, every vertex's alpha at least
// minAlpha, centred in the band and covering all of its width and nearly
// all of its height, not reaching the fades' extent. Its box goes to
// `box`. (The texture's size is a register check: BackdropCandidate.)
inline bool MatchBackdrop(const Vertex v[4], float scaleX, float scaleY, const BackdropRule& rule = {}, Box* box = nullptr)
{
if (!(scaleX > 0.0f && scaleY > 0.0f))
return false;
for (int i = 0; i < 4; i++)
if ((v[i].argb >> 24) < rule.minAlpha)
return false;
Box b;
if (!RectangleBox(v, rule.cornerTolerance, b))
return false;
const float midX = (b.x0 + b.x1) * 0.5f / scaleX, midY = (b.y0 + b.y1) * 0.5f / scaleY;
const float halfX = (b.x1 - b.x0) * 0.5f / scaleX, halfY = (b.y1 - b.y0) * 0.5f / scaleY;
if (!(std::fabs(midX) <= rule.centreMax && std::fabs(midY) <= rule.centreMax))
return false;
if (!(halfX >= rule.extentMinX && halfX < rule.extentMax && halfY >= rule.extentMinY && halfY < rule.extentMax))
return false;
if (box)
*box = b;
return true;
}
// What a backdrop fills, inside the draw's scissor: what lies past the
// band (the columns beside it under Hor+, the rows above and below it
// under Vert+; at most one of the view scales is below 1) and outside
// the backdrop's own box, up to kFillEdge: per side, the part beyond the
// box and, where the box reaches into that side, the parts above and
// below it (Hor+; left and right of it under Vert+). At most 6.
inline uint32_t BackdropRects(const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor, float scaleX, float scaleY,
Rect out[6])
{
uint32_t n = 0;
auto add = [&](float cx0, float cx1, float cy0, float cy1) {
if (cx1 <= cx0 || cy1 <= cy0)
return;
const Rect c = Intersect(ClipToHost(cx0, cx1, cy0, cy1, ps, hostW, hostH), scissor);
if (!Empty(c) && n < 6)
out[n++] = c;
};
const float e = kFillEdge;
if (scaleX < 1.0f)
{
// Right of the band: [scaleX, e] x [-e, e]; left: [-e, -scaleX].
const float in = std::min(std::max(b.x1, scaleX), e); // where the box ends in the right strip
add(in, e, -e, e);
add(scaleX, in, b.y1, e);
add(scaleX, in, -e, b.y0);
const float inL = std::max(std::min(b.x0, -scaleX), -e);
add(-e, inL, -e, e);
add(inL, -scaleX, b.y1, e);
add(inL, -scaleX, -e, b.y0);
}
else if (scaleY < 1.0f)
{
const float in = std::min(std::max(b.y1, scaleY), e);
add(-e, e, in, e);
add(b.x1, e, scaleY, in);
add(-e, b.x0, scaleY, in);
const float inB = std::max(std::min(b.y0, -scaleY), -e);
add(-e, e, -e, inB);
add(b.x1, e, inB, -scaleY);
add(-e, b.x0, inB, -scaleY);
}
return n;
}
// The posScale that stretches the quad's box over [-edge, edge] in clip
// x and y (x' = ax x + bx, likewise y), folded in: NDC = x' ps0 + ps2 =
// x (ax ps0) + (ps2 + bx ps0). Orientation is kept (ax, ay > 0) whatever
@@ -349,18 +447,19 @@ namespace gpu::bars
Rect scissor{}; // the draw's scissor (host pixels)
};
enum class Fill : uint8_t { None, LeftBar, RightBar, Blackout };
enum class Fill : uint8_t { None, LeftBar, RightBar, Blackout, Backdrop };
// What to draw after the draw: `rectCount` more copies of it (none for a
// bar that reaches the screen edge), with `posScale` and each rectangle
// as the scissor.
// What to draw after the draw: for a bar or a black frame, `rectCount`
// more copies of it (none for a bar that reaches the screen edge), with
// `posScale` and each rectangle as the scissor; for a backdrop, each
// rectangle cleared to opaque black (its picture must not be copied).
struct Plan
{
Fill fill = Fill::None;
Box box{}; // the quad's, clip space
uint32_t argb = 0; // its colour
uint32_t rectCount = 0;
Rect rects[2]{};
Rect rects[6]{};
float posScale[4]{};
};
@@ -382,6 +481,51 @@ namespace gpu::bars
d.t0Sampled && d.t0Width <= 32 && d.t0Height <= 32;
}
// The register checks for a backdrop, before any vertex is read:
// Candidate's, but over a large t0 (a picture, not the white texture).
inline bool BackdropCandidate(const Draw& d, const BackdropRule& rule = {})
{
const float* c = d.c;
const bool yScale = d.frontEndY > 0.0f ? std::fabs(std::fabs(c[5]) - d.frontEndY) <= d.frontEndY * 1e-5f
: d.frontEndY == 0.0f && d.scaleY >= 1.0f;
return (d.scaleX < 1.0f || d.scaleY < 1.0f) && !d.occlusion && d.prim == 13 && d.count == 4 && d.frontEndShader && d.leftAlone &&
c[3] == 0.0f && c[4] == 0.0f && c[6] == 0.0f && c[7] == 0.0f && yScale && (d.vte & 0xF) == 0xF &&
(d.blend0 == 0x07060706u || d.blend0 == 0x00010001u) && d.t0Sampled && d.t0Width >= rule.minTexels && d.t0Height >= rule.minTexels;
}
// The plan for a backdrop (PlanFill's for bars and black frames):
// vertex 0's alpha first, then the rest.
template <class ReadWord>
Plan PlanBackdrop(const Draw& d, ReadWord&& read, const BackdropRule& rule = {})
{
Plan p;
if (!BackdropCandidate(d, rule))
return p;
uint32_t colour = 0;
if (!read(0u, 3u, colour) || (colour >> 24) < rule.minAlpha)
return p;
const float* c = d.c;
Vertex v[4];
for (uint32_t k = 0; k < 4; k++)
{
uint32_t w[4];
for (uint32_t j = 0; j < 4; j++)
if (!read(k, j, w[j]))
return p;
float q[3];
std::memcpy(q, w, sizeof(q));
v[k] = { q[0] * c[0] + q[1] * c[1] + q[2] * c[2] + c[3], q[0] * c[4] + q[1] * c[5] + q[2] * c[6] + c[7], w[3] };
}
Box box{};
if (!MatchBackdrop(v, d.scaleX, d.scaleY, rule, &box))
return p;
p.fill = Fill::Backdrop;
p.box = box;
p.argb = v[0].argb;
p.rectCount = BackdropRects(box, d.posScale, d.hostW, d.hostH, d.scissor, d.scaleX, d.scaleY, p.rects);
return p;
}
// The plan for a draw. read(k, j, word): word j of the draw's vertex k
// (k 0..3 in draw order, through its indices; an ePoly's words 0..3 are
// x, y, z and the colour), false past the vertex buffer. Register checks
+163
View File
@@ -0,0 +1,163 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The pipeline cache file's header (GetCachePath()/pipelines.bin), checked
// against this device before the file goes to the driver. Vulkan-free:
// tests/pipeline_cache_header_test.cpp checks it alone; gpu/renderer.cpp
// hands it the file's bytes and the device's VkPhysicalDeviceProperties.
//
// vkGetPipelineCacheData's data starts with VkPipelineCacheHeaderVersionOne,
// 32 bytes, every field least significant byte first on any host:
// 0 headerSize (32) 4 headerVersion (1) 8 vendorID 12 deviceID
// 16 pipelineCacheUUID[16]
// The spec puts it there so an application can tell when its saved data no
// longer fits the device. A driver sets such data aside without a word
// (MoltenVK and Mesa compare the whole header), so the cache starts empty
// and every pipeline compiles again on first use: a brief stutter, once,
// and the next save writes this device's header. The header has no
// driverVersion field: drivers fold their version into pipelineCacheUUID
// (MoltenVK its revision, Mesa its build ID, NVIDIA its driver version), so
// a driver update shows up as a UUID change.
//
// MoltenVK's device ID on an Apple GPU encodes the OS version: OS major (8
// bits), OS minor (8), Mac GPU family (8), Apple GPU family (8)
// (MVKPhysicalDevice::getHighestGPUCapability; its UUID carries the same
// word). An M1 Pro reads 0x1a050207 on macOS 26.5 and 0x1b000207 on 27.0,
// so every macOS update discards the cache: on the 27.0.1 upgrade, one
// 103 ms frame at the first world entry. On SteamOS each Mesa update does
// the same through the UUID.
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
namespace gpu::pipeline_cache
{
constexpr size_t kHeaderBytes = 32;
constexpr uint32_t kHeaderVersionOne = 1; // VK_PIPELINE_CACHE_HEADER_VERSION_ONE
constexpr uint32_t kAppleVendor = 0x106B; // MoltenVK on Apple GPUs (Intel and AMD Macs report PCI IDs)
// Who made a cache: a file's header, or this device's properties
// (vendorID, deviceID, pipelineCacheUUID).
struct Maker
{
uint32_t vendorID = 0, deviceID = 0;
std::array<uint8_t, 16> uuid{};
};
enum class Verdict : uint8_t
{
Empty, // no data: nothing to load
Usable, // this GPU, driver and OS made it: the driver takes it
OtherMaker, // another GPU, driver or OS version made it: the driver would set it aside
Unreadable, // shorter than a header, or not a version-one header
};
struct HeaderCheck
{
Verdict verdict = Verdict::Empty;
size_t bytes = 0;
uint32_t headerSize = 0, headerVersion = 0; // as the file says (0 when shorter than a header)
Maker file; // Usable and OtherMaker: the file's header
bool vendorDiffers = false, deviceDiffers = false, uuidDiffers = false;
};
inline uint32_t ReadLE32(const uint8_t* p)
{
return uint32_t(p[0]) | uint32_t(p[1]) << 8 | uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24;
}
inline HeaderCheck CheckHeader(const uint8_t* data, size_t bytes, const Maker& device)
{
HeaderCheck c;
c.bytes = bytes;
if (bytes == 0)
return c;
c.verdict = Verdict::Unreadable;
if (bytes < kHeaderBytes)
return c;
c.headerSize = ReadLE32(data);
c.headerVersion = ReadLE32(data + 4);
// MoltenVK and Mesa take exactly this header (Mesa compares all 32
// bytes, headerSize included): a longer or newer one is unreadable.
if (c.headerSize != kHeaderBytes || c.headerVersion != kHeaderVersionOne)
return c;
c.file.vendorID = ReadLE32(data + 8);
c.file.deviceID = ReadLE32(data + 12);
std::memcpy(c.file.uuid.data(), data + 16, c.file.uuid.size());
c.vendorDiffers = c.file.vendorID != device.vendorID;
c.deviceDiffers = c.file.deviceID != device.deviceID;
c.uuidDiffers = c.file.uuid != device.uuid;
c.verdict = c.vendorDiffers || c.deviceDiffers || c.uuidDiffers ? Verdict::OtherMaker : Verdict::Usable;
return c;
}
// The UUID as 32 hex digits in byte order, as `xxd -p` shows the file
// (MoltenVK's: its revision, the device ID's word, then flags). Not
// 8-4-4-4-12, so a log line isn't taken for a device's identifier.
inline std::string UuidText(const std::array<uint8_t, 16>& uuid)
{
std::string out;
char hex[3];
for (uint8_t byte : uuid)
{
std::snprintf(hex, sizeof(hex), "%02x", byte);
out += hex;
}
return out;
}
// What differs, file -> device: "OS 26.5 -> 27.0, device ID 1a050207 ->
// 1b000207, cache UUID ... -> ...". The OS part only for MoltenVK on an
// Apple GPU, whose device ID carries it.
inline std::string DescribeChange(const HeaderCheck& c, const Maker& device)
{
std::string out;
char buf[96];
auto add = [&](const std::string& part) { out += (out.empty() ? "" : ", ") + part; };
if (c.vendorDiffers)
{
std::snprintf(buf, sizeof(buf), "vendor %04x -> %04x", c.file.vendorID, device.vendorID);
add(buf);
}
if (c.deviceDiffers)
{
if (!c.vendorDiffers && device.vendorID == kAppleVendor && (c.file.deviceID >> 16) != (device.deviceID >> 16))
{
std::snprintf(buf, sizeof(buf), "OS %u.%u -> %u.%u", c.file.deviceID >> 24, (c.file.deviceID >> 16) & 0xFF,
device.deviceID >> 24, (device.deviceID >> 16) & 0xFF);
add(buf);
}
std::snprintf(buf, sizeof(buf), "device ID %08x -> %08x", c.file.deviceID, device.deviceID);
add(buf);
}
if (c.uuidDiffers)
add("cache UUID " + UuidText(c.file.uuid) + " -> " + UuidText(device.uuid));
return out;
}
// The log's line for a file the driver would set aside, "" for Empty
// and Usable.
inline std::string LogLine(const HeaderCheck& c, const Maker& device)
{
constexpr const char* kRebuild = "rebuilding shaders, expect a brief stutter on first use";
const size_t kb = c.bytes / 1024;
char buf[160];
switch (c.verdict)
{
case Verdict::OtherMaker:
return "[renderer] pipeline cache from another GPU, driver or OS version: " + std::string(kRebuild) + " (" +
DescribeChange(c, device) + "; " + std::to_string(kb) + " KB set aside)";
case Verdict::Unreadable:
if (c.bytes < kHeaderBytes)
std::snprintf(buf, sizeof(buf), "%zu bytes, shorter than its header", c.bytes);
else
std::snprintf(buf, sizeof(buf), "header size %u, version %u; %zu KB set aside", c.headerSize, c.headerVersion, kb);
return "[renderer] pipeline cache file not readable: " + std::string(kRebuild) + " (" + buf + ")";
default:
return {};
}
}
}
+240
View File
@@ -0,0 +1,240 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The internal resolution as a ratio: on each axis `num` host pixels for
// every `den` guest pixels. 1x is 1/1, 2x 2/1, 3x 3/1, and 1.5x is 3/2:
// 1280x720 becomes 1920x1080. Vulkan-free; gpu/renderer.cpp uses it for
// every guest <-> host pixel mapping, and tests/render_scale_test.cpp checks
// it alone.
//
// Whole scales map guest pixel g to host pixels [g s, g s + s). At 3/2 a
// guest pixel is a pixel and a half, so the mapping works on blocks: every
// two guest pixels (an even one and the odd one after it) are exactly three
// host pixels. On each axis:
// - Edges: guest edge g is host edge floor(g num / den) (HostEdge). A
// guest rectangle's host one is its edges', so rectangles that share a
// guest edge share the host edge, with no gap and no overlap: draw
// scissors, clears, pass areas and what a resolve reads all use it. It is
// also the rasteriser's own rule for a pixel-aligned quad (a host pixel
// whose centre lies exactly on an odd guest edge belongs to the pixel
// after it, the top-left rule), so a scissor and the quad it clips agree.
// Even guest coordinates land exactly (g 3/2 is whole); the game's
// resolves are 8-pixel aligned and its tile offsets even, so every
// resolve, clear of a resolve and tile band maps exactly.
// - Ownership: guest pixel g owns host pixels [HostEdge(g), HostEdge(g+1)):
// at 3/2 an even pixel owns one, the odd one after it two. Each host
// pixel has exactly one owner (sbOwner).
// - The 1x average a resolve writes into guest memory weighs every host
// pixel by how much of it lies inside the guest pixel (sbOverlap: a box
// filter, 2/3 and 1/3 at 3/2), so the mean over any whole number of
// blocks is the host mean, and an edge between two colours lands where
// it was. Whole scales give every host pixel weight 1, as before.
// - A depth resolve keeps one sample per guest pixel: the first at a whole
// scale (as before), at 3/2 the host pixel holding the guest pixel's
// centre (sbCenterHost).
//
// The scaled resolve memory (renderer.cpp's ScaledMemory) keeps each host
// sample of a 32-bit tiled surface, found from the guest address of the texel
// that owns it, so a resolve (the writer) and a texture or the front buffer
// (the readers) meet whatever the pitch and base of each. In Xenos's 32-bit
// tiling a texel's word in its 4 KB page has x & 1 in bit 0 and y & 1 in bit 2
// (the other bits depend only on x >> 1 and y >> 1), so a 2x2 block of texels
// at even (x, y) is the words w, w + 1, w + 4, w + 5 of one page, whatever the
// pitch. A page's slot holds its blocks in order of their word with the
// sub-texel bits taken out (sbBlockIndex), each block's num.x x num.y host
// samples row by row. At 3/2: 256 blocks of 9 samples, 9 KB a 4 KB page; at
// a whole scale a block is one texel and this is the layout from before
// (word x s + j x sx + i).
//
// So 1.5x only covers surfaces whose texels come in whole blocks: a scaled
// texture or front buffer needs an even width and height at 3/2 (Exact),
// else it is used at 1x; a resolve needs an even origin, which the 8-pixel
// alignment gives (else it isn't kept at host resolution, logged once).
//
// The block arithmetic is written once, in tokens that are both GLSL and
// C++ (SB_SCALE_FUNCTIONS, as gpu/depth24_glsl.h does for its pack): the shaders
// get it as text (GLSL_SCALE_CODE, spliced into renderer.cpp's resolve,
// fill, untile and front-buffer shaders) and the test runs the same code.
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
// uint arithmetic only (wrapping 32-bit in both languages); `1u` literals,
// min/max of two uints, if, ?:. Every function's name starts "sb".
#define SB_SCALE_FUNCTIONS \
/* Host edge of guest edge g: floor(g num / den). */ \
uint sbHostEdge(uint g, uint num, uint den) { return g * num / den; } \
/* The host pixels covering guest pixels [0, g): ceil(g num / den), a target's or image's size. */ \
uint sbHostSize(uint g, uint num, uint den) { return (g * num + den - 1u) / den; } \
/* The guest edge whose host edge is h (h from sbHostEdge): ceil(h den / num). */ \
uint sbGuestEdge(uint h, uint num, uint den) { return (h * den + num - 1u) / num; } \
/* The guest pixel owning host pixel h: the g with HostEdge(g) <= h < HostEdge(g + 1). */ \
uint sbOwner(uint h, uint num, uint den) { return ((h + 1u) * den + num - 1u) / num - 1u; } \
/* How much of host pixel h lies in guest pixel g, in 1/den of a pixel (num for the whole guest pixel). */ \
uint sbOverlap(uint g, uint h, uint num, uint den) \
{ \
uint lo = max(g * num, h * den); \
uint hi = min((g + 1u) * num, (h + 1u) * den); \
return hi > lo ? hi - lo : 0u; \
} \
/* The one host pixel a depth resolve reads for guest pixel g. */ \
uint sbCenterHost(uint g, uint num, uint den) { return den == 1u ? g * num : ((2u * g + 1u) * num) / (2u * den); } \
/* A word of a 4 KB page of 32-bit tiled texels (0..1023): its block (the sub-texel bits out). */ \
uint sbBlockIndex(uint w, uint denX, uint denY) \
{ \
if (denY == 2u) \
w = (w & 3u) | ((w >> 3u) << 2u); \
if (denX == 2u) \
w = w >> 1u; \
return w; \
} \
/* A block's first word (its texel at even x and y). */ \
uint sbBlockWord(uint b, uint denX, uint denY) \
{ \
if (denX == 2u) \
b = b << 1u; \
if (denY == 2u) \
b = (b & 3u) | ((b >> 2u) << 3u); \
return b; \
} \
/* A word's texel within its block: x & 1 (bit 0) and y & 1 (bit 2), at 3/2 only. */ \
uint sbSubX(uint w, uint denX) { return denX == 2u ? (w & 1u) : 0u; } \
uint sbSubY(uint w, uint denY) { return denY == 2u ? ((w >> 2u) & 1u) : 0u; } \
/* A 4 KB page's samples: its blocks' x theirs. */ \
uint sbSlotWords(uint numX, uint numY, uint denX, uint denY) { return 1024u / (denX * denY) * (numX * numY); } \
/* Host sample (i, j) of word w's block, i < numX and j < numY from the block's corner: its word in the slot. */ \
uint sbSampleWord(uint w, uint i, uint j, uint numX, uint numY, uint denX, uint denY) \
{ \
return sbBlockIndex(w, denX, denY) * (numX * numY) + j * numX + i; \
}
#define SB_SCALE_STRINGIFY_(...) #__VA_ARGS__
#define SB_SCALE_STRINGIFY(...) SB_SCALE_STRINGIFY_(__VA_ARGS__)
// The functions as the shaders have them (comments are gone by now).
#define GLSL_SCALE_CODE "\n// Guest <-> host pixels at the internal resolution (gpu/render_scale.h).\n" \
SB_SCALE_STRINGIFY(SB_SCALE_FUNCTIONS) "\n"
namespace gpu
{
// SB_SCALE_FUNCTIONS as C++, the shaders' own tokens: member functions
// (defined in the class, so inline), called on a temporary, ScaleGlsl{}.
struct ScaleGlsl
{
using uint = uint32_t;
static uint min(uint a, uint b) { return a < b ? a : b; }
static uint max(uint a, uint b) { return a > b ? a : b; }
SB_SCALE_FUNCTIONS
};
// One axis: num host pixels for every den guest pixels (den 1 or 2).
struct AxisScale
{
uint32_t num = 1, den = 1;
bool operator==(const AxisScale&) const = default;
bool IsOne() const { return num == den; }
float Value() const { return float(num) / float(den); }
uint32_t Edge(uint32_t g) const { return ScaleGlsl{}.sbHostEdge(g, num, den); }
uint32_t Size(uint32_t g) const { return ScaleGlsl{}.sbHostSize(g, num, den); }
uint32_t GuestEdge(uint32_t h) const { return ScaleGlsl{}.sbGuestEdge(h, num, den); }
// Guest length g covers whole host pixels (every block whole).
bool Exact(uint32_t g) const { return g % den == 0; }
// A guest coordinate clamped to 0 first (rectangles' signed edges).
int32_t EdgeAt(int32_t g) const { return g <= 0 ? 0 : int32_t(Edge(uint32_t(g))); }
};
struct RenderScale
{
AxisScale x, y;
bool operator==(const RenderScale&) const = default;
bool IsOne() const { return x.IsOne() && y.IsOne(); }
// Host samples a block of den.x x den.y guest texels has.
uint32_t SamplesPerBlock() const { return x.num * y.num; }
// A 4 KB guest page's host samples (words) in the scaled memory.
uint32_t SlotWords() const { return ScaleGlsl{}.sbSlotWords(x.num, y.num, x.den, y.den); }
// A width x height surface maps to whole host pixels and blocks.
bool Exact(uint32_t width, uint32_t height) const { return x.Exact(width) && y.Exact(height); }
// Host pixels per guest pixel as a ratio (the occlusion weight):
// pixelsNum / pixelsDen (9/4 at 1.5x).
uint32_t PixelsNum() const { return x.num * y.num; }
uint32_t PixelsDen() const { return x.den * y.den; }
// Host pixels a frame of guest w x h has (sizes the scratch buffer).
uint64_t HostPixels(uint32_t w, uint32_t h) const { return uint64_t(x.Size(w)) * y.Size(h); }
static RenderScale Whole(uint32_t sx, uint32_t sy) { return { { sx, 1 }, { sy, 1 } }; }
};
// An axis from a number of half steps (2: 1x, 3: 1.5x, 4: 2x, 6: 3x),
// reduced (4/2 is 2/1).
inline AxisScale AxisFromHalves(uint32_t halves)
{
halves = std::max(halves, 2u);
return halves % 2 == 0 ? AxisScale{ halves / 2, 1 } : AxisScale{ halves, 2 };
}
// "1", "1.5", "2" (the log's and settings' spelling).
inline std::string AxisName(const AxisScale& a)
{
if (a.den == 1)
return std::to_string(a.num);
char text[16];
snprintf(text, sizeof(text), "%g", double(a.num) / double(a.den));
return text;
}
// "1x1", "1.5x1.5", "3x2": as the log has always named the scale.
inline std::string ScaleName(const RenderScale& s)
{
return AxisName(s.x) + "x" + AxisName(s.y);
}
// The scales the renderer takes on an axis: 1, 1.5, 2, 3, 4 (the
// whole ones of NFSMW_RENDER_SCALE before 1.5x, and 1.5x). Other
// fractions are refused: false.
inline bool AxisFromNumber(double v, AxisScale& out)
{
if (!(v >= 0.0)) // (NaN too)
return false;
v = std::max(v, 1.0); // (0 was 1x, as the old whole-number parse clamped it)
const double halves = std::round(v * 2.0);
if (std::fabs(v * 2.0 - halves) > 1e-6)
return false;
const uint32_t h = uint32_t(std::clamp(halves, 2.0, 8.0));
if (h % 2 == 1 && h != 3)
return false; // 2.5x, 3.5x: not offered
out = AxisFromHalves(h);
return true;
}
// NFSMW_RENDER_SCALE: "<s>" or "<sx>x<sy>", each 1, 1.5, 2, 3 or 4
// ("2", "1.5", "3x2", "1.5x1"); whole numbers above 4 are clamped to 4,
// as before. False for anything else (the caller logs it).
inline bool ParseRenderScale(const char* text, RenderScale& out)
{
if (!text || !*text)
return false;
char* end = nullptr;
const double a = std::strtod(text, &end);
if (end == text)
return false;
double b = a;
if (*end == 'x' || *end == 'X')
{
const char* second = end + 1;
b = std::strtod(second, &end);
if (end == second)
return false;
}
if (*end != '\0')
return false;
RenderScale s;
if (!AxisFromNumber(a, s.x) || !AxisFromNumber(b, s.y))
return false;
out = s;
return true;
}
}
+434 -206
View File
File diff suppressed because it is too large. Load diff
+59 -1
View File
@@ -3,6 +3,9 @@
#include "installer.h"
#include "locate.h"
#include "placement.h"
#include "portable_switch.h"
#include <user/paths.h>
#ifdef __APPLE__
#include <TargetConditionals.h>
@@ -120,7 +123,8 @@ namespace install
"usage: SpeedBreaker --install <image|folder> [--dest <dir>]\n"
" SpeedBreaker --verify <image|folder>\n"
" SpeedBreaker --find-images [folder...]\n"
" SpeedBreaker --where\n", message);
" SpeedBreaker --where\n"
" SpeedBreaker --portable on|off\n", message);
return 2;
}
@@ -211,6 +215,17 @@ namespace install
return images.empty() ? 1 : 0;
}
// Where settings, saves and caches are, and whether that's portable mode.
void PrintDataFolders()
{
const portable::Layout& layout = portable::Current();
if (layout.Active())
printf("Portable mode (%s in %s): data in %s, caches in %s\n", portable::kMarker, layout.root.c_str(),
GetUserPath().c_str(), GetCachePath().c_str());
else
printf("Data in %s, caches in %s\n", GetUserPath().c_str(), GetCachePath().c_str());
}
int RunWhere()
{
std::optional<GameInstall> game = FindGameInstall();
@@ -226,11 +241,46 @@ namespace install
if (!notice.empty())
printf("%s\n", notice.c_str());
printf("No game install found. Install one with --install <image|folder> [--dest <dir>].\n");
PrintDataFolders();
return 1;
}
const char* origin = game->origin == InstallOrigin::Environment ? "NFSMW_GAME_DIR"
: game->origin == InstallOrigin::Development ? "development checkout" : "installed";
printf("%s (%s%s)\n", game->path.c_str(), origin, game->marker ? ", verified by the installer" : "");
PrintDataFolders();
return 0;
}
// --portable on|off (install/portable_switch.h): the copy, with the
// game's copy shown as --install shows it.
int RunPortable(bool on)
{
if (!portable::kSupported)
{
fprintf(stderr, "Portable mode isn't available on macOS or iOS: SpeedBreaker keeps its data in the system's "
"folder for apps there.\n");
return 1;
}
InterruptGuard guard;
GameCopier copier = [](const fs::path& from, const fs::path& to) -> Result
{
std::unique_ptr<DiscSource> source;
if (Result r = OpenDiscSource(from, source); !r.Ok())
return r;
printf("Copying the game from %s (%s), checking every file:\n", from.c_str(),
FormatSize(GameManifest().totalBytes).c_str());
ProgressPrinter printer;
Result r = Install(*source, to, std::ref(printer), &s_cancel);
printer.Finish();
return r;
};
std::vector<std::string> said;
Result r = on ? MakePortable(BundleFolder(), HomeUserPath(), copier, said)
: LeavePortable(portable::Current(), HomeUserPath(), copier, said);
for (const std::string& line : said)
printf("%s\n", line.c_str());
if (!r.Ok())
return Failed(r);
return 0;
}
}
@@ -269,6 +319,14 @@ namespace install
return RunFindImages(std::vector<fs::path>(argv + i + 1, argv + argc));
if (arg == "--where")
return RunWhere();
if (arg == "--portable")
{
std::string_view what = i + 1 < argc ? std::string_view(argv[i + 1]) : std::string_view();
if ((what != "on" && what != "off") || i + 2 < argc)
return Usage("--portable takes on (copy this computer's SpeedBreaker data and game into SpeedBreaker's "
"folder, and keep them there from now on) or off (copy them back).");
return RunPortable(what == "on");
}
}
return std::nullopt;
}
+3 -1
View File
@@ -5,7 +5,9 @@
// SpeedBreaker --install <image|folder> [--dest <dir>] copy and verify (default dest: GetUserPath()/game)
// SpeedBreaker --verify <image|folder> hash-check every file without copying
// SpeedBreaker --find-images [folder...] list disc images in the usual places (or these)
// SpeedBreaker --where print where the game install was found
// SpeedBreaker --where print where the game install was found, and the data folders
// SpeedBreaker --portable on|off copy the data and the game into SpeedBreaker's folder and keep
// them there (portable mode), or back (install/portable_switch.h)
// Ctrl-C cancels an install cleanly (the partial copy is removed).
#pragma once
#include <optional>
+40 -26
View File
@@ -186,24 +186,41 @@ namespace install
}
}
std::optional<std::filesystem::path> RecordedInstallPath()
std::optional<std::filesystem::path> ReadInstallRecord(const std::filesystem::path& file, const portable::Layout& layout)
{
FILE* f = fopen(LocationFile().c_str(), "rb");
FILE* f = fopen(file.c_str(), "rb");
if (!f)
return std::nullopt;
char buffer[4096];
size_t n = fread(buffer, 1, sizeof(buffer) - 1, f);
fclose(f);
std::string text(buffer, n);
while (!text.empty() && (text.back() == '\n' || text.back() == '\r'))
text.pop_back();
if (text.empty() || text.front() != '/')
return std::nullopt;
// No trailing separator (records before v0.1.1 kept `--dest dir/`'s).
fs::path path = fs::path(text).lexically_normal();
while (path.has_relative_path() && path.filename().empty())
path = path.parent_path();
return path;
// Absolute, or (portable mode) relative to SpeedBreaker's folder; no
// trailing separator (records before v0.1.1 kept `--dest dir/`'s).
return portable::ParseRecord(std::string_view(buffer, n), layout);
}
bool WriteInstallRecord(const std::filesystem::path& file, const std::string& text)
{
std::error_code ec;
fs::create_directories(file.parent_path(), ec);
fs::path temp = file;
temp += ".tmp";
FILE* f = fopen(temp.c_str(), "wb");
if (!f)
return false;
std::string line = text + "\n";
bool ok = fwrite(line.data(), 1, line.size(), f) == line.size();
ok = fflush(f) == 0 && ok;
fsync(fileno(f));
fclose(f);
if (ok)
fs::rename(temp, file, ec);
return ok && !ec;
}
std::optional<std::filesystem::path> RecordedInstallPath()
{
return ReadInstallRecord(LocationFile(), portable::Current());
}
bool RecordInstallPath(const std::filesystem::path& dir)
@@ -218,25 +235,22 @@ namespace install
fs::remove(file, ec);
return !ec;
}
fs::create_directories(file.parent_path(), ec);
fs::path temp = file;
temp += ".tmp";
FILE* f = fopen(temp.c_str(), "wb");
if (!f)
return false;
std::string text = folder.string() + "\n";
bool ok = fwrite(text.data(), 1, text.size(), f) == text.size();
ok = fflush(f) == 0 && ok;
fsync(fileno(f));
fclose(f);
if (ok)
fs::rename(temp, file, ec);
return ok && !ec;
// In portable mode, a folder inside SpeedBreaker's is recorded
// relative to it, so the record still holds once the folder moves.
return WriteInstallRecord(file, portable::RecordText(folder, portable::Current()));
}
std::vector<InstallDestination> InstallDestinations()
{
std::vector<InstallDestination> out;
// Portable mode: SpeedBreaker's own folder (its data/game), and no
// drive's folder, which would leave the game behind when the folder
// moves. Choose a folder... still takes any folder.
if (portable::Current().Active())
{
out.push_back({ "SpeedBreaker's folder", DefaultInstallPath(), false });
return out;
}
out.push_back({ "Internal storage", DefaultInstallPath(), false });
auto addVolume = [&](const fs::path& volume)
{
+12 -1
View File
@@ -8,6 +8,8 @@
#include "manifest.h"
#include "result.h"
#include <user/portable.h>
#include <atomic>
#include <filesystem>
#include <optional>
@@ -53,10 +55,18 @@ namespace install
// Where the player chose to install when it wasn't DefaultInstallPath()
// (an SD card, a USB drive): recorded in GetUserPath()/game-location by
// the installer, and searched by FindGameInstall after NFSMW_GAME_DIR and
// ./game/files. Recording the default path removes the record.
// ./game/files. Recording the default path removes the record. In
// portable mode a folder inside SpeedBreaker's own is recorded relative to
// it (user/portable.h), so the record moves with the folder.
std::optional<std::filesystem::path> RecordedInstallPath();
bool RecordInstallPath(const std::filesystem::path& dir);
// The same for any record file, read as `layout` says (a relative record
// only in portable mode), and a record's text written to one, atomically
// (install/portable_switch.h, which moves records between data folders).
std::optional<std::filesystem::path> ReadInstallRecord(const std::filesystem::path& file, const portable::Layout& layout);
bool WriteInstallRecord(const std::filesystem::path& file, const std::string& text);
struct InstallDestination
{
std::string label; // "Internal storage", or the drive's name
@@ -66,6 +76,7 @@ namespace install
// Where the installer offers to put the game: DefaultInstallPath() first,
// then a folder on each mounted removable drive (<drive>/speedbreaker/game; SpeedBreaker on macOS).
// In portable mode, only DefaultInstallPath() (in SpeedBreaker's folder).
std::vector<InstallDestination> InstallDestinations();
struct DiscImageCandidate
+37 -1
View File
@@ -118,6 +118,29 @@ namespace install
strerror(error ? error : EACCES));
}
// Portable mode: the parts of SpeedBreaker's folder (`root`) the game
// can't go in, and why ("" for the rest of it). `named`: the folder
// as the player named it.
std::string PortableReserved(const fs::path& gameDir, const fs::path& named, const fs::path& root)
{
if (gameDir == root)
return std::format("{} is SpeedBreaker's own folder, which holds the program. Choose a folder inside it, "
"such as {} (where the game goes by default), or another folder.", root.string(),
(root / portable::kDataFolder / "game").string());
const fs::path relative = gameDir.lexically_relative(root);
const fs::path first = *relative.begin();
if (first == "lib" || first == "licenses")
return std::format("{} is inside SpeedBreaker's {} folder, which holds the program's own files: an update "
"overwrites them. Choose another folder.", named.string(), first.string());
if (first == portable::kCacheFolder)
return std::format("{} is inside SpeedBreaker's cache folder, which can be deleted at any time. Choose "
"another folder.", named.string());
if (relative == portable::kDataFolder)
return std::format("{} holds your settings and saves. Choose a folder inside it, such as {} (where the game "
"goes by default), or another folder.", gameDir.string(), (gameDir / "game").string());
return {};
}
// The refusals every game folder gets, picked or exact. Ok if none.
// `picked` is the folder the player chose (gameDir, or the folder
// gameDir would be made in): the messages name it, not a subfolder
@@ -130,7 +153,15 @@ namespace install
if (!context.home.empty() && gameDir == Normal(context.home))
return Refusal(Error::DestinationNotWritable, std::format("{} is your home folder itself. Choose a folder in "
"it instead.", gameDir.string()), gameDir);
if (fs::path bundle = Normal(context.bundle); !bundle.empty() && IsAtOrInside(gameDir, bundle))
// Portable mode: SpeedBreaker's folder is where the player keeps
// the game (data/game by default), except its own parts.
if (fs::path root = Normal(context.portable); !root.empty() && IsAtOrInside(gameDir, root))
{
const fs::path& named = IsAtOrInside(picked, root) ? picked : gameDir;
if (std::string message = PortableReserved(gameDir, named, root); !message.empty())
return Refusal(Error::DestinationNotWritable, std::move(message), gameDir);
}
else if (fs::path bundle = Normal(context.bundle); !bundle.empty() && IsAtOrInside(gameDir, bundle))
{
const fs::path& named = IsAtOrInside(picked, bundle) ? picked : gameDir;
std::string message;
@@ -170,6 +201,9 @@ namespace install
Result FileSystemRules(const fs::path& gameDir, const fs::path& picked, const FolderFacts& facts,
const PlacementContext& context, std::vector<std::string>& notes)
{
if (fs::path root = Normal(context.portable); !root.empty() && !IsAtOrInside(gameDir, root))
notes.push_back(std::format("This folder is outside SpeedBreaker's folder ({}): in portable mode, the game "
"there won't move with it.", root.string()));
if (context.macOS)
{
// iCloud Drive (and Desktop and Documents, when the Mac keeps
@@ -746,6 +780,8 @@ namespace install
context.home = Resolved(home);
if (fs::path bundle = BundleFolder(); !bundle.empty())
context.bundle = Resolved(bundle);
if (const portable::Layout& layout = portable::Current(); layout.Active())
context.portable = Resolved(layout.root);
context.installs.push_back(Resolved(DefaultInstallPath()));
if (std::optional<fs::path> recorded = RecordedInstallPath())
context.installs.push_back(Resolved(*recorded));
+7
View File
@@ -27,6 +27,12 @@
// or an uninstall replaces it, and on the Mac the app's signature would
// break) or anything inside it, or a folder inside an install (the
// default one or the one recorded last time).
// - Portable mode (user/portable.h): SpeedBreaker's own folder is where
// the game goes (data/game by default), so inside it is fine, except the
// folder's top itself (the program's), lib/ and licenses/ (the
// download's own files, overwritten by an update), data/ itself
// (settings and saves) and cache/ (deleted at will). A folder outside it
// gets a note: the game there won't move with the folder.
// - Never into an existing folder SpeedBreaker can't write: Install
// replaces it or moves it aside, which macOS refuses for a folder its
// owner can't write, after the whole copy.
@@ -120,6 +126,7 @@ namespace install
{
std::filesystem::path home; // the home folder (empty: unknown)
std::filesystem::path bundle; // SpeedBreaker's own folder (empty: unknown)
std::filesystem::path portable; // the same in portable mode (user/portable.h); empty otherwise
std::vector<std::filesystem::path> installs; // installs nothing may go inside: the default and the recorded one
uint64_t largestFile = 0; // the manifest's biggest file
bool macOS = false;
+374
View File
@@ -0,0 +1,374 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See portable_switch.h.
#include "portable_switch.h"
#include "installer.h"
#include "locate.h"
#include "placement.h"
#include <algorithm>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <format>
#include <optional>
#include <system_error>
namespace install
{
namespace
{
namespace fs = std::filesystem;
Result Fail(Error error, std::string message, const fs::path& path = {})
{
Result r;
r.error = error;
r.message = std::move(message);
r.file = path.string();
return r;
}
// Not there, or a folder with nothing in it (hidden files count).
bool EmptyOrMissing(const fs::path& dir)
{
std::error_code ec;
fs::file_status status = fs::symlink_status(dir, ec);
if (status.type() == fs::file_type::not_found)
return true;
if (ec || !fs::is_directory(status))
return false;
fs::directory_iterator it(dir, ec);
return !ec && it == fs::directory_iterator();
}
// An unfinished copy from an earlier attempt goes (only one carrying
// the tag: anything else of that name is the player's).
Result ClearPartial(const fs::path& partial, std::vector<std::string>& said)
{
std::error_code ec;
fs::file_status status = fs::symlink_status(partial, ec);
if (status.type() == fs::file_type::not_found)
return {};
std::error_code tagEc;
if (!fs::is_directory(status) || !fs::is_regular_file(fs::symlink_status(partial / kPortablePartialTag, tagEc)))
return Fail(Error::DestinationNotEmpty, std::format("{} is in the way, and it isn't an unfinished copy of "
"SpeedBreaker's. Move it or delete it, then try again.", partial.string()), partial);
fs::remove_all(partial, ec);
if (ec)
return Fail(Error::WriteError, std::format("Couldn't delete {}, an unfinished copy from an earlier attempt ({}). "
"Delete it, then try again.", partial.string(), ec.message()), partial);
said.push_back(std::format("Deleted {}, an unfinished copy from an earlier attempt.", partial.string()));
return {};
}
Result StartPartial(const fs::path& partial)
{
std::error_code ec;
fs::create_directories(partial.parent_path(), ec);
fs::create_directory(partial, ec);
if (!ec)
{
FILE* f = fopen((partial / kPortablePartialTag).string().c_str(), "wb");
if (f && fputs("An unfinished copy of SpeedBreaker's data (speedbreaker.sh --portable).\n", f) >= 0 && fclose(f) == 0)
return {};
if (f)
fclose(f);
ec = std::error_code(errno ? errno : EIO, std::generic_category());
}
return Fail(Error::DestinationNotWritable, std::format("SpeedBreaker can't write to {} ({}): nothing was copied.",
partial.parent_path().string(), ec.message()), partial);
}
// The installed game a data folder's record and default point to, as
// FindGameInstall picks it: the recorded one if it's usable, else the
// default one.
struct FoundGame
{
fs::path dir; // the game to copy (empty: none)
std::optional<fs::path> lost; // a recorded install that isn't usable (and no default one is)
std::vector<fs::path> skipped; // left out of the data copy: the game folders and their in-between folders
};
FoundGame FindGame(const fs::path& data, const portable::Layout& layout, const Manifest& manifest)
{
FoundGame found;
fs::path fallback = data / "game";
std::optional<fs::path> recorded = ReadInstallRecord(data / "game-location", layout);
if (recorded && IsUsableInstall(*recorded, true, manifest))
found.dir = *recorded;
else if (IsUsableInstall(fallback, true, manifest))
found.dir = fallback;
else if (recorded)
found.lost = recorded;
for (const std::optional<fs::path>& game : { std::optional<fs::path>(fallback), recorded })
if (game)
{
fs::path dir = ResolvedFolder(*game);
found.skipped.push_back(dir);
found.skipped.push_back(fs::path(dir.string() + ".partial"));
found.skipped.push_back(fs::path(dir.string() + ".previous"));
}
return found;
}
struct CopyStats
{
uintmax_t files = 0, bytes = 0;
std::vector<std::string> skipped; // links and special files, not copied
};
// `from`'s contents into the folder `to` (made by the caller): folders
// and files, with their modification times (the bug report and the log
// rotation sort by them). Left out: the game's record at the top, and
// the folders in `skip` (the game and its in-between folders).
Result CopyData(const fs::path& from, const fs::path& to, const std::vector<fs::path>& skip, CopyStats& stats, bool top)
{
std::error_code ec;
fs::directory_iterator it(from, ec);
for (fs::directory_iterator end; !ec && it != end; it.increment(ec))
{
const fs::path source = it->path();
const fs::path name = source.filename();
if (top && (name == "game-location" || name == "game-location.tmp" || name == ".write-check"))
continue;
if (std::any_of(skip.begin(), skip.end(), [&](const fs::path& s) { return IsAtOrInside(source, s); }))
continue;
std::error_code typeEc;
fs::file_status status = it->symlink_status(typeEc);
const fs::path target = to / name;
if (fs::is_symlink(status) || (!fs::is_directory(status) && !fs::is_regular_file(status)))
{
stats.skipped.push_back(source.string());
continue;
}
std::error_code copyEc;
if (fs::is_directory(status))
{
fs::create_directory(target, copyEc);
if (copyEc)
return Fail(Error::WriteError, std::format("Couldn't make {} ({}): nothing was changed.", target.string(),
copyEc.message()), target);
if (Result r = CopyData(source, target, skip, stats, false); !r.Ok())
return r;
}
else
{
fs::copy_file(source, target, fs::copy_options::none, copyEc);
if (copyEc)
return Fail(Error::WriteError, std::format("Couldn't copy {} to {} ({}): nothing was changed.",
source.string(), target.string(), copyEc.message()), source);
stats.files++;
stats.bytes += fs::file_size(source, typeEc);
}
std::error_code timeEc;
fs::last_write_time(target, fs::last_write_time(source, timeEc), timeEc);
}
if (ec)
return Fail(Error::ReadError, std::format("Couldn't read {} ({}): nothing was changed.", from.string(), ec.message()),
from);
return {};
}
void SayCopied(const CopyStats& stats, const fs::path& from, const fs::path& to, std::vector<std::string>& said)
{
if (stats.files == 0)
said.push_back(std::format("{} holds no settings, saves or logs: nothing of them to copy.", from.string()));
else
said.push_back(std::format("Copied settings, saves and logs ({} files, {}) from {} to {}.", stats.files,
FormatSize(stats.bytes), from.string(), to.string()));
for (const std::string& path : stats.skipped)
said.push_back(std::format("Not copied (a link or a special file): {}", path));
}
// A name for the old folder that isn't taken: <folder>.before-portable-off-<UTC date>-<time>[-N].
fs::path AsideName(const fs::path& folder)
{
std::string stamp = std::format("{:%Y%m%d-%H%M%S}",
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now()));
fs::path aside = folder.string() + ".before-portable-off-" + stamp;
std::error_code ec;
for (int n = 2; fs::exists(fs::symlink_status(aside, ec)); n++)
aside = folder.string() + ".before-portable-off-" + stamp + "-" + std::to_string(n);
return aside;
}
bool WriteMarker(const fs::path& file)
{
fs::path temp = file;
temp += ".tmp";
FILE* f = fopen(temp.string().c_str(), "wb");
if (!f)
return false;
bool ok = fputs("SpeedBreaker portable mode: with this file here, SpeedBreaker keeps its settings, saves, logs and "
"caches in this folder (data/ and cache/) and installs the game into data/game. Delete this file to use this "
"computer's own data folder again (README.md, \"Portable mode\").\n", f) >= 0;
ok = fclose(f) == 0 && ok;
std::error_code ec;
if (ok)
fs::rename(temp, file, ec);
else
fs::remove(temp, ec);
return ok && !ec;
}
}
Result MakePortable(const std::filesystem::path& root, const std::filesystem::path& home, const GameCopier& copyGame,
std::vector<std::string>& said, const Manifest& manifest)
{
if (root.empty() || !root.is_absolute())
return Fail(Error::NotFound, "SpeedBreaker can't tell which folder it runs from, so it can't make that folder "
"portable. Start it with the speedbreaker.sh in that folder.");
const fs::path folder = ResolvedFolder(root);
const fs::path data = folder / portable::kDataFolder, marker = folder / portable::kMarker;
std::error_code ec;
if (fs::exists(fs::symlink_status(marker, ec)))
return Fail(Error::DestinationNotEmpty, std::format("Portable mode is already on ({} is there): nothing was copied.",
marker.string()), marker);
if (!EmptyOrMissing(data))
return Fail(Error::DestinationNotEmpty, std::format("{} already holds files (from an earlier portable setup?), so "
"nothing was copied. To use them as they are, create {} yourself. To copy this computer's data instead, move "
"{} somewhere else first.", data.string(), marker.string(), data.string()), data);
const fs::path source = ResolvedFolder(home);
FoundGame game = FindGame(source, portable::Layout{}, manifest);
if (game.dir.empty() && game.lost)
return Fail(Error::NotFound, std::format("The game is installed in {}, which isn't there now or isn't a current "
"install (a drive not connected?), so nothing was copied. Connect it and try again. If it's gone for good, "
"delete {} and try again: SpeedBreaker then installs the game into {} at its first start.",
game.lost->string(), (source / "game-location").string(), (data / "game").string()), *game.lost);
const fs::path partial = folder / (std::string(portable::kDataFolder) + ".partial");
if (Result r = ClearPartial(partial, said); !r.Ok())
return r;
if (Result r = StartPartial(partial); !r.Ok())
return r;
auto abandon = [&](Result r) {
std::error_code removeEc;
fs::remove_all(partial, removeEc);
return r;
};
CopyStats stats;
if (fs::is_directory(source, ec))
if (Result r = CopyData(source, partial, game.skipped, stats, true); !r.Ok())
return abandon(r);
if (!game.dir.empty())
if (Result r = copyGame(game.dir, partial / "game"); !r.Ok())
return abandon(r);
fs::remove(partial / kPortablePartialTag, ec);
fs::remove(data, ec); // an empty one (EmptyOrMissing): replaced
ec.clear();
fs::rename(partial, data, ec);
if (ec)
return abandon(Fail(Error::WriteError, std::format("Couldn't rename {} to {} ({}): nothing was changed.",
partial.string(), data.string(), ec.message()), data));
if (!WriteMarker(marker))
return Fail(Error::WriteError, std::format("The data was copied to {}, but {} couldn't be written. Create it "
"yourself (an empty file will do) to turn portable mode on.", data.string(), marker.string()), marker);
SayCopied(stats, source, data, said);
if (!game.dir.empty())
said.push_back(std::format("Copied the game to {} and checked every file.", (data / "game").string()));
else
said.push_back(std::format("No installed game was found, so none was copied: SpeedBreaker installs it into {} "
"at its first start.", (data / "game").string()));
said.push_back(std::format("Portable mode is on ({}): SpeedBreaker keeps everything in {} now. Move or copy that "
"whole folder to take it all along.", marker.string(), folder.string()));
if (stats.files || !game.dir.empty())
said.push_back(std::format("Nothing was removed: the old copies are still in {}{}. Delete them once you're happy "
"with the portable folder, or keep them as a backup.", source.string(),
game.dir.empty() || IsAtOrInside(ResolvedFolder(game.dir), source) ? ""
: std::format(" (and the game in {})", game.dir.string())));
return {};
}
Result LeavePortable(const portable::Layout& layout, const std::filesystem::path& home, const GameCopier& copyGame,
std::vector<std::string>& said, const Manifest& manifest)
{
if (!layout.Active())
return Fail(Error::NotFound, std::format("Portable mode isn't on (there's no {} in SpeedBreaker's folder): nothing "
"to do.", portable::kMarker));
const fs::path target = ResolvedFolder(home);
const fs::path marker = layout.root / portable::kMarker;
if (IsAtOrInside(target, layout.root))
return Fail(Error::DestinationNotWritable, std::format("This computer's data folder, {}, is inside SpeedBreaker's "
"folder ({}): nothing was copied. Is XDG_DATA_HOME set to a folder in it?", target.string(),
layout.root.string()), target);
FoundGame game = FindGame(layout.data, layout, manifest);
const bool copyTheGame = !game.dir.empty() && IsAtOrInside(ResolvedFolder(game.dir), layout.root);
const fs::path partial = fs::path(target.string() + ".partial");
if (Result r = ClearPartial(partial, said); !r.Ok())
return r;
if (Result r = StartPartial(partial); !r.Ok())
return r;
auto abandon = [&](Result r) {
std::error_code removeEc;
fs::remove_all(partial, removeEc);
return r;
};
CopyStats stats;
std::error_code ec;
if (fs::is_directory(layout.data, ec))
if (Result r = CopyData(layout.data, partial, game.skipped, stats, true); !r.Ok())
return abandon(r);
if (copyTheGame)
{
if (Result r = copyGame(game.dir, partial / "game"); !r.Ok())
return abandon(r);
}
else if (!game.dir.empty() && !WriteInstallRecord(partial / "game-location", portable::RecordText(game.dir, {})))
return abandon(Fail(Error::WriteError, std::format("Couldn't write {}: nothing was changed.",
(partial / "game-location").string()), partial));
fs::remove(partial / kPortablePartialTag, ec);
// The data folder there now (usually the one from before portable
// mode) is kept, renamed aside; an empty one is replaced.
std::optional<fs::path> aside;
if (fs::exists(fs::symlink_status(target, ec)))
{
if (EmptyOrMissing(target))
fs::remove(target, ec);
else
{
aside = AsideName(target);
fs::rename(target, *aside, ec);
if (ec)
return abandon(Fail(Error::WriteError, std::format("Couldn't rename {} to {} ({}): nothing was changed.",
target.string(), aside->string(), ec.message()), target));
}
}
ec.clear();
fs::rename(partial, target, ec);
if (ec)
{
Result r = Fail(Error::WriteError, std::format("Couldn't rename {} to {} ({}): nothing was changed.", partial.string(),
target.string(), ec.message()), target);
std::error_code backEc;
if (aside)
fs::rename(*aside, target, backEc);
return abandon(r);
}
fs::remove(marker, ec);
if (ec)
return Fail(Error::WriteError, std::format("The data was copied to {}, but {} couldn't be removed ({}). Delete it "
"yourself to turn portable mode off.", target.string(), marker.string(), ec.message()), marker);
if (aside)
said.push_back(std::format("Renamed the data folder that was already there, {}, to {}: kept as it was. Delete it "
"once you're happy.", target.string(), aside->string()));
SayCopied(stats, layout.data, target, said);
if (copyTheGame)
said.push_back(std::format("Copied the game to {} and checked every file.", (target / "game").string()));
else if (!game.dir.empty())
said.push_back(std::format("The game stays where it is, {} (outside SpeedBreaker's folder), and is recorded there.",
game.dir.string()));
else
said.push_back("No installed game was found, so none was copied: SpeedBreaker installs it at its first start.");
said.push_back(std::format("Portable mode is off ({} removed): SpeedBreaker uses {} again. Nothing in {} was removed: "
"delete it once you're happy, or keep it as a backup.", marker.string(), target.string(), layout.root.string()));
return {};
}
}
+58
View File
@@ -0,0 +1,58 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Switching an existing setup into portable mode (user/portable.h) and back,
// with a copy: speedbreaker.sh --portable on|off (install_cli.cpp). Making or
// deleting portable.txt by hand switches too, with nothing copied (a fresh
// start, or this computer's data as it was).
//
// on: this computer's data folder (settings, saves, logs; not the game
// folder or its record) is copied into SpeedBreaker's data/, then the
// installed game (the default one or the recorded one) into
// data/game, verified (GameCopier: the installer's copy, every file
// hashed against the manifest), then portable.txt is written.
// off: data/ (not the game) is copied into this computer's data folder,
// and the game into its game/ when it's in SpeedBreaker's folder; a
// game recorded outside it stays recorded where it is. A data folder
// already there (usually the one from before "on") is renamed aside
// to <name>.before-portable-off-<date>-<time>, then portable.txt is
// removed.
// The command line refuses both on Apple platforms (portable::kSupported);
// the functions themselves work anywhere (tests run them on a Mac).
// Copies, never moves: the source is left as it was, and the messages say
// what can be deleted once the player is happy. Each copy is built in a
// tagged "<folder>.partial" beside its destination and renamed into place at
// the end, with the marker changed last, so an interruption leaves the
// previous mode working; the next attempt replaces its own unfinished copy
// (one with the tag), and refuses anything else of that name.
#pragma once
#include "result.h"
#include <user/portable.h>
#include <filesystem>
#include <functional>
#include <string>
#include <vector>
namespace install
{
struct Manifest;
const Manifest& GameManifest();
// Copies the installed game in `from` into `to` (which doesn't exist),
// verified. The command line's: OpenDiscSource + Install, with progress.
using GameCopier = std::function<Result(const std::filesystem::path& from, const std::filesystem::path& to)>;
// The tag file in an unfinished copy (see above).
inline constexpr const char* kPortablePartialTag = ".speedbreaker-portable-partial";
// --portable on. `root`: SpeedBreaker's folder (absolute; portable.txt
// not in it yet); `home`: this computer's data folder (HomeUserPath()).
// `said` gets what was done, a line each, for the terminal.
Result MakePortable(const std::filesystem::path& root, const std::filesystem::path& home, const GameCopier& copyGame,
std::vector<std::string>& said, const Manifest& manifest = GameManifest());
// --portable off. `layout`: portable mode's (active); `home` as above.
Result LeavePortable(const portable::Layout& layout, const std::filesystem::path& home, const GameCopier& copyGame,
std::vector<std::string>& said, const Manifest& manifest = GameManifest());
}
+35 -3
View File
@@ -4,6 +4,8 @@
#include "memory.h"
#ifndef _WIN32
#include <cerrno>
#include <cstdlib>
#include <fcntl.h>
#include <unistd.h>
#ifdef __APPLE__
@@ -23,6 +25,7 @@ bool Memory::MapPhysicalMirrors()
if (kr != KERN_SUCCESS)
{
fprintf(stderr, "[memory] mach_make_memory_entry_64: %s\n", mach_error_string(kr));
failedError = kr;
return false;
}
bool ok = true;
@@ -32,11 +35,14 @@ bool Memory::MapPhysicalMirrors()
int fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd < 0)
{
failedError = errno;
perror("[memory] shm_open");
return false;
}
shm_unlink(name); // the mappings keep it alive
bool ok = ftruncate(fd, PHYSICAL_SIZE) == 0;
if (!ok)
failedError = errno;
#endif
struct View { uint32_t guest; size_t offset; size_t length; };
@@ -67,11 +73,13 @@ bool Memory::MapPhysicalMirrors()
if (kr != KERN_SUCCESS)
{
fprintf(stderr, "[memory] vm_map physical view: %s\n", mach_error_string(kr));
failedError = kr;
ok = false;
}
#else
if (mmap(at, v.length, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, off_t(offset)) == MAP_FAILED)
{
failedError = errno;
perror("[memory] mmap physical view");
ok = false;
}
@@ -100,13 +108,28 @@ Memory::Memory()
DWORD oldProtect;
VirtualProtect(base, 4096, PAGE_NOACCESS, &oldProtect);
#else
base = (uint8_t*)mmap((void*)0x100000000ull, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
// NFSMW_TEST_RESERVE_FAIL=1|2: fail the reservation or the mirrors as an
// iOS without room would (memory_status.h), to see the startup message
// anywhere.
const char* testFail = std::getenv("NFSMW_TEST_RESERVE_FAIL");
int failStep = testFail ? std::atoi(testFail) : 0;
if (base == (uint8_t*)MAP_FAILED)
// SpeedBreaker: one contiguous 4 GB, since guest addresses are offsets
// from base. iOS makes room for it with Extended Virtual Addressing or
// Increased Memory Limit (scripts/ios/SpeedBreaker/SpeedBreaker.entitlements,
// kernel/memory_status.h); without them only about 5 GB is free in one
// piece, so this runs in static initialization, before anything else
// takes address space. If it fails (ENOMEM), main.cpp says so on screen.
base = failStep == 1 ? (uint8_t*)MAP_FAILED
: (uint8_t*)mmap((void*)0x100000000ull, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
if (base == (uint8_t*)MAP_FAILED && failStep != 1)
base = (uint8_t*)mmap(NULL, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
if (base == (uint8_t*)MAP_FAILED)
{
failedStep = 1;
failedError = failStep == 1 ? ENOMEM : errno;
base = nullptr;
return;
}
@@ -118,8 +141,17 @@ Memory::Memory()
// 0xE0000000 maps physical +0x1000 (Xenia's map_info). D3D writes records
// through one window and reads them through another, so all three must be
// the same host memory: one shared object, mapped three times.
if (!MapPhysicalMirrors())
if (failStep == 2 || !MapPhysicalMirrors())
{
failedStep = 2;
if (failStep == 2)
{
#ifdef __APPLE__
failedError = KERN_NO_SPACE;
#else
failedError = ENOMEM;
#endif
}
munmap(base, PPC_MEMORY_SIZE);
base = nullptr;
return;
+7
View File
@@ -16,6 +16,13 @@ struct Memory
// Physical offset of the 0xE0000000 window: 0x1000 on hardware, 0 on
// hosts whose page size can't express it (see memory.cpp).
uint32_t eWindowShift = 0;
// SpeedBreaker: base is null because a step failed (memory_status.h's
// Failure: 1, the 4 GB reservation; 2, the physical memory mirrors) with
// this error (errno; a kern_return_t for the Apple mirrors). main.cpp
// says so on screen. NFSMW_TEST_RESERVE_FAIL=1 or 2 fails that step on
// purpose (testing the message on any machine).
int failedStep = 0;
int failedError = 0;
bool IsInMemoryRange(const void* host) const noexcept
{
+237
View File
@@ -0,0 +1,237 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See memory_status.h.
#include "memory_status.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <format>
#ifdef __APPLE__
#include <TargetConditionals.h>
#include <mach/mach.h>
#include <platform/code_signature.h>
#if TARGET_OS_IOS
#include <os/proc.h>
#endif
#endif
namespace memstatus
{
namespace
{
constexpr const char* kExtendedVa = "com.apple.developer.kernel.extended-virtual-addressing";
constexpr const char* kIncreasedMemory = "com.apple.developer.kernel.increased-memory-limit";
// The mirrors' errors are kern_return_t values on Apple (Mach memory
// entries, kernel/memory.cpp), errno elsewhere.
#ifdef __APPLE__
constexpr bool kMachErrors = true;
#else
constexpr bool kMachErrors = false;
#endif
std::string ErrorText(const Status& s)
{
if (s.failure == Failure::AddressSpace)
return std::format("reserving 4 GB failed: {} (errno {})", s.errorText.empty() ? "unknown error" : s.errorText, s.error);
if (s.failure == Failure::PhysicalMemory)
return std::format("mapping the 512 MB of physical memory failed: {} ({})",
s.errorText.empty() ? "unknown error" : s.errorText, s.error);
return "";
}
// "entitlements in the signature: Extended Virtual Addressing yes,
// Increased Memory Limit no", or why they aren't known.
std::string EntitlementsText(const Status& s)
{
if (!s.signatureRead)
return "entitlements: not read";
return std::format("entitlements in the signature: Extended Virtual Addressing {}, Increased Memory Limit {}",
FlagName(s.extendedVirtualAddressing), FlagName(s.increasedMemoryLimit));
}
std::string Facts(const Status& s)
{
std::string text = s.failure == Failure::None ? std::format("guest 4 GB at {:#x}", s.base) : ErrorText(s);
if (s.ios)
{
text += "; " + EntitlementsText(s);
if (s.addressSpaceTop)
text += std::format("; address space up to {:#x} ({} GB)", s.addressSpaceTop, s.addressSpaceTop >> 30);
text += s.memoryLimitMB > 0 ? std::format("; memory limit {} MB", s.memoryLimitMB) : "; memory limit unknown";
}
return text;
}
#if defined(__APPLE__) && TARGET_OS_IOS
bool ReadVmInfo(task_vm_info_data_t& info, mach_msg_type_number_t& count)
{
count = TASK_VM_INFO_COUNT;
return task_info(mach_task_self(), TASK_VM_INFO, task_info_t(&info), &count) == KERN_SUCCESS;
}
#endif
}
const char* FlagName(Flag flag)
{
return flag == Flag::Yes ? "yes" : flag == Flag::No ? "no" : "unknown";
}
std::string LogLine(const Status& status)
{
return "[memory] " + Facts(status);
}
std::string SummaryItem(const Status& status)
{
return Facts(status);
}
Message StartupFailure(const Status& s)
{
Message m;
if (s.failure == Failure::None)
return m;
m.details = "Details: " + Facts(s) + ".";
if (!s.ios)
{
m.title = "SpeedBreaker can't start";
m.paragraphs.push_back(s.failure == Failure::AddressSpace
? "The system refused the 4 GB of address space the game needs to lay out the Xbox 360's memory."
: "The system refused to map the Xbox 360's 512 MB of memory into the game's address space.");
m.paragraphs.push_back("Close other programs and try again. On Linux, check that no address-space limit applies "
"(ulimit -v must say unlimited).");
return m;
}
m.title = "This copy of SpeedBreaker can't start";
if (s.failure == Failure::PhysicalMemory)
{
m.paragraphs.push_back("iOS refused to map the Xbox 360's 512 MB of memory into the game's address space.");
m.paragraphs.push_back("Restart the device and try again. If it happens again, please report it with a "
"screenshot of this page.");
return m;
}
if (s.extendedVirtualAddressing == Flag::Yes)
{
m.paragraphs.push_back("iOS refused the 4 GB of address space the game needs to lay out the Xbox 360's memory, "
"even though this copy has the Extended Virtual Addressing entitlement.");
m.paragraphs.push_back("Restart the device and try again. If it happens again, please report it with a "
"screenshot of this page.");
return m;
}
// iOS gives an app room for 4 GB with Extended Virtual Addressing,
// or with Increased Memory Limit on devices that support it (both
// lift its address space past 63 GB); without either it has about
// 5 GB in one piece above the system's shared region, and the 4 GB
// fits there only if nothing took that space first. A free Apple Account can
// never give Extended Virtual Addressing; some sideloading tools
// keep Increased Memory Limit for it (scripts/ios/README.md).
std::string why = "iOS refused the 4 GB of address space the game needs for the Xbox 360's memory layout. ";
if (s.extendedVirtualAddressing == Flag::No && s.increasedMemoryLimit == Flag::No)
why += "iOS gives an app that much room with the Extended Virtual Addressing or the Increased Memory Limit "
"entitlement, and this copy was signed with neither.";
else if (s.extendedVirtualAddressing == Flag::No)
why += "This copy was signed with Increased Memory Limit, which gives that room only on devices that support "
"it, but not with Extended Virtual Addressing.";
else
why += "It takes the Extended Virtual Addressing or the Increased Memory Limit entitlement, and iOS didn't "
"give this copy the room.";
m.paragraphs.push_back(why);
m.paragraphs.push_back("This build needs the Extended Virtual Addressing entitlement, which a paid Apple Developer "
"Program account can give, or Increased Memory Limit, which AltStore 2.2 or later keeps even "
"with a free Apple Account (SideStore 0.7 and some other tools drop it). Sideload "
"SpeedBreaker again that way, or build it yourself with Xcode: see scripts/ios/README.md in "
"the source.");
m.paragraphs.push_back("You can close SpeedBreaker now: swipe it away in the app switcher.");
return m;
}
std::string LowMemoryNotice(const Status& s)
{
if (!s.ios || s.failure != Failure::None)
return "";
bool smallSpace = s.addressSpaceTop != 0 && s.addressSpaceTop < kLargeAddressSpace;
bool lowLimit = s.memoryLimitMB > 0 && s.memoryLimitMB < kPeakFootprintMB;
if (smallSpace && lowLimit)
return std::format("iOS gives SpeedBreaker little room ({} GB of address space, {} MB of memory: no memory "
"entitlements): it may close the game in busy scenes", s.addressSpaceTop >> 30, s.memoryLimitMB);
if (smallSpace)
return std::format("iOS gives SpeedBreaker only {} GB of address space (no memory entitlements): it may close "
"the game in busy scenes", s.addressSpaceTop >> 30);
if (lowLimit && s.increasedMemoryLimit != Flag::Yes)
return std::format("iOS lets SpeedBreaker use only {} MB (no Increased Memory Limit entitlement): it may close "
"the game in busy scenes", s.memoryLimitMB);
if (lowLimit)
return std::format("iOS lets SpeedBreaker use only {} MB on this device: it may close the game in busy scenes",
s.memoryLimitMB);
return "";
}
void Gather(Status& status)
{
if (status.failure == Failure::AddressSpace || (status.failure == Failure::PhysicalMemory && !kMachErrors))
status.errorText = std::strerror(status.error);
#ifdef __APPLE__
else if (status.failure == Failure::PhysicalMemory)
status.errorText = mach_error_string(status.error);
#endif
#if defined(__APPLE__) && TARGET_OS_IOS
status.ios = true;
#else
// NFSMW_TEST_MEMORY_IOS=1: the iOS wording on another system (with
// NFSMW_TEST_RESERVE_FAIL, the iOS page), the entitlements read from
// this executable's own signature (a Mac build has none: "no").
const char* test = std::getenv("NFSMW_TEST_MEMORY_IOS");
status.ios = test && test[0] == '1';
#endif
#ifdef __APPLE__
if (!status.ios)
return;
if (std::optional<platform::codesign::Entitlements> e = platform::codesign::ReadOwn())
{
using platform::codesign::Lookup;
using platform::codesign::Value;
status.signatureRead = true;
status.extendedVirtualAddressing = Lookup(*e, kExtendedVa) == Value::True ? Flag::Yes : Flag::No;
status.increasedMemoryLimit = Lookup(*e, kIncreasedMemory) == Value::True ? Flag::Yes : Flag::No;
}
#else
(void)kExtendedVa;
(void)kIncreasedMemory;
#endif
#if defined(__APPLE__) && TARGET_OS_IOS
// The limit iOS kills the app at: what it has (its footprint) plus
// what it may still take. The address space's top shows whether
// Extended Virtual Addressing took effect.
task_vm_info_data_t info{};
mach_msg_type_number_t count = 0;
size_t available = os_proc_available_memory();
if (ReadVmInfo(info, count))
{
if (available > 0)
status.memoryLimitMB = int64_t((uint64_t(info.phys_footprint) + available) >> 20);
if (count >= TASK_VM_INFO_REV2_COUNT)
status.addressSpaceTop = info.max_address;
}
#endif
}
void Poll()
{
#if defined(__APPLE__) && TARGET_OS_IOS
using clock = std::chrono::steady_clock;
static clock::time_point s_next;
clock::time_point now = clock::now();
if (now < s_next)
return;
s_next = now + std::chrono::seconds(60);
task_vm_info_data_t info{};
mach_msg_type_number_t count = 0;
if (!ReadVmInfo(info, count))
return;
int64_t peak = count >= TASK_VM_INFO_REV3_COUNT ? info.ledger_phys_footprint_peak : 0;
fprintf(stderr, "[memory] footprint %llu MB, peak %lld MB, %zu MB left before iOS's limit\n",
(unsigned long long)(info.phys_footprint >> 20), (long long)(peak >> 20), os_proc_available_memory() >> 20);
#endif
}
}
+105
View File
@@ -0,0 +1,105 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Whether the game got the memory it needs, and what to tell the player when
// it didn't. The recompiled code addresses guest memory as base + a 32-bit
// guest address, so the game needs one contiguous 4 GB of host address space
// (kernel/memory.cpp), with the 360's 512 MB of physical memory mapped three
// times inside it (0xA0000000, 0xC0000000, 0xE0000000). There is no smaller
// layout: the game's heaps reach 0x7EFFFFFF, its image sits at 0x82000000 and
// the three physical windows end at 0xFFFFFFFF.
//
// iOS limits an app's address space to 17.4 GB on a device with more than
// 3 GB of memory, about 5 GB of it free in one piece above the system's
// shared region, unless it has Extended Virtual Addressing (then the 4 GB
// lands at 0x7000000000) or Increased Memory Limit on a device that supports
// it (63 GB since iOS 16: xnu's kern_exec.c). Increased Memory Limit also
// raises the memory limit (about 3.3 GB -> 6 GB on an 8 GB iPhone), which the
// game's peak footprint (about 2.5-3 GB, scripts/ios/README.md, "Memory")
// needs on smaller devices. scripts/ios/SpeedBreaker/SpeedBreaker.entitlements
// asks for both. A sideloaded copy (the IPA, scripts/ios/make_ipa.sh) is
// re-signed with the player's own Apple Account: a free one never gets
// Extended Virtual Addressing, and keeps Increased Memory Limit only with
// some tools. When the 4 GB can't be had the game can't start: main.cpp shows
// StartupFailure() on screen instead of quitting. With a low memory limit it
// starts, and LowMemoryNotice() says iOS may close it.
//
// Everything here is pure (tests/memory_status_test.cpp) except Gather(),
// which reads the device.
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace memstatus
{
// Which step of Memory::Memory() failed (kernel/memory.h: failedStep).
enum class Failure : uint8_t { None, AddressSpace, PhysicalMemory };
// From the app's own code signature (platform/code_signature.h):
// Unknown when it couldn't be read (or isn't iOS's concern).
enum class Flag : uint8_t { Unknown, No, Yes };
struct Status
{
Failure failure = Failure::None;
int error = 0; // errno for the reservation, a kern_return_t for the mirrors
std::string errorText; // strerror / mach_error_string
uint64_t base = 0; // host address of the guest's 4 GB (0: none)
Flag extendedVirtualAddressing = Flag::Unknown;
Flag increasedMemoryLimit = Flag::Unknown;
bool signatureRead = false; // the entitlements above come from the signature
int64_t memoryLimitMB = -1; // iOS: footprint + os_proc_available_memory() at startup; -1 unknown
// iOS: the top of the app's address space (task_vm_info's
// max_address), which shows whether the entitlements took effect:
// 0xFC0000000 (63 GB) or more with them, 0x458000000 (17.4 GB)
// without on a device with more than 3 GB; 0 unknown.
uint64_t addressSpaceTop = 0;
bool ios = false;
};
// The game's peak memory footprint with room to spare (measured 2.2 GB
// in a race at 1x on a Mac, about 2.5 GB expected in an iPhone's free
// roam: scripts/ios/README.md, "Memory"): below this limit iOS may
// close the game in a busy scene.
constexpr int64_t kPeakFootprintMB = 3072;
// An address space that ends below this (MACH_VM_JUMBO_ADDRESS, 63 GB)
// is iOS's default one: neither entitlement took effect, and the game's
// 4 GB plus the 3 GB or so it needs besides (the graphics driver's,
// its heaps) must fit in the 7 GB or so iOS leaves an app.
constexpr uint64_t kLargeAddressSpace = 0xFC0000000;
// The log's line: "[memory] guest 4 GB at 0x7000000000; ...".
std::string LogLine(const Status& status);
// The system summary's Memory item (the same facts, without the prefix).
std::string SummaryItem(const Status& status);
struct Message
{
std::string title;
std::vector<std::string> paragraphs; // the first says what happened, the rest what to do
std::string details; // small print: the error and the entitlements
};
// What the player sees when the game can't start (failure != None).
Message StartupFailure(const Status& status);
// A one-line notice (a toast) when the game started but iOS may close
// it: iOS's default address space (below kLargeAddressSpace), or a
// memory limit below kPeakFootprintMB. "" otherwise (and off iOS).
std::string LowMemoryNotice(const Status& status);
// The device's side (iOS): the entitlements from the app's signature,
// the memory limit and the address space's top; elsewhere only the
// error's text (NFSMW_TEST_MEMORY_IOS=1: the iOS wording); `base`, the
// failure and its error come from g_memory. Main thread, once, early.
void Gather(Status& status);
// iOS, main thread, every frame (video::RunFrame): once a minute a log
// line with the app's memory footprint, its peak and what is left before
// iOS's limit ("[memory] footprint 2410 MB, peak 2511 MB, 3626 MB left"),
// so a log cut short by iOS closing the game shows how close it was.
// Nothing elsewhere.
void Poll();
const char* FlagName(Flag flag); // "yes", "no", "unknown"
}
+59 -5
View File
@@ -10,6 +10,7 @@
#include "stdafx.h"
#include "kernel/arena.h"
#include "kernel/memory_status.h"
#include "kernel/module.h"
#include "kernel/write_watch.h"
#include "kernel/dispatcher.h"
@@ -277,12 +278,23 @@ int main(int argc, char** argv)
if (const char* home = std::getenv("HOME"))
chdir((std::string(home) + "/Documents").c_str());
#endif
// Before anything uses the per-user folders: nfsmw-recomp's become
// SpeedBreaker's (user/paths.h).
std::string migrated = MigrateUserFolders();
// Which folder the game runs from, before anything changes directory:
// the installer never puts the game inside it (install/placement.h).
// the installer never puts the game inside it (install/placement.h), and
// a portable.txt there keeps all of SpeedBreaker's data in it
// (user/portable.h).
install::SetProgramPath(argc > 0 ? argv[0] : nullptr);
// Before anything uses the per-user folders: portable mode or not, and
// then (not in portable mode) nfsmw-recomp's folders become
// SpeedBreaker's (user/paths.h). A portable folder that can't be written
// (a read-only drive, a locked card) is still used: the game plays, and
// the player is told settings and saves won't be kept; never a quiet
// switch to the home folder, which would split saves between two places.
const portable::Layout& portableLayout = portable::Detect(install::BundleFolder());
const int portableError = portable::CheckWritable(portableLayout);
std::string migrated = MigrateUserFolders() + portable::Describe(portableLayout);
if (portableError)
migrated += std::format("[paths] portable mode: {} can't be written ({}): settings and saves won't be kept\n",
portableLayout.data.string(), strerror(portableError));
// The name volume mixers and the desktop show (before SDL starts).
SDL_SetAppMetadata("SpeedBreaker", report::BuildString(), nullptr);
if (std::optional<int> code = install::RunInstallCommand(argc, argv))
@@ -299,9 +311,41 @@ int main(int argc, char** argv)
report::StartLogWriter();
report::RegisterThread("main (window)");
// The guest's 4 GB (kernel/memory.cpp, reserved before main): where it
// went, or why it couldn't, and on iOS what the app's signature allows
// (kernel/memory_status.h). For the log, the system summary and, when
// the game can't start, the page that says why.
memstatus::Status memory;
memory.base = reinterpret_cast<uintptr_t>(g_memory.base);
memory.failure = memstatus::Failure(g_memory.base ? 0 : std::max(g_memory.failedStep, 1));
memory.error = g_memory.failedError;
memstatus::Gather(memory);
fprintf(stderr, "%s\n", memstatus::LogLine(memory).c_str());
report::SetSystemItem("Memory", memstatus::SummaryItem(memory));
if (g_memory.base == nullptr)
{
fprintf(stderr, "[runtime] could not reserve 4 GB of guest address space\n");
// An iOS copy signed without Extended Virtual Addressing (a free
// Apple Account's sideload) ends up here: say so on screen rather
// than vanish. Before the game's code runs at all.
memstatus::Message message = memstatus::StartupFailure(memory);
fprintf(stderr, "[runtime] could not reserve 4 GB of guest address space: %s\n", message.title.c_str());
for (const std::string& p : message.paragraphs)
fprintf(stderr, "[runtime] %s\n", p.c_str());
settings::Load();
if (video::Initialize())
{
ui::RunStartupError(message.title, message.paragraphs, message.details, [] { return video::RunFrame(); });
video::Shutdown();
}
else if (const char* h = std::getenv("NFSMW_HEADLESS"); !h || h[0] != '1')
{
// No window for the page: the system's own message box.
std::string text;
for (const std::string& p : message.paragraphs)
text += p + "\n\n";
SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, message.title.c_str(), (text + message.details).c_str(), nullptr);
}
report::FlushLog();
return 1;
}
@@ -363,6 +407,16 @@ int main(int argc, char** argv)
bool windowed = video::Initialize();
if (!settingsLoaded)
ui::Toast(settings::LastError(), 12.0); // a mistake in settings.toml: it was kept as .bad
if (portableError)
ui::Toast(std::format("SpeedBreaker's folder can't be written ({}): settings and saves won't be kept this time. "
"Move the folder to a drive it can write to.", strerror(portableError)), 12.0);
// iOS: a copy without Increased Memory Limit (a free Apple Account's
// sideload) starts, but iOS may close it in a busy scene: say so once.
if (std::string notice = memstatus::LowMemoryNotice(memory); !notice.empty())
{
fprintf(stderr, "[memory] %s\n", notice.c_str());
ui::Toast(notice, 12.0);
}
report::NoticePreviousCrash();
// NFSMW_LIFELINE_PID (scripts/steamos/speedbreaker.sh): the launcher's PID. The
+242
View File
@@ -0,0 +1,242 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See code_signature.h.
//
// Formats (Apple's <mach-o/loader.h>, <mach-o/fat.h> and the code signing
// blobs of xnu's osfmk/kern/cs_blobs.h), read field by field so this builds
// and tests anywhere:
// - Fat header, big-endian: magic 0xCAFEBABE (32-bit offsets, 20-byte
// entries) or 0xCAFEBABF (64-bit, 32-byte entries), then the entries:
// cputype, cpusubtype, offset, size, align (, reserved).
// - mach_header_64, the file's (little-endian on arm64): magic 0xFEEDFACF,
// cputype, cpusubtype, filetype, ncmds, sizeofcmds, flags, reserved;
// then the load commands, each cmd and cmdsize first.
// LC_CODE_SIGNATURE (0x1D) is a linkedit_data_command: dataoff and
// datasize, file offsets within the slice.
// - The embedded signature, big-endian: a SuperBlob (magic 0xFADE0CC0,
// length, count, then count index entries of type and offset from the
// SuperBlob's start). Slot 5 is the XML entitlements blob (magic
// 0xFADE7171), slot 7 the DER one (0xFADE7172): magic, length (the
// blob's whole size, header included), then the bytes.
#include "code_signature.h"
#include <algorithm>
#include <cstring>
#include <vector>
#ifdef __APPLE__
#include <fcntl.h>
#include <mach-o/dyld.h>
#include <unistd.h>
#endif
namespace platform::codesign
{
namespace
{
constexpr uint32_t kFatMagic = 0xCAFEBABE, kFatMagic64 = 0xCAFEBABF;
constexpr uint32_t kMachMagic64 = 0xFEEDFACF;
constexpr uint32_t kCpuArm64 = 0x0100000C;
constexpr uint32_t kLcCodeSignature = 0x1D;
constexpr uint32_t kSuperBlobMagic = 0xFADE0CC0;
constexpr uint32_t kEntitlementsMagic = 0xFADE7171, kDerEntitlementsMagic = 0xFADE7172;
constexpr uint32_t kSlotEntitlements = 5, kSlotDerEntitlements = 7;
constexpr size_t kMachHeaderSize = 32;
bool Has(std::span<const uint8_t> b, uint64_t at, uint64_t n)
{
return at <= b.size() && n <= b.size() - at;
}
uint32_t Be32(std::span<const uint8_t> b, size_t at)
{
return uint32_t(b[at]) << 24 | uint32_t(b[at + 1]) << 16 | uint32_t(b[at + 2]) << 8 | b[at + 3];
}
uint64_t Be64(std::span<const uint8_t> b, size_t at)
{
return uint64_t(Be32(b, at)) << 32 | Be32(b, at + 4);
}
uint32_t Le32(std::span<const uint8_t> b, size_t at)
{
return uint32_t(b[at]) | uint32_t(b[at + 1]) << 8 | uint32_t(b[at + 2]) << 16 | uint32_t(b[at + 3]) << 24;
}
}
std::optional<Range> MachOSlice(std::span<const uint8_t> fileStart, uint64_t fileSize)
{
if (!Has(fileStart, 0, 8))
return std::nullopt;
if (Le32(fileStart, 0) == kMachMagic64)
return Range{ 0, fileSize };
uint32_t magic = Be32(fileStart, 0);
if (magic != kFatMagic && magic != kFatMagic64)
return std::nullopt;
uint32_t count = Be32(fileStart, 4);
size_t entry = magic == kFatMagic64 ? 32 : 20;
if (count == 0 || count > 64 || !Has(fileStart, 8, uint64_t(count) * entry))
return std::nullopt;
std::optional<Range> chosen;
for (uint32_t i = 0; i < count; i++)
{
size_t at = 8 + i * entry;
Range r;
if (magic == kFatMagic64)
r = { Be64(fileStart, at + 8), Be64(fileStart, at + 16) };
else
r = { Be32(fileStart, at + 8), Be32(fileStart, at + 12) };
if (r.offset > fileSize || r.size > fileSize - r.offset)
continue;
if (Be32(fileStart, at) == kCpuArm64)
return r;
if (count == 1)
chosen = r;
}
return chosen;
}
std::optional<Range> SignatureRange(std::span<const uint8_t> sliceStart, uint64_t sliceOffset)
{
if (!Has(sliceStart, 0, kMachHeaderSize) || Le32(sliceStart, 0) != kMachMagic64)
return std::nullopt;
uint32_t ncmds = Le32(sliceStart, 16), sizeofcmds = Le32(sliceStart, 20);
if (!Has(sliceStart, kMachHeaderSize, sizeofcmds))
return std::nullopt;
size_t at = kMachHeaderSize, end = kMachHeaderSize + sizeofcmds;
for (uint32_t i = 0; i < ncmds; i++)
{
if (at + 8 > end)
return std::nullopt;
uint32_t cmd = Le32(sliceStart, at), cmdsize = Le32(sliceStart, at + 4);
if (cmdsize < 8 || cmdsize > end - at)
return std::nullopt;
if (cmd == kLcCodeSignature)
{
if (cmdsize < 16)
return std::nullopt;
uint32_t dataoff = Le32(sliceStart, at + 8), datasize = Le32(sliceStart, at + 12);
if (datasize == 0)
return std::nullopt;
return Range{ sliceOffset + dataoff, datasize };
}
at += cmdsize;
}
return std::nullopt;
}
std::optional<Entitlements> FromSuperBlob(std::span<const uint8_t> blob)
{
if (!Has(blob, 0, 12) || Be32(blob, 0) != kSuperBlobMagic)
return std::nullopt;
uint32_t length = Be32(blob, 4), count = Be32(blob, 8);
if (length < 12 || length > blob.size())
return std::nullopt;
blob = blob.first(length);
if (!Has(blob, 12, uint64_t(count) * 8))
return std::nullopt;
Entitlements der;
for (uint32_t i = 0; i < count; i++)
{
uint32_t type = Be32(blob, 12 + i * 8), offset = Be32(blob, 16 + i * 8);
if (type != kSlotEntitlements && type != kSlotDerEntitlements)
continue;
if (!Has(blob, offset, 8))
return std::nullopt;
uint32_t magic = Be32(blob, offset), size = Be32(blob, offset + 4);
if (size < 8 || !Has(blob, offset, size))
return std::nullopt;
const char* bytes = reinterpret_cast<const char*>(blob.data() + offset + 8);
if (type == kSlotEntitlements && magic == kEntitlementsMagic)
return Entitlements{ true, std::string(bytes, size - 8), false }; // XML: the one to read
if (type == kSlotDerEntitlements && magic == kDerEntitlementsMagic)
der = Entitlements{ true, std::string(bytes, size - 8), true };
}
return der; // DER only, or found == false: no entitlements at all
}
Value Lookup(const Entitlements& entitlements, std::string_view key)
{
if (!entitlements.found || key.empty())
return Value::Absent;
const std::string& t = entitlements.text;
if (entitlements.der)
{
// UTF8String (tag 0x0C, short length) holding the key, then a
// BOOLEAN (0x01, length 1, 0x00 false, anything else true).
if (key.size() >= 0x80)
return Value::Absent;
std::string pattern;
pattern += char(0x0C);
pattern += char(key.size());
pattern += key;
for (size_t at = t.find(pattern); at != std::string::npos; at = t.find(pattern, at + 1))
{
size_t v = at + pattern.size();
if (v + 3 <= t.size() && t[v] == 0x01 && t[v + 1] == 0x01)
return t[v + 2] != 0 ? Value::True : Value::False;
}
return Value::Absent;
}
std::string pattern = "<key>" + std::string(key) + "</key>";
size_t at = t.find(pattern);
if (at == std::string::npos)
return Value::Absent;
at += pattern.size();
while (at < t.size() && (t[at] == ' ' || t[at] == '\t' || t[at] == '\r' || t[at] == '\n'))
at++;
std::string_view rest = std::string_view(t).substr(at);
if (rest.starts_with("<true/>") || rest.starts_with("<true />"))
return Value::True;
if (rest.starts_with("<false/>") || rest.starts_with("<false />"))
return Value::False;
return Value::Absent;
}
std::optional<Entitlements> FromFile(std::span<const uint8_t> file)
{
std::optional<Range> slice = MachOSlice(file, file.size());
if (!slice)
return std::nullopt;
std::optional<Range> sig = SignatureRange(file.subspan(slice->offset, slice->size), slice->offset);
if (!sig || !Has(file, sig->offset, sig->size))
return std::nullopt;
return FromSuperBlob(file.subspan(sig->offset, sig->size));
}
std::optional<Entitlements> ReadOwn()
{
#ifdef __APPLE__
char path[4096];
uint32_t pathSize = sizeof(path);
if (_NSGetExecutablePath(path, &pathSize) != 0)
return std::nullopt;
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0)
return std::nullopt;
off_t fileSize = lseek(fd, 0, SEEK_END);
auto read = [fd](uint64_t offset, uint64_t length) {
std::vector<uint8_t> bytes(static_cast<size_t>(length));
ssize_t got = length ? pread(fd, bytes.data(), bytes.size(), off_t(offset)) : 0;
bytes.resize(got > 0 ? size_t(got) : 0);
return bytes;
};
std::optional<Entitlements> result;
if (fileSize > 0)
{
std::vector<uint8_t> start = read(0, std::min<uint64_t>(uint64_t(fileSize), 4096));
if (std::optional<Range> slice = MachOSlice(start, uint64_t(fileSize)))
{
std::vector<uint8_t> header = read(slice->offset, kMachHeaderSize);
if (header.size() == kMachHeaderSize && Le32(header, 0) == kMachMagic64)
{
uint64_t commands = std::min<uint64_t>(Le32(header, 20), 16u << 20);
std::vector<uint8_t> head = read(slice->offset, kMachHeaderSize + commands);
std::optional<Range> sig = SignatureRange(head, slice->offset);
if (sig && sig->size <= (64u << 20))
result = FromSuperBlob(read(sig->offset, sig->size));
}
}
}
close(fd);
return result;
#else
return std::nullopt;
#endif
}
}
+66
View File
@@ -0,0 +1,66 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The entitlements in the game's own code signature, as installed. On iOS
// what the app may do is what its signature says (and its provisioning
// profile allows: iOS won't install or open an app signed with entitlements
// its profile lacks), and a sideloading tool (AltStore, SideStore,
// Sideloadly...) re-signs the app with the player's own Apple Account: a
// tool may keep, or drop, the entitlements the app asks for
// (scripts/ios/SpeedBreaker/SpeedBreaker.entitlements). So the signature on
// the device answers "did this copy get Extended Virtual Addressing?" for
// the log and the startup message (kernel/memory_status.h).
//
// Parsing is pure (tests/code_signature_test.cpp): a Mach-O file (thin, or
// fat with an arm64 slice) -> its LC_CODE_SIGNATURE -> the embedded
// signature's SuperBlob -> the entitlements blob, as XML (slot 5) or DER
// (slot 7). ReadOwn() reads the running executable (Apple only).
#pragma once
#include <cstdint>
#include <optional>
#include <span>
#include <string>
#include <string_view>
namespace platform::codesign
{
// A file range: where the embedded signature is in the executable.
struct Range
{
uint64_t offset = 0;
uint64_t size = 0;
};
// The arm64 slice of a fat file, or the whole file if thin: from the
// file's first bytes (the fat header, which is small). nullopt: neither a
// Mach-O nor a fat file with an arm64 (or the only) slice.
std::optional<Range> MachOSlice(std::span<const uint8_t> fileStart, uint64_t fileSize);
// The embedded signature's range in the file, from the slice's first
// bytes (its header and load commands, `sliceOffset` into the file).
// nullopt: not a 64-bit Mach-O, malformed, or unsigned (no
// LC_CODE_SIGNATURE).
std::optional<Range> SignatureRange(std::span<const uint8_t> sliceStart, uint64_t sliceOffset);
struct Entitlements
{
bool found = false; // the signature has an entitlements blob (else: none at all)
std::string text; // XML (slot 5); else the DER blob's bytes (slot 7)
bool der = false;
};
// The entitlements from the embedded signature (a CS SuperBlob,
// big-endian). nullopt: not a SuperBlob or malformed. found == false: a
// signature without entitlements (an ad-hoc one, as the linker makes).
std::optional<Entitlements> FromSuperBlob(std::span<const uint8_t> blob);
enum class Value : uint8_t { Absent, False, True };
// `key`'s boolean in the entitlements: XML <key>key</key><true/>, or DER
// (UTF8String key, BOOLEAN). Absent: not there (or not a boolean).
Value Lookup(const Entitlements& entitlements, std::string_view key);
// The whole chain on a file's bytes (tests): nullopt as above.
std::optional<Entitlements> FromFile(std::span<const uint8_t> file);
// Apple: the running executable's entitlements, read from its file.
// nullopt: unreadable, unsigned or not parsed (and elsewhere).
std::optional<Entitlements> ReadOwn();
}
+52
View File
@@ -5,6 +5,8 @@
#pragma once
#include <string>
#include <video/swapchain_policy.h>
struct SDL_Window;
namespace platform::ios
@@ -41,4 +43,54 @@ namespace platform::ios
// An iPhone (UIDevice's interface idiom), not an iPad: Frame Rate's
// default (main.cpp). Main thread.
bool IsPhone();
// Orientation (video/swapchain_policy.h says why). SDL 3.4.16 sets its
// view's frame itself when a keyboard shows or hides (-[SDL_uikitview-
// controller updateKeyboard] -> UIKit_ComputeViewFrame, SDL_uikitvideo.m)
// and swaps its width and height unless it believes the interface is
// landscape: UIApplication.statusBarOrientation is landscape (on iOS 27
// it reads "unknown") or the window can't be portrait. A resizable
// window can be, to SDL, unless SDL_HINT_ORIENTATIONS says otherwise
// (UIKit_GetSupportedOrientations ignores the Info.plist then).
// Before SDL_CreateWindow: on a device where the app is landscape-only
// (LandscapeOnly), SDL_HINT_ORIENTATIONS = "LandscapeLeft LandscapeRight"
// (unless set already), so SDL never turns the view portrait. Main thread.
void PrepareOrientation();
// Once the window is up: the log notes keyboards showing and hiding, with
// the view's geometry (the trigger of any turn that gets past the hint),
// at most 20 times.
void WatchKeyboards(SDL_Window* window);
// The app shows only in landscape here: an iPhone, whose Info.plist
// lists no portrait orientation. (An iPad's window can take any shape.)
bool LandscapeOnly();
// The interface's orientation: the window's scene's, else its bounds'
// shape. Unknown without a window.
video::Orientation InterfaceOrientation(SDL_Window* window);
// SDL's view (the Metal view the surface is) fills its UIWindow: the
// same size (an offset for a keyboard is SDL's to make).
bool ViewFitsWindow(SDL_Window* window);
// Fits SDL's view to its UIWindow's bounds again, and lays it out (SDL
// then sizes the drawables and sends its resize events). True: the view
// had to be changed. Logs what it did.
bool FitViewToWindow(SDL_Window* window);
// The scene's interface orientation by name ("landscape left", ...),
// for the log: a string literal, so a change shows as another pointer.
const char* SceneOrientationName(SDL_Window* window);
// The view, its window and the scene in one line, for the log.
std::string DescribeGeometry(SDL_Window* window);
// The battery, for the log: its level and charging state once known
// ("[power] battery 87%, charging, at 0.4 s"), then each change ("[power]
// battery 87% -> 86%, on battery, at 312.0 s"), so a session's drain per
// minute reads off its log beside the [thermal] lines. Main thread, every
// frame (video::RunFrame): reads UIDevice every 5 seconds at most, with
// battery monitoring left on from the first call.
void PollBattery();
}
+238
View File
@@ -24,6 +24,7 @@
// press still comes through once.
#include "ios_ui.h"
#include <SDL3/SDL_hints.h>
#include <SDL3/SDL_keyboard.h>
#include <SDL3/SDL_properties.h>
#include <SDL3/SDL_video.h>
@@ -33,9 +34,13 @@
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#include <report/report.h>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <utility>
namespace
{
@@ -69,6 +74,40 @@ namespace
bool s_noViewLogged = false, s_logged = false;
int s_retakes = 0;
constexpr int kRetakesLogged = 10;
UIWindow* UiWindow(SDL_Window* window)
{
return window ? static_cast<UIWindow*>(SDL_GetPointerProperty(SDL_GetWindowProperties(window),
SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER, nullptr)) : nil;
}
const char* InterfaceName(UIInterfaceOrientation o)
{
switch (o)
{
case UIInterfaceOrientationPortrait:
return "portrait";
case UIInterfaceOrientationPortraitUpsideDown:
return "portrait upside down";
case UIInterfaceOrientationLandscapeLeft:
return "landscape left";
case UIInterfaceOrientationLandscapeRight:
return "landscape right";
default:
return "unknown";
}
}
bool SameSize(CGSize a, CGSize b)
{
return std::fabs(a.width - b.width) < 0.5 && std::fabs(a.height - b.height) < 0.5;
}
// Keyboard notifications reach SDL's view controller, which sets the
// view's frame on each (see ios_ui.h): the log says when, and whose.
int s_keyboardNotes = 0;
constexpr int kKeyboardNotesLogged = 20;
SDL_Window* s_window = nullptr; // the game's, once it exists (FitViewToWindow)
}
namespace platform::ios
@@ -192,4 +231,203 @@ namespace platform::ios
{
return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone;
}
bool LandscapeOnly()
{
static const bool only = [] {
@autoreleasepool
{
if (!IsPhone())
return false;
// NSBundle hands over the device's own variant of the key.
id list = [NSBundle.mainBundle objectForInfoDictionaryKey:@"UISupportedInterfaceOrientations"];
if (![list isKindOfClass:[NSArray class]] || [(NSArray*)list count] == 0)
return false;
for (id o in (NSArray*)list)
if (![o isKindOfClass:[NSString class]] || ![(NSString*)o hasPrefix:@"UIInterfaceOrientationLandscape"])
return false;
return true;
}
}();
return only;
}
void PrepareOrientation()
{
@autoreleasepool
{
if (LandscapeOnly())
{
if (const char* set = SDL_GetHint(SDL_HINT_ORIENTATIONS))
fprintf(stderr, "[video] orientation: SDL_HINT_ORIENTATIONS is \"%s\" already: left as it is\n", set);
else
{
SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight");
fprintf(stderr, "[video] orientation: landscape only (iPhone): SDL_HINT_ORIENTATIONS = \"LandscapeLeft LandscapeRight\", "
"so SDL never turns its view portrait\n");
}
}
}
}
void WatchKeyboards(SDL_Window* window)
{
@autoreleasepool
{
s_window = window;
static bool watching = false;
if (std::exchange(watching, true))
return;
for (NSString* name in @[ UIKeyboardWillShowNotification, UIKeyboardWillHideNotification ])
[NSNotificationCenter.defaultCenter addObserverForName:name object:nil queue:nil usingBlock:^(NSNotification* note) {
if (s_keyboardNotes >= kKeyboardNotesLogged)
return;
s_keyboardNotes++;
NSNumber* local = note.userInfo[UIKeyboardIsLocalUserInfoKey];
fprintf(stderr, "[ui] a keyboard will %s (%s)%s: %s\n", [note.name isEqualToString:UIKeyboardWillShowNotification] ? "show" : "hide",
local == nil ? "whose not said" : local.boolValue ? "this app's" : "another app's",
s_keyboardNotes == kKeyboardNotesLogged ? " (no more of these logged)" : "", DescribeGeometry(s_window).c_str());
}];
}
}
video::Orientation InterfaceOrientation(SDL_Window* window)
{
@autoreleasepool
{
UIWindow* uiWindow = UiWindow(window);
if (!uiWindow)
return video::Orientation::Unknown;
UIInterfaceOrientation o = uiWindow.windowScene ? uiWindow.windowScene.effectiveGeometry.interfaceOrientation
: UIInterfaceOrientationUnknown;
if (UIInterfaceOrientationIsLandscape(o))
return video::Orientation::Landscape;
if (UIInterfaceOrientationIsPortrait(o))
return video::Orientation::Portrait;
const CGSize size = uiWindow.bounds.size;
return size.width <= 0 || size.height <= 0 ? video::Orientation::Unknown
: size.width >= size.height ? video::Orientation::Landscape
: video::Orientation::Portrait;
}
}
bool ViewFitsWindow(SDL_Window* window)
{
@autoreleasepool
{
UIWindow* uiWindow = UiWindow(window);
UIView* view = uiWindow.rootViewController.viewIfLoaded;
return !view || SameSize(view.bounds.size, uiWindow.bounds.size);
}
}
bool FitViewToWindow(SDL_Window* window)
{
@autoreleasepool
{
UIWindow* uiWindow = UiWindow(window);
UIView* view = uiWindow.rootViewController.viewIfLoaded;
if (!view)
return false;
const CGRect before = view.frame;
if (SameSize(view.bounds.size, uiWindow.bounds.size))
{
fprintf(stderr, "[video] the view already fills its window: %s\n", DescribeGeometry(window).c_str());
return false;
}
view.frame = uiWindow.bounds;
[view setNeedsLayout];
[view layoutIfNeeded];
fprintf(stderr, "[video] the view was %.0fx%.0f points in a %.0fx%.0f window: fitted back to it (%s)\n", before.size.width,
before.size.height, uiWindow.bounds.size.width, uiWindow.bounds.size.height, DescribeGeometry(window).c_str());
return true;
}
}
const char* SceneOrientationName(SDL_Window* window)
{
@autoreleasepool
{
UIWindow* uiWindow = UiWindow(window);
return InterfaceName(uiWindow.windowScene ? uiWindow.windowScene.effectiveGeometry.interfaceOrientation
: UIInterfaceOrientationUnknown);
}
}
std::string DescribeGeometry(SDL_Window* window)
{
@autoreleasepool
{
UIWindow* uiWindow = UiWindow(window);
UIView* view = uiWindow.rootViewController.viewIfLoaded;
if (!view)
return "no view";
UIInterfaceOrientation scene = uiWindow.windowScene ? uiWindow.windowScene.effectiveGeometry.interfaceOrientation
: UIInterfaceOrientationUnknown;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
UIInterfaceOrientation bar = UIApplication.sharedApplication.statusBarOrientation;
#pragma clang diagnostic pop
const char* state = "active";
switch (UIApplication.sharedApplication.applicationState)
{
case UIApplicationStateInactive:
state = "inactive";
break;
case UIApplicationStateBackground:
state = "in the background";
break;
default:
break;
}
CGSize drawables = [view.layer isKindOfClass:[CAMetalLayer class]] ? ((CAMetalLayer*)view.layer).drawableSize : CGSizeZero;
char text[320];
snprintf(text, sizeof text,
"view %.0fx%.0f at %.0f,%.0f in a %.0fx%.0f window; drawables %.0fx%.0f at scale %.3f; scene %s, status bar %s; app %s",
view.frame.size.width, view.frame.size.height, view.frame.origin.x, view.frame.origin.y, uiWindow.bounds.size.width,
uiWindow.bounds.size.height, drawables.width, drawables.height, double(view.layer.contentsScale), InterfaceName(scene),
InterfaceName(bar), state);
return text;
}
}
void PollBattery()
{
static double next = 0.0;
static int lastPercent = -1;
static const char* lastState = nullptr; // nullptr: nothing logged yet
const double now = report::SessionSeconds();
if (now < next)
return;
next = now + 5.0;
@autoreleasepool
{
UIDevice* device = UIDevice.currentDevice;
if (!device.batteryMonitoringEnabled)
device.batteryMonitoringEnabled = YES; // left on: SDL's power API isn't used
const float level = device.batteryLevel; // -1 while unknown
const int percent = level < 0.0f ? -1 : int(std::lround(double(level) * 100.0));
const char* state = "unknown";
switch (device.batteryState)
{
case UIDeviceBatteryStateUnplugged: state = "on battery"; break;
case UIDeviceBatteryStateCharging: state = "charging"; break;
case UIDeviceBatteryStateFull: state = "full, on a charger"; break;
default: break;
}
if (percent < 0)
return; // monitoring just turned on (or no battery): next time
if (!lastState)
fprintf(stderr, "[power] battery %d%%, %s, at %.1f s\n", percent, state, now);
else if (percent != lastPercent || std::strcmp(state, lastState) != 0)
{
if (percent != lastPercent)
fprintf(stderr, "[power] battery %d%% -> %d%%, %s, at %.1f s\n", lastPercent, percent, state, now);
else
fprintf(stderr, "[power] battery %d%%, %s -> %s, at %.1f s\n", percent, lastState, state, now);
}
lastPercent = percent;
lastState = state;
}
}
}
+2 -1
View File
@@ -298,7 +298,8 @@ namespace report
}
text += "Session: " + SessionText() + "\n";
text += "Environment: " + EnvironmentText() + "\n";
text += "Data: " + GetUserPath().string() + " (logs in logs/), caches: " + GetCachePath().string() + "\n";
text += "Data: " + GetUserPath().string() + " (logs in logs/" + (portable::Current().Active() ? "; portable mode" : "")
+ "), caches: " + GetCachePath().string() + "\n";
text += SettingsText();
return text;
}
+4 -1
View File
@@ -143,7 +143,10 @@ namespace ui
// refuses for a file chooser, so no picker would open at all.
SDL_PropertiesID props = SDL_CreateProperties();
SDL_SetPointerProperty(props, SDL_PROP_FILE_DIALOG_WINDOW_POINTER, GetWindow());
if (const char* home = std::getenv("HOME"))
// Portable mode: it opens in SpeedBreaker's folder, where the game moves with it.
if (const portable::Layout& layout = portable::Current(); layout.Active())
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_LOCATION_STRING, layout.root.string().c_str());
else if (const char* home = std::getenv("HOME"))
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_LOCATION_STRING, home);
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_TITLE_STRING, "Choose where to install the game");
#ifdef __APPLE__
+97
View File
@@ -0,0 +1,97 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ui.h.
//
// The page shown instead of the game when it can't start: today, when the
// guest's 4 GB of address space couldn't be reserved (kernel/memory_status.h:
// on iOS, an app signed without Extended Virtual Addressing). Before the
// game, like the installer: nothing of the game runs behind it. On iOS it
// stays up (an app doesn't quit itself; the player swipes it away); elsewhere
// OK, A, B, Start or Esc closes it, and the window's close button too.
#include <stdafx.h>
#include "ui.h"
#include <imgui.h>
namespace ui
{
namespace
{
constexpr bool kCanQuit =
#if defined(__APPLE__) && TARGET_OS_IOS
false;
#else
true;
#endif
struct ErrorPage
{
std::string title;
std::vector<std::string> paragraphs;
std::string details;
bool firstFrame = true;
bool quit = false;
};
void Draw(ErrorPage& page)
{
float s = Scale();
ImGuiViewport* viewport = ImGui::GetMainViewport();
float width = std::min(viewport->WorkSize.x - 2 * kSpaceL * s, 760.0f * s);
float maxHeight = viewport->WorkSize.y - 2 * kSpaceL * s;
ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSizeConstraints(ImVec2(width, 0), ImVec2(width, maxHeight));
constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize;
if (page.firstFrame || !ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow))
ImGui::SetNextWindowFocus();
if (!ImGui::Begin("Can't start", nullptr, flags))
{
ImGui::End();
return;
}
TitleText(page.title.c_str());
ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f);
for (size_t i = 0; i < page.paragraphs.size(); i++)
{
if (i > 0)
ImGui::Dummy(ImVec2(0, kSpaceS * s));
ImGui::TextWrapped("%s", page.paragraphs[i].c_str());
}
if (!page.details.empty())
{
ImGui::Dummy(ImVec2(0, kSpaceM * s));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
ImGui::TextWrapped("%s", page.details.c_str());
ImGui::PopStyleColor();
}
ImGui::PopTextWrapPos();
if (kCanQuit)
{
ImGui::Dummy(ImVec2(0, kSpaceS * s));
if (page.firstFrame)
ImGui::SetKeyboardFocusHere();
bool quit = ImGui::Button("Quit");
if (quit || ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)
|| ImGui::IsKeyPressed(ImGuiKey_GamepadStart, false))
page.quit = true;
}
page.firstFrame = false;
ImGui::End();
}
}
void RunStartupError(const std::string& title, const std::vector<std::string>& paragraphs, const std::string& details,
const std::function<bool()>& runFrame)
{
if (!Ready())
return;
ErrorPage page{ title, paragraphs, details };
SetModal([&page] { Draw(page); });
fprintf(stderr, "[ui] showing \"%s\" (%s)\n", title.c_str(),
kCanQuit ? "until it is closed" : "until the app is closed");
while (!page.quit && runFrame())
;
SetModal(nullptr);
fprintf(stderr, "[ui] \"%s\" closed\n", title.c_str());
}
}
+33 -7
View File
@@ -5,6 +5,7 @@
#include <kernel/xam.h>
#include <report/report.h>
#include <user/settings.h>
#include <video/swapchain_policy.h>
#include <imgui.h>
#include <imgui_impl_sdl3.h>
@@ -106,6 +107,7 @@ namespace ui
float s_appliedScale = 0.0f;
uint32_t s_framebufferW = 0, s_framebufferH = 0; // SetFramebufferSize
bool s_displayFromSwapchain = false; // the layout follows the swapchain, not the window (FitUiDisplay)
ImGuiStyle s_baseStyle;
// Back + Start on one controller toggles the settings menu; both must
@@ -826,21 +828,45 @@ namespace ui
return false;
ImGui_ImplVulkan_NewFrame();
ImGui_ImplSDL3_NewFrame();
// SDL gave the window's pixels at the screen's scale; a smaller
// swapchain (iOS's present scale) needs its own for the viewport,
// clip rectangles and font density.
if (ImGuiIO& io = ImGui::GetIO(); s_framebufferW && io.DisplaySize.x > 0 && io.DisplaySize.y > 0)
io.DisplayFramebufferScale = ImVec2(float(s_framebufferW) / io.DisplaySize.x, float(s_framebufferH) / io.DisplaySize.y);
// SDL gave the window's size and pixels; the UI is drawn into the
// swapchain, which can be smaller (iOS's present scale) and, for a
// moment, another shape (a view SDL turned portrait while it is
// fitted back): its own scale for the viewport, clip rectangles and
// font density, and its shape for the layout (and so the UI scale,
// which follows the height).
ImGuiIO& io = ImGui::GetIO();
if (s_framebufferW && io.DisplaySize.x > 0 && io.DisplaySize.y > 0)
{
const video::UiDisplay fit = video::FitUiDisplay(io.DisplaySize.x, io.DisplaySize.y, { s_framebufferW, s_framebufferH });
if (fit.fromSwapchain != s_displayFromSwapchain)
{
s_displayFromSwapchain = fit.fromSwapchain;
if (fit.fromSwapchain)
fprintf(stderr, "[ui] laying out at the swapchain's %.0fx%.0f points (%ux%u pixels), not the window's %.0fx%.0f\n",
fit.width, fit.height, s_framebufferW, s_framebufferH, io.DisplaySize.x, io.DisplaySize.y);
else
fprintf(stderr, "[ui] laying out at the window's %.0fx%.0f points again\n", io.DisplaySize.x, io.DisplaySize.y);
}
io.DisplaySize = ImVec2(fit.width, fit.height);
io.DisplayFramebufferScale = ImVec2(fit.scaleX, fit.scaleY);
}
UpdateScale();
ImGui::NewFrame();
// Panels and toasts lay out in the work area: the window's safe area,
// clear of an iPhone's Dynamic Island and home indicator (elsewhere
// the whole window, as before).
// (The window's, in its points: kept inside the display, which is
// the swapchain's shape while FitUiDisplay overrules the window.)
if (SDL_Rect safe; s_window && SDL_GetWindowSafeArea(s_window, &safe) && safe.w > 0 && safe.h > 0)
{
ImGuiViewport* viewport = ImGui::GetMainViewport();
viewport->WorkPos = ImVec2(viewport->Pos.x + float(safe.x), viewport->Pos.y + float(safe.y));
viewport->WorkSize = ImVec2(float(safe.w), float(safe.h));
const float x0 = std::clamp(float(safe.x), 0.0f, io.DisplaySize.x), y0 = std::clamp(float(safe.y), 0.0f, io.DisplaySize.y);
const float x1 = std::clamp(float(safe.x + safe.w), x0, io.DisplaySize.x), y1 = std::clamp(float(safe.y + safe.h), y0, io.DisplaySize.y);
if (x1 - x0 >= 1.0f && y1 - y0 >= 1.0f)
{
viewport->WorkPos = ImVec2(viewport->Pos.x + x0, viewport->Pos.y + y0);
viewport->WorkSize = ImVec2(x1 - x0, y1 - y0);
}
}
#if defined(__APPLE__) && TARGET_OS_IOS
DragToScroll();
+13 -3
View File
@@ -24,6 +24,7 @@
#include <mutex>
#include <string>
#include <string_view>
#include <vector>
#include <SDL3/SDL_events.h>
#include <SDL3/SDL_video.h>
@@ -47,9 +48,11 @@ namespace ui
bool Initialize(SDL_Window* window, const VulkanTarget& target);
// The swapchain was recreated in another format.
void SetFormat(VkFormat format);
// The swapchain's size when it isn't the window's pixels (iOS's present
// scale: Screen Resolution or NFSMW_PRESENT_SCALE); 0, 0: the window's
// (the default).
// The swapchain's size (each time it is built). The UI's framebuffer
// scale follows it (iOS's present scale: Screen Resolution or
// NFSMW_PRESENT_SCALE makes it smaller than the window's pixels), and
// its layout too should the window's size disagree with it in shape
// (video/swapchain_policy.h: FitUiDisplay). 0, 0: the window's.
void SetFramebufferSize(uint32_t width, uint32_t height);
// Main thread, with the device idle.
void Shutdown();
@@ -113,6 +116,13 @@ namespace ui
// closes it (A, B, Start, Esc or OK). Nothing without an entry.
void ShowWhatsNew(std::string_view version);
// Main thread, before the game starts (main.cpp): a page saying why the
// game can't start (memory_status.h's StartupFailure), drawn until it is
// closed (Quit, B, Start, Esc; on iOS only by closing the app) or the
// window closes. `runFrame` is video::RunFrame. Nothing without a UI.
void RunStartupError(const std::string& title, const std::vector<std::string>& paragraphs, const std::string& details,
const std::function<bool()>& runFrame);
void OpenSettings();
void CloseSettings(); // saves any change
bool SettingsOpen();
+2
View File
@@ -38,5 +38,7 @@ namespace
std::string MigrateUserFolders()
{
if (portable::Current().Active())
return {}; // the home folder's data isn't SpeedBreaker's in portable mode: leave it as it is
return MigrateFolder(UserDataBase(), DataFolderName()) + MigrateFolder(CacheBase(), CacheFolderName());
}
+28 -6
View File
@@ -7,6 +7,8 @@
#include <TargetConditionals.h>
#endif
#include "portable.h"
// Extracted disc contents (default.xex, NFS/, Movies/): where startup found
// or installed the game (install::FindGameInstall, SetGamePath); before
// that, NFSMW_GAME_DIR or the repo layout.
@@ -54,7 +56,7 @@ inline const char*& CacheFolderName()
return name;
}
// Where the per-user folders go.
// Where the per-user folders go (outside portable mode: user/portable.h).
// macOS: ~/Library/Application Support, ~/Library/Caches
// iOS: the same in the app's container; $XDG_DATA_HOME and
// $XDG_CACHE_HOME when set (test runs: iOS sets HOME itself)
@@ -93,21 +95,41 @@ inline std::filesystem::path CacheBase()
#endif
}
// Per-user data: saves, settings, logs and (by default) the installed game.
inline std::filesystem::path GetUserPath()
// This computer's own data and cache folders, portable mode or not
// (install/portable_switch.h copies between them and SpeedBreaker's folder).
inline std::filesystem::path HomeUserPath()
{
return UserDataBase() / DataFolderName();
}
// Caches that can be deleted any time (compiled shaders, pipelines).
inline std::filesystem::path GetCachePath()
inline std::filesystem::path HomeCachePath()
{
return CacheBase() / CacheFolderName();
}
// Per-user data: saves, settings, logs and (by default) the installed game.
// In portable mode, the data folder in SpeedBreaker's own folder.
inline std::filesystem::path GetUserPath()
{
if (const portable::Layout& layout = portable::Current(); layout.Active())
return layout.data;
return HomeUserPath();
}
// Caches that can be deleted any time (compiled shaders, pipelines). In
// portable mode, the cache folder in SpeedBreaker's own folder.
inline std::filesystem::path GetCachePath()
{
if (const portable::Layout& layout = portable::Current(); layout.Active())
return layout.cache;
return HomeCachePath();
}
// Renames nfsmw-recomp folders to the current name, leaving the old name as
// a link to the new folder (for older builds and scripts). Call first thing
// in main; returns what it did, for the log once that is open.
// in main (after portable::Detect: in portable mode it does nothing, since
// the home folder isn't SpeedBreaker's then); returns what it did, for the
// log once that is open.
std::string MigrateUserFolders();
inline std::filesystem::path GetSavePath(bool)
+116
View File
@@ -0,0 +1,116 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See portable.h.
#include "portable.h"
#include <cerrno>
#include <cstdio>
#include <format>
#include <system_error>
namespace portable
{
namespace
{
namespace fs = std::filesystem;
// Normalized, no trailing separator.
fs::path Normal(const fs::path& path)
{
fs::path p = path.lexically_normal();
while (p.has_relative_path() && p.filename().empty())
p = p.parent_path();
return p;
}
// `relative` stays inside the folder it's relative to (not "", ".",
// or "../...").
bool Inside(const fs::path& relative)
{
return !relative.empty() && relative != "." && *relative.begin() != "..";
}
}
Layout Resolve(const std::filesystem::path& root, bool marker, bool supported)
{
Layout layout;
if (!supported || !marker || root.empty() || !root.is_absolute())
return layout;
layout.root = Normal(root);
layout.data = layout.root / kDataFolder;
layout.cache = layout.root / kCacheFolder;
return layout;
}
bool MarkerIn(const std::filesystem::path& root)
{
std::error_code ec;
return !root.empty() && fs::is_regular_file(root / kMarker, ec);
}
const Layout& Detect(const std::filesystem::path& root)
{
CurrentLayout() = Resolve(root, kSupported && MarkerIn(root));
return Current();
}
std::string RecordText(const std::filesystem::path& folder, const Layout& layout)
{
fs::path path = Normal(folder);
if (layout.Active())
if (fs::path relative = path.lexically_relative(layout.root); Inside(relative))
return relative.generic_string();
return path.string();
}
std::optional<std::filesystem::path> ParseRecord(std::string_view text, const Layout& layout)
{
while (!text.empty() && (text.back() == '\n' || text.back() == '\r'))
text.remove_suffix(1);
if (text.empty())
return std::nullopt;
fs::path path{ std::string(text) };
// (Records before v0.1.1 kept `--dest dir/`'s trailing separator.)
if (path.is_absolute())
return Normal(path);
if (!layout.Active())
return std::nullopt;
fs::path relative = path.lexically_normal();
if (!Inside(relative) || relative.has_root_path())
return std::nullopt;
return Normal(layout.root / relative);
}
int CheckWritable(const Layout& layout)
{
if (!layout.Active())
return 0;
std::error_code ec;
fs::create_directories(layout.data, ec);
if (ec)
{
std::error_condition condition = ec.default_error_condition();
return condition.category() == std::generic_category() ? condition.value() : EIO;
}
// A real write: a read-only mount, a locked card or permissions all
// show here, whatever the file system.
fs::path probe = layout.data / ".write-check";
errno = 0;
FILE* f = fopen(probe.string().c_str(), "wb");
if (!f)
return errno ? errno : EACCES;
bool ok = fputc('1', f) != EOF;
int error = errno;
ok = fclose(f) == 0 && ok;
if (!ok && !error)
error = errno;
fs::remove(probe, ec);
return ok ? 0 : (error ? error : EIO);
}
std::string Describe(const Layout& layout)
{
if (!layout.Active())
return {};
return std::format("[paths] portable mode ({} in {}): data in {}, caches in {}\n", kMarker, layout.root.string(),
layout.data.string(), layout.cache.string());
}
}
+114
View File
@@ -0,0 +1,114 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Portable mode (GitHub issue #1): everything SpeedBreaker keeps (settings,
// saves, logs, caches and, by default, the installed game) in SpeedBreaker's
// own folder, so the whole folder can be moved to another machine and run
// there. Opt-in: a file named portable.txt in that folder (the one with
// speedbreaker.sh; a development build's program folder; on Windows, the
// one with SpeedBreaker.exe: install::BundleFolder()). Its contents are
// ignored.
//
// Why a file: it travels with the folder (an environment variable lives in
// a Steam shortcut or a shell profile, per machine, so the next machine
// would quietly use its own data folder), nothing makes it by accident (the
// download never holds one, and unpacking an update over the folder leaves
// it), and players know it from Xenia, Dolphin and DuckStation. Not a data/
// folder as the switch: deleting or renaming a folder would flip the mode
// without a word, and a leftover can't be told from an opt-in.
//
// The layout, in SpeedBreaker's folder (the "root"):
// data/ what GetUserPath() is otherwise (~/.local/share/speedbreaker),
// the same layout: settings.toml, profile_settings.bin, save/,
// logs/ (with crash and hang reports), game-location, and game/,
// the installer's default
// cache/ what GetCachePath() is otherwise (~/.cache/speedbreaker):
// SPIR-V (GPU-independent, keyed by shader source) and the Vulkan
// pipeline cache (its header names the GPU and driver: another
// GPU's driver ignores it and it is rebuilt). Beside data/, so it
// can be deleted without touching saves.
// Nothing outside the folder is read or written for SpeedBreaker's data:
// MigrateUserFolders does nothing, XDG_DATA_HOME and XDG_CACHE_HOME are
// ignored, and no symlink is made. The game's location is recorded relative
// to the folder when it is inside it (RecordText), so it moves with it.
//
// Not on Apple platforms: a signed Mac app can't hold data (macOS stops
// opening it), a folder beside it is App-Translocated or privacy-prompted in
// the common cases, and iOS apps write only their container. There the
// layout is never active.
//
// Switching an existing setup in or out with a copy: install/portable_switch.h
// (speedbreaker.sh --portable on|off). By hand, making or deleting the file
// switches with nothing copied.
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
namespace portable
{
inline constexpr const char* kMarker = "portable.txt";
inline constexpr const char* kDataFolder = "data";
inline constexpr const char* kCacheFolder = "cache";
#if defined(__APPLE__)
inline constexpr bool kSupported = false;
#else
inline constexpr bool kSupported = true;
#endif
// Where portable mode keeps things; all empty when it's off.
struct Layout
{
std::filesystem::path root; // SpeedBreaker's folder: absolute, normalized, no trailing separator
std::filesystem::path data; // root/data
std::filesystem::path cache; // root/cache
bool Active() const { return !root.empty(); }
};
// Pure: the layout for SpeedBreaker's folder `root` (absolute; empty if
// unknown), with the marker in it or not. Off unless supported, marked
// and absolute.
Layout Resolve(const std::filesystem::path& root, bool marker, bool supported = kSupported);
// The layout in effect: set by Detect at startup, before any other thread
// starts and before anything uses a data folder (paths.h reads it).
inline Layout& CurrentLayout()
{
static Layout layout;
return layout;
}
inline const Layout& Current()
{
return CurrentLayout();
}
// Is there a portable.txt in `root`: a file, or a link to one (one stat)?
bool MarkerIn(const std::filesystem::path& root);
// Startup: Resolve(root, MarkerIn(root)) made current, and returned. No
// look at the disk on Apple platforms.
const Layout& Detect(const std::filesystem::path& root);
// The text recorded for the game folder `folder` (absolute, links
// resolved): relative to the root, with '/' separators, when portable
// mode is on and the folder is inside the root; otherwise the absolute
// path.
std::string RecordText(const std::filesystem::path& folder, const Layout& layout);
// The folder a record names, or nullopt: an absolute path as it is (no
// trailing separator); a relative one only in portable mode, inside the
// root (resolved against where the root is now; "../" out of it is
// ignored). Trailing newlines are ignored.
std::optional<std::filesystem::path> ParseRecord(std::string_view text, const Layout& layout);
// Startup, portable mode: makes the data folder if needed and writes a
// small file there (then deletes it). 0 if that worked, else the errno
// (EROFS, EACCES...): settings and saves won't be kept. 0 when off.
int CheckWritable(const Layout& layout);
// One log line: where the data and caches are ("" when off).
std::string Describe(const Layout& layout);
}
+8 -4
View File
@@ -83,11 +83,15 @@ namespace settings
{ int32_t(AspectMode::Widescreen), "16:9", "16:9" },
};
// The scale in half steps (settings.h); the tokens are what
// settings.toml and NFSMW_RESOLUTION hold, so older files read as
// before.
constexpr EnumEntry kResolutionEntries[] = {
{ kResolutionAuto, "auto", "Auto" },
{ 1, "1x", "1x (720p)" },
{ 2, "2x", "2x (1440p)" },
{ 3, "3x", "3x (2160p)" },
{ 2, "1x", "1x (720p)" },
{ 3, "1.5x", "1.5x (1080p)" },
{ 4, "2x", "2x (1440p)" },
{ 6, "3x", "3x (2160p)" },
};
constexpr EnumEntry kAnisotropyEntries[] = {
@@ -146,7 +150,7 @@ namespace settings
.env = "NFSMW_TEXTURE_MIPS", .defaultValue = 1 },
{ .id = Id::InternalResolution, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
.table = "video", .key = "internal_resolution", .label = "Internal Resolution",
.description = "Render the 3D scene above the 360's 720p. Auto: from the screen (1440p: 2x, or 3x2 filling an ultrawide).",
.description = "Render the 3D scene above the 360's 720p. 1.5x is 1080p, a little over half the pixels of 2x. Auto: from the screen (1440p: 2x, or 3x2 filling an ultrawide).",
.env = "NFSMW_RESOLUTION", .defaultValue = kResolutionAuto, .entries = kResolutionEntries },
{ .id = Id::WindowWidth, .category = Category::Display, .type = Type::Int, .apply = Apply::Restart, .hidden = true,
.table = "video", .key = "window_width", .label = "Window Width",
+4 -2
View File
@@ -76,8 +76,10 @@ namespace settings
// Frame Rate: 60, 30, or Auto (video/frame_rate.h: 60, or 30 while the
// device is hot or can't hold 60).
constexpr int32_t kFrameRateAuto = 0;
// Internal resolution: 1-3 renders at that multiple of 720p; Auto picks
// it from the screen area the picture covers (see renderer).
// Internal resolution: the multiple of 720p it renders at, in half steps
// (2: 1x, 3: 1.5x = 1080p, 4: 2x, 6: 3x; only the tokens "1x", "1.5x"...
// are saved, so the numbers are free to change); Auto picks it from the
// screen area the picture covers (see renderer).
constexpr int32_t kResolutionAuto = 0;
// Auto: a screen of another shape is filled (video/picture_fit), the HUD
// kept in a centred 16:9 block: a wider one with a wider view (Hor+), a
+59
View File
@@ -0,0 +1,59 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Post-processing that stays on its side of the 16:9 band's edge
// (video/presenter.cpp's anti-aliasing and scaling shaders do this in GLSL,
// line for line; tests/band_filter_test.cpp checks it here).
//
// Under Vert+ and Hor+ the frame holds the 16:9 band (video::BandOf) and the
// strips past it, and what the game draws often ends exactly at the band's
// edge: the movie player letterboxes (or pillarboxes) a movie into the band
// (game/ultrawide.cpp tells it the screen's aspect), and the edge-bar fills
// black out the strips (gpu/edge_bars.h). Both filters read across that
// edge: the anti-aliasing (an FXAA-style pass at the frame's resolution)
// blended the strip's last row towards the band's first, by up to half on a
// textured edge, and the scaling (Catmull-Rom from bilinear taps) a few per
// cent more; the career intro's movie (storyfmv_rac01) showed as a faint row
// just past each band edge (max 40-58 of 255 at 1280x800, the frame's own
// rows exactly black). With this each pixel's taps are kept to its own span
// of the three ([0, band start), the band, [band end, size)) along each
// axis, as if that span were the whole image (clamp to edge): a strip pixel
// reads only the strip, a band pixel only the band.
//
// Units: texels of the frame image, 0 at its edge, texel i covering
// [i, i + 1) with its centre at i + 0.5.
#pragma once
#include <algorithm>
#include <cstdint>
namespace video::band
{
// The span along one axis holding coordinate t: [0, lo), [lo, hi) or
// [hi, size); its first texel `a` and end `b`.
struct Span
{
int32_t a, b;
};
inline Span SpanOf(float t, int32_t lo, int32_t hi, int32_t size)
{
if (t < float(lo))
return { 0, lo };
if (t < float(hi))
return { lo, hi };
return { hi, size };
}
// A sample coordinate kept between the span's first and last texel
// centres: a bilinear fetch there weighs only that span's texels.
inline float Clamp(float t, Span s)
{
return std::clamp(t, float(s.a) + 0.5f, float(s.b) - 0.5f);
}
// A texel index kept in the span (a gather's 2x2: the row or column
// past the span's edge reads its edge one instead).
inline int32_t ClampTexel(int32_t i, Span s)
{
return std::clamp(i, s.a, s.b - 1);
}
}
+224
View File
@@ -0,0 +1,224 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Which Vulkan device the presenter draws with (video/presenter.cpp,
// ChooseDevice), and the log's one line about it. Up to v0.1.1 it was the
// loader's first that could present to the window. On a laptop with two GPUs
// (an Intel or AMD one in the processor beside an NVIDIA or AMD graphics
// card) the loader, and Mesa's device-select layer, usually list the
// integrated one first, so the game ran there (GitHub PR #6). Now, of the
// devices that can present to the window:
// 1. one with every device extension the game needs, before one without
// (the game can't start on that);
// 2. a discrete GPU, then an integrated one, a virtual one, any other kind,
// then a software renderer (llvmpipe); a layered one (Mesa's Dozen,
// Vulkan on Direct3D 12, which lists the same GPUs again) after every
// native driver's;
// 3. among equals, the one with the most device-local memory;
// 4. then the loader's order.
// A device that can't present to the window is never chosen. So where only
// one can present (a Steam Deck, a Steam Machine, a Steam Frame, a Mac, an
// iPhone or iPad: one GPU each), it is that one, as before.
// NFSMW_GPU=<index or part of a name> picks one by hand: an index from the
// log's list, or any part of a name, in any case (several matching: the best
// of them by the rules above). A number that is no device's index is tried as
// part of a name ("3050").
//
// Header only and Vulkan-free (`type` holds VkPhysicalDeviceType's values):
// tests/gpu_choice_test.cpp. The windows branch's first version of this file
// had rules 2 and 4 (and NFSMW_GPU); this one adds 1 and 3, and the log line.
#pragma once
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <format>
#include <string>
#include <vector>
namespace video
{
struct GpuCandidate
{
size_t index; // in the loader's list (vkEnumeratePhysicalDevices)
int type; // VkPhysicalDeviceType: 1 integrated, 2 discrete, 3 virtual, 4 CPU, else other
bool layered = false; // Mesa's Dozen: Vulkan on Direct3D 12
std::string name; // deviceName
uint64_t localBytes = 0; // its device-local memory heaps, added up
bool presents = true; // a graphics queue family can present to the window
std::string lacks; // a device extension the game needs that it doesn't have ("": none)
};
inline constexpr size_t kNoGpu = size_t(-1);
inline const char* GpuKind(int type)
{
return type == 2 ? "discrete" : type == 1 ? "integrated" : type == 3 ? "virtual" : type == 4 ? "software" : "other";
}
// "a discrete GPU", "an integrated GPU on Direct3D 12".
inline std::string GpuDescription(const GpuCandidate& c)
{
const char* text = c.type == 2 ? "a discrete GPU" : c.type == 1 ? "an integrated GPU" : c.type == 3 ? "a virtual GPU"
: c.type == 4 ? "a software renderer" : "a device of another kind";
return std::format("{}{}", text, c.layered ? " on Direct3D 12" : "");
}
// "7.6 GB" (binary gigabytes, as drivers size their heaps).
inline std::string GpuMemory(uint64_t bytes)
{
return std::format("{:.1f} GB", double(bytes) / double(1ull << 30));
}
// Rule 2's order: lower comes first.
inline int GpuKindRank(const GpuCandidate& c)
{
int kind = c.type == 2 ? 0 : c.type == 1 ? 1 : c.type == 3 ? 2 : c.type == 4 ? 4 : 3;
return kind + (c.layered ? 5 : 0);
}
// Rules 1-4: whether `a` comes before `b`.
inline bool GpuBefore(const GpuCandidate& a, const GpuCandidate& b)
{
if (a.lacks.empty() != b.lacks.empty())
return a.lacks.empty();
if (GpuKindRank(a) != GpuKindRank(b))
return GpuKindRank(a) < GpuKindRank(b);
if (a.localBytes != b.localBytes)
return a.localBytes > b.localBytes;
return a.index < b.index;
}
struct GpuChoice
{
size_t pick = kNoGpu; // into the list; kNoGpu: none can present to the window
std::string why; // why that one, for the log
std::string want; // NFSMW_GPU as given ("": not set)
std::string wantNote; // when NFSMW_GPU was set but not used: why not ("": used, or not set)
};
namespace detail
{
inline std::string Lower(std::string text)
{
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return char(std::tolower(c)); });
return text;
}
inline std::string Trim(const char* text)
{
std::string s(text ? text : "");
size_t b = s.find_first_not_of(" \t"), e = s.find_last_not_of(" \t");
return b == std::string::npos ? std::string() : s.substr(b, e - b + 1);
}
// The best of the presenting devices, by rules 1-4 (kNoGpu: none).
inline size_t Best(const std::vector<GpuCandidate>& devices, size_t skip = kNoGpu)
{
size_t best = kNoGpu;
for (size_t i = 0; i < devices.size(); i++)
if (i != skip && devices[i].presents && (best == kNoGpu || GpuBefore(devices[i], devices[best])))
best = i;
return best;
}
// Why `pick` came before `next` (the best of the rest), by the first
// rule that tells them apart.
inline std::string Why(const GpuCandidate& pick, const GpuCandidate& next)
{
if (pick.lacks.empty() != next.lacks.empty())
return std::format("{} {} lacks {}, which the game needs", next.index, next.name, next.lacks);
if (GpuKindRank(pick) != GpuKindRank(next))
return std::format("{} before {}", GpuDescription(pick), GpuDescription(next));
if (pick.localBytes != next.localBytes)
return std::format("{}, with more device-local memory than {}: {} against {}", GpuDescription(pick), next.index,
GpuMemory(pick.localBytes), GpuMemory(next.localBytes));
return std::format("{}, with as much device-local memory as {}: the loader's order", GpuDescription(pick), next.index);
}
}
// `devices`: every device the loader lists, in its order. `want`:
// NFSMW_GPU, or null.
inline GpuChoice ChooseGpu(const std::vector<GpuCandidate>& devices, const char* want)
{
GpuChoice choice;
choice.pick = detail::Best(devices);
if (choice.pick == kNoGpu)
return choice;
const size_t next = detail::Best(devices, choice.pick);
choice.why = next == kNoGpu ? std::string("the only one that can present to the window")
: detail::Why(devices[choice.pick], devices[next]);
choice.want = detail::Trim(want);
if (choice.want.empty())
return choice;
// An index from the log's list, else part of a name.
size_t named = kNoGpu;
char* end = nullptr;
unsigned long long index = std::strtoull(choice.want.c_str(), &end, 10);
if (end != choice.want.c_str() && *end == '\0' && std::isdigit((unsigned char)choice.want[0]))
for (size_t i = 0; i < devices.size() && named == kNoGpu; i++)
if (devices[i].index == index)
named = i;
bool anyName = false;
if (named == kNoGpu)
{
const std::string part = detail::Lower(choice.want);
for (size_t i = 0; i < devices.size(); i++)
if (detail::Lower(devices[i].name).find(part) != std::string::npos)
{
anyName = true;
if (devices[i].presents && (named == kNoGpu || GpuBefore(devices[i], devices[named])))
named = i;
}
}
if (named != kNoGpu && devices[named].presents)
{
choice.pick = named;
choice.why = std::format("NFSMW_GPU={}", choice.want);
return choice;
}
choice.wantNote = named != kNoGpu
? std::format("NFSMW_GPU={} names {} {}, which can't present to the window", choice.want, devices[named].index, devices[named].name)
: anyName ? std::format("NFSMW_GPU={} names only devices that can't present to the window", choice.want)
: std::format("NFSMW_GPU={} names none of them (an index from this list, or part of a name)", choice.want);
return choice;
}
// The log's line (after "[video] "): every device the loader listed, the
// one used, and why.
// Vulkan devices: 0 Intel(R) Iris(R) Xe Graphics (integrated, 7.6 GB),
// 1 NVIDIA GeForce RTX 3050 Laptop GPU (discrete, 4.0 GB); using 1: a
// discrete GPU before an integrated GPU (the loader listed 0 first);
// NFSMW_GPU=<index or part of a name> picks another
inline std::string GpuLogLine(const std::vector<GpuCandidate>& devices, const GpuChoice& choice)
{
std::string line = "Vulkan devices: ";
if (devices.empty())
line += "none";
size_t presenting = 0, first = kNoGpu;
for (size_t i = 0; i < devices.size(); i++)
{
const GpuCandidate& c = devices[i];
line += std::format("{}{} {} ({}{}{}{}{})", i ? ", " : "", c.index, c.name, GpuKind(c.type),
c.layered ? " on Direct3D 12" : "", c.localBytes ? ", " + GpuMemory(c.localBytes) : std::string(),
c.presents ? "" : ", can't present to the window", c.lacks.empty() ? std::string() : ", lacks " + c.lacks);
if (c.presents)
{
presenting++;
if (first == kNoGpu)
first = i;
}
}
if (choice.pick == kNoGpu)
return line + "; none can present to the window";
line += std::format("; using {}: {}", devices[choice.pick].index,
choice.wantNote.empty() ? choice.why : choice.wantNote + ", so the usual choice: " + choice.why);
if (presenting > 1)
{
line += choice.pick == first ? " (the loader listed it first)" : std::format(" (the loader listed {} first)", devices[first].index);
if (choice.want.empty())
line += "; NFSMW_GPU=<index or part of a name> picks another";
}
return line;
}
}
+18
View File
@@ -1,6 +1,8 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See picture_fit.h.
#include "picture_fit.h"
#include <gpu/edge_bars.h>
#include <algorithm>
#include <cmath>
@@ -58,6 +60,22 @@ namespace video
return fit;
}
FrameBand BandOf(uint32_t width, uint32_t height, float scaleX, float scaleY)
{
FrameBand band{ 0, int32_t(width), 0, int32_t(height) };
if (scaleX < 1.0f)
{
band.x0 = gpu::bars::Edge(-scaleX, float(width));
band.x1 = gpu::bars::Edge(scaleX, float(width));
}
if (scaleY < 1.0f)
{
band.y0 = gpu::bars::Edge(-scaleY, float(height));
band.y1 = gpu::bars::Edge(scaleY, float(height));
}
return band;
}
void AutoRenderScale(uint32_t regionW, uint32_t regionH, uint32_t budget, uint32_t& sx, uint32_t& sy)
{
// Enough host pixels to cover the screen area the frame fills
+12
View File
@@ -53,6 +53,18 @@ namespace video
// for another one (a redraw after a resize) or clamped to 4:3.
PictureFit PlacePicture(uint32_t screenW, uint32_t screenH, float frameW, float frameH, float scaleX, float scaleY);
// The 16:9 band of a frame image of width x height texels (any internal
// resolution) rendered with the view scales (scaleX, scaleY): what a
// 16:9 screen shows, the columns [x0, x1) and rows [y0, y1) whose
// centres lie inside clip +-scaleX and +-scaleY, by gpu::bars::Edge's
// rule, so the presenter splits a frame where the renderer's band clip
// and edge-bar fills do. The whole image at 1, 1.
struct FrameBand
{
int32_t x0 = 0, x1 = 0, y0 = 0, y1 = 0;
};
FrameBand BandOf(uint32_t width, uint32_t height, float scaleX, float scaleY);
// Auto internal resolution: the render scale (sx x sy host pixels per
// guest pixel, 1-3 each) for a picture covering regionW x regionH of the
// screen (video::FrameRegion), within `budget` host pixels per guest
+419 -83
View File
@@ -9,8 +9,10 @@
#include <apu/audio.h>
#include <gpu/renderer.h>
#include <game/ultrawide.h>
#include "gpu_choice.h"
#include "picture_fit.h"
#include "frame_rate.h"
#include "swapchain_policy.h"
#include <cpu/guest_thread.h>
#include <cpu/guest_time.h>
#include "vblank_lock.h"
@@ -21,6 +23,7 @@
#include <user/settings.h>
#include <report/report.h>
#include <platform/thermal.h>
#include <kernel/memory_status.h>
#include <SDL3/SDL.h>
#include <SDL3/SDL_vulkan.h>
@@ -155,10 +158,18 @@ namespace video
std::condition_variable s_foreground;
bool SDLCALL OnAppEvent(void*, SDL_Event* e);
// Main thread: RunFrame stopped the controller's rumble for the
// suspension (hid::Suspend), and a window resize waits for the
// resume to rebuild the swapchain (no GPU work before it).
// suspension (hid::Suspend).
bool s_inputSuspended = false;
bool s_swapchainStale = false;
// When the swapchain is built again, and at what size (main thread;
// swapchain_policy.h): not while the app is inactive, the surface
// watched after a resume, a size event or a new swapchain,
// SUBOPTIMAL/OUT_OF_DATE followed, SDL's view fitted back to its
// window on iOS, and no portrait swapchain on a landscape iPhone.
SwapchainPolicy s_swapPolicy;
// What the next CreateSwapchain is for (its log line), and how many
// rebuilds at an unchanged size have been logged.
const char* s_swapReason = nullptr;
int s_sameSizeLogged = 0;
// Bug-report screenshots waiting for the next frame drawn (CaptureFrame).
std::mutex s_captureMutex;
std::vector<std::promise<CapturedFrame>> s_captureRequests;
@@ -349,6 +360,57 @@ namespace video
}
}
// The surface's extent: Vulkan's current extent, else (Wayland: the
// swapchain decides) the window's pixels.
Extent SurfaceExtent(const VkSurfaceCapabilitiesKHR& caps)
{
if (caps.currentExtent.width != UINT32_MAX)
return { caps.currentExtent.width, caps.currentExtent.height };
int w = 0, h = 0;
SDL_GetWindowSizeInPixels(s_window, &w, &h);
return { uint32_t(std::max(w, 0)), uint32_t(std::max(h, 0)) };
}
Extent SurfaceExtent()
{
VkSurfaceCapabilitiesKHR caps{};
if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(s_physical, s_surface, &caps) != VK_SUCCESS)
return {};
return SurfaceExtent(caps);
}
// The interface's orientation and whether the app is landscape-only
// there: iOS's (an iPhone is), unknown and no elsewhere.
Orientation InterfaceNow()
{
#if defined(__APPLE__) && TARGET_OS_IOS
return platform::ios::InterfaceOrientation(s_window);
#else
return Orientation::Unknown;
#endif
}
bool LandscapeOnlyHere()
{
#if defined(__APPLE__) && TARGET_OS_IOS
return platform::ios::LandscapeOnly();
#else
return false;
#endif
}
// iOS: the view, its window and the scene, for a log line ("" elsewhere).
std::string GeometryNote()
{
#if defined(__APPLE__) && TARGET_OS_IOS
return ": " + platform::ios::DescribeGeometry(s_window);
#else
return {};
#endif
}
void UpdateDisplayItem();
void DestroySwapchain()
{
{
@@ -380,16 +442,22 @@ namespace video
#endif
VkSurfaceCapabilitiesKHR caps;
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(s_physical, s_surface, &caps);
int w, h;
SDL_GetWindowSizeInPixels(s_window, &w, &h);
s_swapExtent = caps.currentExtent.width != UINT32_MAX ? caps.currentExtent
: VkExtent2D{ uint32_t(w), uint32_t(h) };
const VkExtent2D previous = s_swapExtent;
// A landscape-only iPhone takes no portrait swapchain under a
// landscape interface (swapchain_policy.h): the same size turned,
// while the view is fitted back to its window.
const Extent surface = SurfaceExtent(caps);
const ExtentChoice choice = ChooseExtent(surface, InterfaceNow(), LandscapeOnlyHere());
s_swapExtent = { choice.extent.width, choice.extent.height };
if (s_swapExtent.width == 0 || s_swapExtent.height == 0)
return false; // minimised
if (choice.refused)
fprintf(stderr, "[video] the surface is %ux%u, portrait, under a landscape interface: the swapchain is %ux%u instead\n",
surface.width, surface.height, s_swapExtent.width, s_swapExtent.height);
s_regionExtent.store(uint64_t(s_swapExtent.width) << 32 | s_swapExtent.height, std::memory_order_relaxed);
// The UI draws to the drawables' size, not the window's pixels
// (0, 0: they're the window's).
ui::SetFramebufferSize(s_presentScale < 1.0f ? s_swapExtent.width : 0, s_presentScale < 1.0f ? s_swapExtent.height : 0);
// The UI lays out at and draws to the swapchain's size (the
// drawables' with iOS's present scale), not the window's pixels.
ui::SetFramebufferSize(s_swapExtent.width, s_swapExtent.height);
uint32_t count = 0;
vkGetPhysicalDeviceSurfaceFormatsKHR(s_physical, s_surface, &count, nullptr);
@@ -479,13 +547,25 @@ namespace video
}
if (s_uiReady)
ui::SetFormat(s_swapFormat);
s_swapPolicy.Created({ s_swapExtent.width, s_swapExtent.height });
static VkExtent2D logged{};
const char* reason = std::exchange(s_swapReason, nullptr);
if (logged.width != s_swapExtent.width || logged.height != s_swapExtent.height)
{
if (logged.width) // the first one is logged with the device
fprintf(stderr, "[video] swapchain %ux%u\n", s_swapExtent.width, s_swapExtent.height);
{
fprintf(stderr, "[video] swapchain %ux%u (was %ux%u%s%s)%s\n", s_swapExtent.width, s_swapExtent.height, logged.width,
logged.height, reason ? "; " : "", reason ? reason : "", GeometryNote().c_str());
UpdateDisplayItem();
}
logged = s_swapExtent;
}
else if (reason && previous.width && s_sameSizeLogged < 10)
{
s_sameSizeLogged++;
fprintf(stderr, "[video] swapchain %ux%u again (%s)%s\n", s_swapExtent.width, s_swapExtent.height, reason,
s_sameSizeLogged == 10 ? "; no more same-size rebuilds logged" : "");
}
vkGetSwapchainImagesKHR(s_device, s_swapchain, &count, nullptr);
s_swapImages.resize(count);
@@ -683,13 +763,30 @@ void main()
layout(local_size_x = 8, local_size_y = 8) in;
layout(set = 0, binding = 0) uniform sampler2D src;
layout(set = 0, binding = 1, rgba8) uniform writeonly image2D dst;
layout(push_constant) uniform P { ivec2 size; float subpixel; float pad; } p;
// band: the 16:9 band's columns x..y and rows z..w (video::BandOf), used
// when bandOn is 1 (video/band_filter.h).
layout(push_constant) uniform P { ivec2 size; float subpixel; uint bandOn; ivec4 band; } p;
const float kEdgeMin = 0.0312; // no AA on darker contrast than this
const float kEdgeRelative = 0.125; // ... or than this fraction of the local maximum
// This pixel's span of the band's three along each axis, as uv: every fetch
// stays between its first and last texel centres (band_filter.h's Clamp).
vec2 spanLo = vec2(0.0), spanHi = vec2(1.0);
float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
vec3 at(vec2 uv) { return textureLod(src, uv, 0.0).rgb; }
vec3 at(vec2 uv)
{
if (p.bandOn != 0u)
uv = clamp(uv, spanLo, spanHi);
return textureLod(src, uv, 0.0).rgb;
}
// band_filter.h's SpanOf: [0, lo), [lo, hi) or [hi, size) holding t.
vec2 spanOf(float t, int lo, int hi, int size)
{
return t < float(lo) ? vec2(0.0, float(lo)) : t < float(hi) ? vec2(float(lo), float(hi)) : vec2(float(hi), float(size));
}
void main()
{
@@ -698,6 +795,12 @@ void main()
return;
vec2 texel = 1.0 / vec2(p.size);
vec2 uv = (vec2(pix) + 0.5) * texel;
if (p.bandOn != 0u)
{
vec2 sx = spanOf(float(pix.x) + 0.5, p.band.x, p.band.y, p.size.x), sy = spanOf(float(pix.y) + 0.5, p.band.z, p.band.w, p.size.y);
spanLo = (vec2(sx.x, sy.x) + 0.5) * texel;
spanHi = (vec2(sx.y, sy.y) - 0.5) * texel;
}
vec3 center = at(uv);
float lM = luma(center);
float lN = luma(at(uv + vec2(0.0, -texel.y))), lS = luma(at(uv + vec2(0.0, texel.y)));
@@ -767,9 +870,37 @@ void main()
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 color;
layout(set = 0, binding = 0) uniform sampler2D src;
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; } p;
// band: the 16:9 band's columns x..y and rows z..w in texels (video::BandOf),
// used when bandOn is 1 (video/band_filter.h).
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; vec4 band; uint bandOn; } p;
vec3 at(vec2 t) { return textureLod(src, t, 0.0).rgb; }
// The span of the band's three along each axis that this pixel's centre
// falls in, as uv: every tap stays between its first and last texel centres
// (band_filter.h's Clamp), so a strip's pixel reads only the strip.
vec2 spanLo = vec2(0.0), spanHi = vec2(1.0);
vec3 at(vec2 t)
{
if (p.bandOn != 0u)
t = clamp(t, spanLo, spanHi);
return textureLod(src, t, 0.0).rgb;
}
// band_filter.h's SpanOf: [0, lo), [lo, hi) or [hi, size) holding t.
vec2 spanOf(float t, float lo, float hi, float size)
{
return t < lo ? vec2(0.0, lo) : t < hi ? vec2(lo, hi) : vec2(hi, size);
}
void setSpan()
{
if (p.bandOn == 0u)
return;
vec2 pos = uv * p.size;
vec2 sx = spanOf(pos.x, p.band.x, p.band.y, p.size.x), sy = spanOf(pos.y, p.band.z, p.band.w, p.size.y);
spanLo = (vec2(sx.x, sy.x) + 0.5) / p.size;
spanHi = (vec2(sx.y, sy.y) - 0.5) / p.size;
}
// Catmull-Rom bicubic from 9 bilinear taps: the middle two weights of each
// axis are folded into one bilinear fetch.
@@ -791,6 +922,7 @@ vec3 catmullRom(vec2 t, vec2 texel)
void main()
{
setSpan();
if (p.bicubic == 0u)
{
color = vec4(at(uv), 1.0);
@@ -820,12 +952,34 @@ void main()
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 color;
layout(set = 0, binding = 0) uniform sampler2D src;
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; } p;
// band, bandOn: as kScaleFs's.
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; vec4 band; uint bandOn; } p;
vec3 at(vec2 t) { return textureLod(src, t, 0.0).rgb; }
vec2 spanLo = vec2(0.0), spanHi = vec2(1.0); // as kScaleFs's
vec3 at(vec2 t)
{
if (p.bandOn != 0u)
t = clamp(t, spanLo, spanHi);
return textureLod(src, t, 0.0).rgb;
}
vec2 spanOf(float t, float lo, float hi, float size)
{
return t < lo ? vec2(0.0, lo) : t < hi ? vec2(lo, hi) : vec2(hi, size);
}
void main()
{
vec2 sx = vec2(0.0), sy = vec2(0.0);
if (p.bandOn != 0u)
{
vec2 pos = uv * p.size;
sx = spanOf(pos.x, p.band.x, p.band.y, p.size.x);
sy = spanOf(pos.y, p.band.z, p.band.w, p.size.y);
spanLo = (vec2(sx.x, sy.x) + 0.5) / p.size;
spanHi = (vec2(sx.y, sy.y) - 0.5) / p.size;
}
if (p.bicubic == 0u)
{
color = vec4(at(uv), 1.0);
@@ -846,6 +1000,15 @@ void main()
vec2 mid = (c + 0.5) * texel;
vec4 r = textureGather(src, mid, 0), gr = textureGather(src, mid, 1), b = textureGather(src, mid, 2);
vec3 a = vec3(r.w, gr.w, b.w), e = vec3(r.z, gr.z, b.z), d = vec3(r.x, gr.x, b.x), h = vec3(r.y, gr.y, b.y);
if (p.bandOn != 0u)
{
// The gather's row or column past this pixel's span reads the
// span's edge one instead (band_filter.h's ClampTexel): rows a e
// (first) and d h, columns a d (first) and e h.
vec2 first = c - 0.5;
if (first.y < sy.x) { a = d; e = h; } else if (first.y + 1.0 >= sy.y) { d = a; h = e; }
if (first.x < sx.x) { a = e; d = h; } else if (first.x + 1.0 >= sx.y) { e = a; h = d; }
}
vec3 middle = mix(mix(a, e, g.x), mix(d, h, g.x), g.y);
vec3 bilinear = mix(mix(a, e, f.x), mix(d, h, f.x), f.y);
float wN = w12.x * w0.y, wW = w0.x * w12.y, wM = w12.x * w12.y, wE = w3.x * w12.y, wS = w12.x * w3.y;
@@ -986,7 +1149,7 @@ void main()
dslci.bindingCount = 1;
dslci.pBindings = &scaleBinding;
vkCreateDescriptorSetLayout(s_device, &dslci, nullptr, &s_post.scaleSet);
VkPushConstantRange aaRange{ VK_SHADER_STAGE_COMPUTE_BIT, 0, 16 }, scaleRange{ VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16 };
VkPushConstantRange aaRange{ VK_SHADER_STAGE_COMPUTE_BIT, 0, 32 }, scaleRange{ VK_SHADER_STAGE_FRAGMENT_BIT, 0, 36 };
VkPipelineLayoutCreateInfo plci{ VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO };
plci.setLayoutCount = 1;
plci.pSetLayouts = &s_post.aaSet;
@@ -1110,7 +1273,9 @@ void main()
{
WaitIdle();
DestroySwapchain();
s_swapReason = "V-Sync or Screen Resolution";
CreateSwapchain();
s_swapReason = nullptr;
}
}
@@ -1135,6 +1300,80 @@ void main()
settings::Set(settings::Id::WindowHeight, h);
}
}
// The device extensions the game can't start without: Initialize
// enables them, and ChooseDevice puts a device with all of them first.
constexpr const char* kNeededDeviceExtensions[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME,
VK_KHR_DYNAMIC_RENDERING_EXTENSION_NAME, VK_KHR_DEPTH_STENCIL_RESOLVE_EXTENSION_NAME,
VK_KHR_CREATE_RENDERPASS_2_EXTENSION_NAME, VK_KHR_MULTIVIEW_EXTENSION_NAME, VK_KHR_MAINTENANCE_2_EXTENSION_NAME,
VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME };
// The Vulkan device to draw with (s_physical, s_queueFamily; left
// null when none can present to the window), and the log's one line
// listing every device the loader found, the one used and why
// (video/gpu_choice.h: a discrete GPU first, NFSMW_GPU=<index or part
// of a name> to pick another). Up to v0.1.1 it was the loader's first
// that could present, which on a laptop with two GPUs was usually the
// integrated one; where only one device can present it still is that
// one. A device's queue family is its first with graphics that can
// present to the window, as before.
void ChooseDevice(const std::vector<VkPhysicalDevice>& devices)
{
std::vector<GpuCandidate> candidates;
std::vector<uint32_t> families;
for (size_t i = 0; i < devices.size(); i++)
{
VkPhysicalDevice d = devices[i];
VkPhysicalDeviceProperties props;
vkGetPhysicalDeviceProperties(d, &props);
uint32_t qc = 0;
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, nullptr);
std::vector<VkQueueFamilyProperties> qs(qc);
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, qs.data());
uint32_t family = UINT32_MAX;
for (uint32_t f = 0; f < qc && family == UINT32_MAX; f++)
{
VkBool32 present = VK_FALSE;
vkGetPhysicalDeviceSurfaceSupportKHR(d, f, s_surface, &present);
if ((qs[f].queueFlags & VK_QUEUE_GRAPHICS_BIT) && present)
family = f;
}
VkPhysicalDeviceMemoryProperties memory;
vkGetPhysicalDeviceMemoryProperties(d, &memory);
uint64_t local = 0;
for (uint32_t h = 0; h < memory.memoryHeapCount; h++)
if (memory.memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT)
local += memory.memoryHeaps[h].size;
uint32_t extCount = 0;
vkEnumerateDeviceExtensionProperties(d, nullptr, &extCount, nullptr);
std::vector<VkExtensionProperties> exts(extCount);
vkEnumerateDeviceExtensionProperties(d, nullptr, &extCount, exts.data());
std::string lacks;
for (const char* needed : kNeededDeviceExtensions)
if (lacks.empty() && std::none_of(exts.begin(), exts.end(),
[needed](const VkExtensionProperties& e) { return strcmp(e.extensionName, needed) == 0; }))
lacks = needed;
// Mesa's Dozen (Vulkan on Direct3D 12: Windows, WSL) lists GPUs
// a native driver may list too.
bool dozen = false;
if (props.apiVersion >= VK_API_VERSION_1_2)
{
VkPhysicalDeviceDriverProperties driver{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
VkPhysicalDeviceProperties2 props2{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2 };
props2.pNext = &driver;
vkGetPhysicalDeviceProperties2(d, &props2);
dozen = driver.driverID == VK_DRIVER_ID_MESA_DOZEN;
}
candidates.push_back({ i, int(props.deviceType), dozen, props.deviceName, local, family != UINT32_MAX, lacks });
families.push_back(family);
}
GpuChoice choice = ChooseGpu(candidates, std::getenv("NFSMW_GPU"));
fprintf(stderr, "[video] %s\n", GpuLogLine(candidates, choice).c_str());
if (choice.pick == kNoGpu)
return;
s_physical = devices[choice.pick];
s_queueFamily = families[choice.pick];
}
}
bool Initialize()
@@ -1186,6 +1425,9 @@ void main()
// the one swipe the windowed app had. ("2" defers them, if touch
// players leave the game by accident.)
SDL_SetHint(SDL_HINT_IOS_HIDE_HOME_INDICATOR, "0");
// On iPhone, landscape only to SDL too: else it turns its view
// portrait when a keyboard shows or hides (ios_ui.h).
platform::ios::PrepareOrientation();
#endif
s_window = SDL_CreateWindow("SpeedBreaker", windowW, windowH,
SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | (s_fullscreen ? SDL_WINDOW_FULLSCREEN : 0));
@@ -1194,6 +1436,9 @@ void main()
fprintf(stderr, "[video] SDL_CreateWindow: %s\n", SDL_GetError());
return false;
}
#if defined(__APPLE__) && TARGET_OS_IOS
platform::ios::WatchKeyboards(s_window);
#endif
Uint32 extCount = 0;
const char* const* sdlExts = SDL_Vulkan_GetInstanceExtensions(&extCount);
@@ -1244,26 +1489,8 @@ void main()
vkEnumeratePhysicalDevices(s_instance, &count, nullptr);
std::vector<VkPhysicalDevice> devices(count);
vkEnumeratePhysicalDevices(s_instance, &count, devices.data());
for (VkPhysicalDevice d : devices)
{
uint32_t qc = 0;
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, nullptr);
std::vector<VkQueueFamilyProperties> qs(qc);
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, qs.data());
for (uint32_t i = 0; i < qc; i++)
{
VkBool32 present = VK_FALSE;
vkGetPhysicalDeviceSurfaceSupportKHR(d, i, s_surface, &present);
if ((qs[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && present)
{
s_physical = d;
s_queueFamily = i;
break;
}
}
if (s_physical)
break;
}
devices.resize(count);
ChooseDevice(devices);
if (!s_physical)
{
fprintf(stderr, "[video] no Vulkan device can present to the window\n");
@@ -1275,10 +1502,7 @@ void main()
// The renderer needs dynamic rendering, and imports guest memory as
// a buffer where the device can (host-memory import; Turnip, the
// Steam Frame's driver, can't: the renderer's GPU copy instead).
std::vector<const char*> devExts = { VK_KHR_SWAPCHAIN_EXTENSION_NAME,
VK_KHR_DYNAMIC_RENDERING_EXTENSION_NAME, VK_KHR_DEPTH_STENCIL_RESOLVE_EXTENSION_NAME,
VK_KHR_CREATE_RENDERPASS_2_EXTENSION_NAME, VK_KHR_MULTIVIEW_EXTENSION_NAME, VK_KHR_MAINTENANCE_2_EXTENSION_NAME,
VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME };
std::vector<const char*> devExts(std::begin(kNeededDeviceExtensions), std::end(kNeededDeviceExtensions));
uint32_t extAvail = 0;
vkEnumerateDeviceExtensionProperties(s_physical, nullptr, &extAvail, nullptr);
std::vector<VkExtensionProperties> avail(extAvail);
@@ -1463,7 +1687,7 @@ void main()
hostQueryReset.hostQueryReset ? (PFN_vkResetQueryPool)vkGetDeviceProcAddr(s_device, "vkResetQueryPool") : nullptr };
s_contextReady = true;
s_settingsGeneration = settings::Generation();
fprintf(stderr, "[video] %s, swapchain %ux%u\n", props.deviceName, s_swapExtent.width, s_swapExtent.height);
fprintf(stderr, "[video] %s, swapchain %ux%u%s\n", props.deviceName, s_swapExtent.width, s_swapExtent.height, GeometryNote().c_str());
#if defined(__APPLE__) && TARGET_OS_IOS
if (s_presentScale < 1.0f)
{
@@ -1501,15 +1725,7 @@ void main()
props.deviceName, type, props.vendorID, props.deviceID, driver, VK_API_VERSION_MAJOR(props.apiVersion),
VK_API_VERSION_MINOR(props.apiVersion), VK_API_VERSION_PATCH(props.apiVersion), VK_API_VERSION_MAJOR(loader),
VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader)));
int windowW = 0, windowH = 0;
SDL_GetWindowSizeInPixels(s_window, &windowW, &windowH);
const char* name = SDL_GetDisplayName(SDL_GetDisplayForWindow(s_window));
report::SetSystemItem("Display", std::format("{} {}x{} at {:.2f} Hz{}; window {}x{} pixels{}, "
"swapchain {}x{}, V-Sync {}; SDL video driver {}; {}", name ? name : "?", mode ? mode->w : 0, mode ? mode->h : 0,
mode ? mode->refresh_rate : 0.0f, nativeW > 0 ? std::format(", monitor {}x{}", nativeW, nativeH) : std::string(),
windowW, windowH, s_fullscreen ? " (fullscreen)" : "",
s_swapExtent.width, s_swapExtent.height, s_vsync ? "on" : "off", SDL_GetCurrentVideoDriver(),
s_gameMode ? "Game Mode (gamescope)" : "desktop"));
UpdateDisplayItem();
}
if (s_gameMode && mode && nativeW > 0 && int64_t(mode->w) * mode->h < int64_t(nativeW) * nativeH)
s_resolutionHint = std::format("Steam gives this game {}x{} of your {}x{} display. For full sharpness, set this game's "
@@ -1525,6 +1741,27 @@ void main()
return true;
}
namespace
{
// The bug report's "Display" line: at start, and again with each
// swapchain of a new size (it said what the window was at launch).
void UpdateDisplayItem()
{
const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(s_window));
static const std::pair<int, int> native = NativeMonitorMode();
const auto [nativeW, nativeH] = native;
int windowW = 0, windowH = 0;
SDL_GetWindowSizeInPixels(s_window, &windowW, &windowH);
const char* name = SDL_GetDisplayName(SDL_GetDisplayForWindow(s_window));
report::SetSystemItem("Display", std::format("{} {}x{} at {:.2f} Hz{}; window {}x{} pixels{}, "
"swapchain {}x{}, V-Sync {}; SDL video driver {}; {}", name ? name : "?", mode ? mode->w : 0, mode ? mode->h : 0,
mode ? mode->refresh_rate : 0.0f, nativeW > 0 ? std::format(", monitor {}x{}", nativeW, nativeH) : std::string(),
windowW, windowH, s_fullscreen ? " (fullscreen)" : "",
s_swapExtent.width, s_swapExtent.height, s_vsync ? "on" : "off", SDL_GetCurrentVideoDriver(),
s_gameMode ? "Game Mode (gamescope)" : "desktop"));
}
}
void ReleaseFrontImage(VkImage image)
{
std::lock_guard use(s_frontUseMutex);
@@ -1635,18 +1872,26 @@ void main()
{
// NFSMW_WINDOW_AT=<s>:<w>x<h>,...: resize the window at those seconds
// (NFSMW_UI_KEYS's clock), to test what follows the window's size,
// such as the Auto internal resolution, without a person.
// such as the Auto internal resolution, without a person. A step of
// <s>:full or <s>:windowed toggles fullscreen there instead (on
// macOS a fullscreen Space, as the green button makes).
void ScriptedWindowSizes()
{
struct Step { double at; int w, h; };
struct Step { double at; int w, h; }; // w -1: fullscreen, -2: windowed
static std::vector<Step> steps = [] {
std::vector<Step> out;
const char* v = std::getenv("NFSMW_WINDOW_AT");
for (const char* p = v; p && *p;)
{
Step step{};
char word[16] = {};
if (sscanf(p, "%lf:%dx%d", &step.at, &step.w, &step.h) == 3 && step.w > 0 && step.h > 0)
out.push_back(step);
else if (sscanf(p, "%lf:%15[a-z]", &step.at, word) == 2 && (!strcmp(word, "full") || !strcmp(word, "windowed")))
{
step.w = word[0] == 'f' ? -1 : -2;
out.push_back(step);
}
p = strchr(p, ',');
p = p ? p + 1 : nullptr;
}
@@ -1658,6 +1903,12 @@ void main()
std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count() < steps[next].at)
return;
const Step& step = steps[next++];
if (step.w < 0)
{
fprintf(stderr, "[video] NFSMW_WINDOW_AT: %s\n", step.w == -1 ? "fullscreen" : "windowed");
SDL_SetWindowFullscreen(s_window, step.w == -1);
return;
}
fprintf(stderr, "[video] NFSMW_WINDOW_AT: window %dx%d\n", step.w, step.h);
SDL_SetWindowSize(s_window, step.w, step.h);
}
@@ -1803,6 +2054,8 @@ void main()
void SuspendGame()
{
if (s_window)
s_swapPolicy.Suspended();
// 1. Every guest clock stops at one instant. The game's threads, its
// timers, the vblank and the audio cadence then park on their own:
// nothing here waits for them.
@@ -1847,6 +2100,8 @@ void main()
std::lock_guard lock(s_queueMutex);
held = s_background.exchange(false, std::memory_order_acq_rel);
}
if (s_window)
s_swapPolicy.Resumed(); // the swapchain is checked against the surface for a while
if (held)
{
s_foreground.notify_all();
@@ -1902,11 +2157,68 @@ void main()
s_testEventsWaiting.store(true, std::memory_order_release);
}
namespace
{
// Builds the swapchain again when the policy says so
// (swapchain_policy.h), or (iOS) fits SDL's view back to its window.
// Once a frame, before the acquire. Main thread.
void CheckSwapchain()
{
SurfaceState state;
state.active = !s_background.load(std::memory_order_acquire);
state.swapchain = s_swapchain ? Extent{ s_swapExtent.width, s_swapExtent.height } : Extent{};
if (s_swapPolicy.WantsSurface()) // active or not: the policy holds off while inactive
{
state.surfaceRead = true;
state.surface = SurfaceExtent();
state.interface = InterfaceNow();
state.landscapeOnly = LandscapeOnlyHere();
#if defined(__APPLE__) && TARGET_OS_IOS
state.viewFitsWindow = platform::ios::ViewFitsWindow(s_window);
// Turning the phone over (landscape left and right) changes
// no size, so nothing else would say so.
static const char* lastScene = nullptr;
if (const char* scene = platform::ios::SceneOrientationName(s_window); scene != lastScene)
{
if (lastScene)
fprintf(stderr, "[video] the interface turned: %s -> %s (swapchain %ux%u, surface %ux%u)\n", lastScene, scene,
s_swapExtent.width, s_swapExtent.height, state.surface.width, state.surface.height);
lastScene = scene;
}
#endif
}
const SwapDecision d = s_swapPolicy.Decide(state);
if (d.settled)
fprintf(stderr, "[video] %u frames after the resume: swapchain %ux%u, surface %ux%u%s\n", s_swapPolicy.Rules().settleFrames,
s_swapExtent.width, s_swapExtent.height, state.surface.width, state.surface.height, GeometryNote().c_str());
if (d.action == SwapAction::RepairView)
{
fprintf(stderr, "[video] size check: %s (surface %ux%u, swapchain %ux%u, interface %s)\n", d.reason, state.surface.width,
state.surface.height, state.swapchain.width, state.swapchain.height, OrientationName(state.interface));
#if defined(__APPLE__) && TARGET_OS_IOS
platform::ios::FitViewToWindow(s_window); // SDL's resize events follow; the next frames look again
#endif
}
else if (d.action == SwapAction::Recreate)
{
WaitIdle();
DestroySwapchain();
s_swapReason = d.reason;
CreateSwapchain(); // at ChooseExtent of the surface then (d.extent, unless it moved since)
s_swapReason = nullptr;
}
}
}
bool RunFrame()
{
DeliverTestAppEvents();
ui::Tick();
platform::thermal::Poll(); // about once a second
memstatus::Poll(); // iOS: the memory footprint, once a minute
#if defined(__APPLE__) && TARGET_OS_IOS
platform::ios::PollBattery(); // every 5 seconds
#endif
ScriptedWindowSizes();
SDL_Event e;
while (SDL_PollEvent(&e))
@@ -1915,20 +2227,20 @@ void main()
hid::HandleEvent(e);
if (e.type == SDL_EVENT_QUIT || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED)
return false;
if (e.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED)
if (e.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED || e.type == SDL_EVENT_WINDOW_RESIZED ||
e.type == SDL_EVENT_WINDOW_METAL_VIEW_RESIZED || e.type == SDL_EVENT_DISPLAY_ORIENTATION)
{
// No GPU work while the app is inactive: the first frame
// after the resume rebuilds it.
if (s_background.load(std::memory_order_acquire))
s_swapchainStale = true;
else
{
WaitIdle();
DestroySwapchain();
CreateSwapchain();
}
// Only a reason to look: CheckSwapchain compares the surface
// with the swapchain, once the app is active (a size seen
// while it isn't may be iOS's snapshot of it, or SDL's guess).
s_swapPolicy.SizeChanged();
#if defined(__APPLE__) && TARGET_OS_IOS
if (e.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED)
fprintf(stderr, "[video] window pixels %dx%d%s%s\n", e.window.data1, e.window.data2,
s_background.load(std::memory_order_acquire) ? " (app inactive: checked after the resume)" : "", GeometryNote().c_str());
#endif
}
else if (e.type == SDL_EVENT_WINDOW_RESIZED)
if (e.type == SDL_EVENT_WINDOW_RESIZED)
RememberWindowSize();
else if (e.type == SDL_EVENT_WINDOW_FOCUS_GAINED || e.type == SDL_EVENT_WINDOW_FOCUS_LOST)
s_focused.store(e.type == SDL_EVENT_WINDOW_FOCUS_GAINED, std::memory_order_relaxed);
@@ -1964,13 +2276,7 @@ void main()
hid::Resume(); // the game's last rumble again
s_inputSuspended = false;
}
if (s_swapchainStale)
{
s_swapchainStale = false;
WaitIdle();
DestroySwapchain();
CreateSwapchain();
}
CheckSwapchain();
SuspendAudit();
hid::SetBlocked(ui::CapturingInput());
hid::Update();
@@ -2082,11 +2388,13 @@ void main()
}
uint32_t imageIndex;
VkResult acquire = vkAcquireNextImageKHR(s_device, s_swapchain, UINT64_MAX, f.imageAvailable, VK_NULL_HANDLE, &imageIndex);
// Either is for the next frame's CheckSwapchain, which runs before
// its acquire (one decision a frame: the policy counts in frames).
if (acquire == VK_SUBOPTIMAL_KHR)
s_swapPolicy.Reported(SurfaceResult::Suboptimal); // this frame is drawn and presented anyway
if (acquire == VK_ERROR_OUT_OF_DATE_KHR)
{
WaitIdle();
DestroySwapchain();
CreateSwapchain();
s_swapPolicy.Reported(SurfaceResult::OutOfDate);
// Screenshots wait for the next frame drawn.
std::lock_guard lock(s_captureMutex);
for (auto& promise : captures)
@@ -2154,6 +2462,7 @@ void main()
// non-uniform internal resolution such as 3x2 they differ.)
float frameW = float(aspectW ? aspectW : w), frameH = float(aspectH ? aspectH : h);
int32_t dw = int32_t(frameW), dh = int32_t(frameH);
float frameScaleX = 1.0f, frameScaleY = 1.0f; // the view scales this frame is shown for
if (haveSource)
{
// A new frame decides (and tells the game for the next ones); a
@@ -2174,7 +2483,22 @@ void main()
fit = PlacePicture(s_swapExtent.width, s_swapExtent.height, frameW, frameH, s_lastScaleX, s_lastScaleY);
dw = fit.width;
dh = fit.height;
frameScaleX = fit.scaleX;
frameScaleY = fit.scaleY;
}
// Under Hor+ and Vert+ the post-processing keeps to each side of the
// 16:9 band's edge (video/band_filter.h): the frame's band and the
// strips past it are filtered as if each were the whole image.
// NFSMW_BAND_FILTER=0 filters across it as before (an A/B switch).
static const bool s_bandFilter = [] {
const char* v = std::getenv("NFSMW_BAND_FILTER");
const bool on = !(v && v[0] == '0');
if (!on)
fprintf(stderr, "[video] NFSMW_BAND_FILTER=0: anti-aliasing and scaling read across the 16:9 band's edge\n");
return on;
}();
const video::FrameBand band = video::BandOf(w, h, frameScaleX, frameScaleY);
const bool bandOn = s_bandFilter && (band.x0 > 0 || band.x1 < int32_t(w) || band.y0 > 0 || band.y1 < int32_t(h));
int32_t dx = (int32_t(s_swapExtent.width) - dw) / 2, dy = (int32_t(s_swapExtent.height) - dh) / 2;
// The picture without its letterbox bars, for a screenshot.
VkRect2D pictureRect{};
@@ -2231,7 +2555,14 @@ void main()
{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 0, 0, 1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr },
{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 1, 0, 1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, &out, nullptr, nullptr },
};
struct { int32_t width, height; float subpixel, pad; } pc = { int32_t(w), int32_t(h), 0.5f, 0.0f };
struct
{
int32_t width, height;
float subpixel;
uint32_t bandOn;
int32_t band[4];
} pc = { int32_t(w), int32_t(h), 0.5f, bandOn ? 1u : 0u, { band.x0, band.x1, band.y0, band.y1 } };
static_assert(sizeof(pc) == 32);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s_post.aa);
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s_post.aaLayout, 0, 2, writes);
vkCmdPushConstants(cmd, s_post.aaLayout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(pc), &pc);
@@ -2271,7 +2602,15 @@ void main()
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr };
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, s_post.scaleLayout, 0, 1, &write);
bool bicubic = Bicubic();
struct { float width, height, sharpen; uint32_t bicubic; } pc = { float(w), float(h), Sharpen(), bicubic ? 1u : 0u };
struct
{
float width, height, sharpen;
uint32_t bicubic;
float band[4];
uint32_t bandOn;
} pc = { float(w), float(h), Sharpen(), bicubic ? 1u : 0u, { float(band.x0), float(band.x1), float(band.y0), float(band.y1) },
bandOn ? 1u : 0u };
static_assert(sizeof(pc) == 36);
vkCmdPushConstants(cmd, s_post.scaleLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
vkCmdDraw(cmd, 3, 1, 0, 0);
if (!captures.empty() && pictureRect.extent.width)
@@ -2491,12 +2830,9 @@ void main()
vkDestroyBuffer(s_device, captureBuffer, nullptr);
vkFreeMemory(s_device, captureMemory, nullptr);
}
// The next frame's CheckSwapchain builds again (before its acquire).
if (present == VK_ERROR_OUT_OF_DATE_KHR || present == VK_SUBOPTIMAL_KHR)
{
WaitIdle();
DestroySwapchain();
CreateSwapchain();
}
s_swapPolicy.Reported(present == VK_ERROR_OUT_OF_DATE_KHR ? SurfaceResult::OutOfDate : SurfaceResult::Suboptimal);
s_frameIndex = (s_frameIndex + 1) % FRAMES_IN_FLIGHT;
return true;
}
+73
View File
@@ -0,0 +1,73 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See scale_budget.h.
#include "scale_budget.h"
namespace video
{
namespace
{
// "Apple <family><generation>[ <tier>] GPU": family 'A' (iPhones, the
// iPad mini and the base iPad) or 'M' (iPad Pro and Air), tier "Pro"
// or empty. False for any other name.
bool ParseAppleChip(std::string_view name, char& family, unsigned& generation, std::string_view& tier)
{
constexpr std::string_view kApple = "Apple ";
if (!name.starts_with(kApple))
return false;
name.remove_prefix(kApple.size());
if (name.empty() || (name[0] != 'A' && name[0] != 'M'))
return false;
family = name[0];
name.remove_prefix(1);
generation = 0;
size_t digits = 0;
while (digits < name.size() && name[digits] >= '0' && name[digits] <= '9' && digits < 4)
generation = generation * 10 + unsigned(name[digits++] - '0');
if (digits == 0 || (digits < name.size() && name[digits] != ' '))
return false;
name.remove_prefix(digits < name.size() ? digits + 1 : digits);
// What's left: "GPU", or "<tier> GPU". MoltenVK names the device
// after Metal's, which ends in " GPU"; accept a name without it too.
constexpr std::string_view kGpu = "GPU";
if (name.ends_with(kGpu))
name.remove_suffix(kGpu.size());
while (!name.empty() && name.back() == ' ')
name.remove_suffix(1);
tier = name;
return true;
}
}
ScaleBudget MobileScaleBudget(std::string_view gpuName)
{
char family = 0;
unsigned generation = 0;
std::string_view tier;
if (!ParseAppleChip(gpuName, family, generation, tier))
return { 1, "an unrecognised GPU keeps 1x1" };
if (family == 'A' && generation == 20 && tier == "Pro")
{
// iPhone 18 Pro Max (2868x1320 screen), on a charger, scripted
// fly-in and race: 60 fps at 1x1 with GPU to spare (race 7.7 ms a
// frame), but iOS's thermal state went nominal -> fair at 75 s and
// -> serious at 350 s (Frame Rate's Auto then dropped to 30 fps),
// and was still serious 25 minutes later. More pixels only heat
// it sooner: 1x1 until 2x2 is shown to stay out of serious.
return { 1, "the A20 Pro heats to serious within 6 minutes at 1x1 on a charger" };
}
if (family == 'A' && generation <= 17)
{
// iPhone 15 Pro Max (A17 Pro), scripted race: GPU 15 ms a frame
// at 1x1, 36-40 ms at 2x2.
return { 1, "A17 Pro and older hold a race at 60 fps at 1x1 only" };
}
if (family == 'M')
{
// iPad Pro M2: 2x2 = 43-50 fps in a race. Newer M chips are not
// measured in an iPad.
return { 1, "iPads keep 1x1: the M2 makes 43-50 fps at 2x2" };
}
// An A-series chip newer than the A17 Pro but not measured (the
// A18/A19, a plain A20, an A21...).
return { 1, "a chip not measured yet keeps 1x1" };
}
}
+32
View File
@@ -0,0 +1,32 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Internal Resolution's Auto on iPhones and iPads: how many host pixels per
// guest pixel (sx x sy, video::AutoRenderScale's budget) the GPU gets, chip
// by chip. A pure function of the GPU's name, for tests/scale_budget_test.cpp.
//
// Only chips measured to hold a race at 60 fps with headroom, and to stay out
// of serious thermal throttling over a long session, get more than 1x1. Any
// other chip, a newer one included, keeps 1x1 until it is measured: a phone
// that renders too much heats up and throttles to 30 fps mid-session, which
// is worse than a softer picture. None has qualified yet: the A20 Pro (iPhone
// 18 Pro Max) reaches the serious thermal state at 1x1 already (on a
// charger), so it keeps 1x1 too, and Settings > Graphics > Internal
// Resolution > 2x stays the player's choice.
#pragma once
#include <cstdint>
#include <string_view>
namespace video
{
struct ScaleBudget
{
// At most this many host pixels per guest pixel (1: 1x1, 4: 2x2).
uint32_t pixels = 1;
// Why, for the log ("[renderer] Auto internal resolution: ...").
const char* reason = "";
};
// The budget for the GPU that MoltenVK names `gpuName` (Metal's device
// name, "Apple A20 Pro GPU", "Apple M2 GPU").
ScaleBudget MobileScaleBudget(std::string_view gpuName);
}
+220
View File
@@ -0,0 +1,220 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See swapchain_policy.h.
#include "swapchain_policy.h"
#include <algorithm>
#include <cmath>
namespace video
{
const char* OrientationName(Orientation orientation)
{
switch (orientation)
{
case Orientation::Landscape:
return "landscape";
case Orientation::Portrait:
return "portrait";
default:
return "unknown";
}
}
const char* SwapActionName(SwapAction action)
{
switch (action)
{
case SwapAction::Recreate:
return "recreate";
case SwapAction::RepairView:
return "repair the view";
default:
return "none";
}
}
ExtentChoice ChooseExtent(Extent surface, Orientation interface, bool landscapeOnly, bool refusePortrait)
{
// Unknown counts as landscape: the app shows in nothing else.
if (!refusePortrait || !landscapeOnly || !surface.Portrait() || interface == Orientation::Portrait)
return { surface, false };
return { Extent{ surface.height, surface.width }, true };
}
void SwapchainPolicy::Suspended()
{
resumeWatch_ = false;
}
void SwapchainPolicy::Resumed()
{
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
resumeWatch_ = rules_.settleFrames > 0;
}
void SwapchainPolicy::SizeChanged()
{
sizePending_ = true;
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
}
void SwapchainPolicy::Reported(SurfaceResult result)
{
if (result == SurfaceResult::OutOfDate)
outOfDate_ = true;
else if (rules_.followSuboptimal)
suboptimal_ = true;
}
void SwapchainPolicy::Created(Extent)
{
outOfDate_ = false;
sizePending_ = false;
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
}
bool SwapchainPolicy::WantsSurface() const
{
return sizePending_ || suboptimal_ || outOfDate_ || settleLeft_ > 0 || misfitFrames_ > 0 || refusedFrames_ > 0 ||
(rules_.periodFrames > 0 && (frame_ + 1) % rules_.periodFrames == 0);
}
SwapDecision SwapchainPolicy::Decide(const SurfaceState& s)
{
SwapDecision d;
if (!s.active && rules_.holdWhileInactive)
{
// What was seen meanwhile waits for the resume: a size reported
// now (iOS turning or snapshotting the window) is not the one the
// app will show at.
d.reason = "inactive";
return d;
}
frame_++;
if (settleLeft_ > 0 && --settleLeft_ == 0 && resumeWatch_)
{
resumeWatch_ = false;
d.settled = true;
}
if (outOfDate_)
{
// The old one can't be presented to: build again whatever the size.
outOfDate_ = suboptimal_ = sizePending_ = false;
ExtentChoice choice = ChooseExtent(s.surface, s.interface, s.landscapeOnly, rules_.refusePortrait);
d.action = SwapAction::Recreate;
d.reason = "VK_ERROR_OUT_OF_DATE_KHR";
d.extent = choice.extent;
d.refused = choice.refused;
return d;
}
if (!s.surfaceRead)
return d;
if (s.surface.Empty())
{
// Minimised (or no layer): as before, a size event tears the
// swapchain down and the presenter waits for a size again.
refusedFrames_ = misfitFrames_ = 0;
if (sizePending_ && !s.swapchain.Empty())
{
sizePending_ = suboptimal_ = false;
d.action = SwapAction::Recreate;
d.reason = "no surface (minimised)";
}
return d;
}
// iOS: the surface is the view's layer. A view that doesn't fill its
// window was sized by someone guessing (SDL's portrait guess): fit it
// back, and look again next frame, before following it anywhere.
if (!s.viewFitsWindow && rules_.repairView)
{
misfitFrames_++;
if (misfitFrames_ <= rules_.repairPatience)
{
if (misfitFrames_ <= 3 || (rules_.repairEvery && misfitFrames_ % rules_.repairEvery == 0))
{
d.action = SwapAction::RepairView;
d.reason = "the view doesn't fill its window";
}
else
d.reason = "waiting for the view to fill its window";
return d;
}
// It won't: the swapchain follows the layer after all (below).
}
else
misfitFrames_ = 0;
const ExtentChoice choice = ChooseExtent(s.surface, s.interface, s.landscapeOnly, rules_.refusePortrait);
if (choice.extent != s.swapchain)
{
d.action = SwapAction::Recreate;
d.reason = s.swapchain.Empty() ? "no swapchain"
: sizePending_ ? "the size changed"
: resumeWatch_ ? "after the resume"
: suboptimal_ ? "VK_SUBOPTIMAL_KHR"
: "the surface's size moved";
d.extent = choice.extent;
d.refused = choice.refused;
sizePending_ = suboptimal_ = false;
refusedFrames_ = choice.refused ? refusedFrames_ + 1 : 0;
return d;
}
sizePending_ = false;
if (choice.refused)
{
// A portrait layer under a landscape-only interface: the swapchain
// keeps its landscape size (its SUBOPTIMALs say only that), and the
// view is fitted back to its window.
suboptimal_ = false;
refusedFrames_++;
d.refused = true;
if (rules_.repairView && (refusedFrames_ <= 3 || (rules_.repairEvery && refusedFrames_ % rules_.repairEvery == 0)))
{
d.action = SwapAction::RepairView;
d.reason = "a portrait surface under a landscape interface";
}
else
d.reason = "a portrait surface refused";
return d;
}
refusedFrames_ = 0;
if (suboptimal_)
{
suboptimal_ = false;
// The same size: the drawables or the layer's scale disagree with
// it. Build again, but not every frame if that doesn't settle it.
if (lastSameRecreate_ == 0 || frame_ - lastSameRecreate_ >= rules_.suboptimalBackoff)
{
lastSameRecreate_ = frame_;
d.action = SwapAction::Recreate;
d.reason = "VK_SUBOPTIMAL_KHR at the same size";
d.extent = choice.extent;
return d;
}
d.reason = "VK_SUBOPTIMAL_KHR again (backing off)";
}
return d;
}
UiDisplay FitUiDisplay(float windowWidth, float windowHeight, Extent fb)
{
UiDisplay u;
u.width = windowWidth;
u.height = windowHeight;
if (fb.Empty() || windowWidth <= 0 || windowHeight <= 0)
return u;
// Pixels per point, whichever way round the two are.
const float ppp = float(std::max(fb.width, fb.height)) / std::max(windowWidth, windowHeight);
const float w = float(fb.width) / ppp, h = float(fb.height) / ppp;
if (std::fabs(w - windowWidth) > 2.0f || std::fabs(h - windowHeight) > 2.0f)
{
u.width = w;
u.height = h;
u.fromSwapchain = true;
}
u.scaleX = float(fb.width) / u.width;
u.scaleY = float(fb.height) / u.height;
return u;
}
}
+157
View File
@@ -0,0 +1,157 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// When the presenter builds its swapchain again, and at what size; and the
// size the settings UI lays out at. Pure decisions (no Vulkan, SDL or UIKit:
// presenter.cpp feeds them what it reads), tested with planted wrong rules in
// tests/swapchain_policy_test.cpp. Main thread.
//
// Why (v0.1.2, an iPhone 18 Pro Max on iOS 27): after an iOS screenshot the
// game's picture sat in a corner of the screen and the settings UI was blown
// up. SDL 3.4.16 sets its view's frame itself whenever a keyboard shows or
// hides (-[SDL_uikitviewcontroller updateKeyboard], UIKit_ComputeViewFrame),
// swapping width and height when it believes the interface is portrait:
// iOS 27's UIApplication.statusBarOrientation is "unknown" under the scene
// life cycle, and SDL thinks a resizable window may be portrait whatever the
// Info.plist says. The view became 440x956 points in a 956x440 window, its
// layer followed, and the presenter rebuilt its swapchain at the layer's
// 1320x2868 and never back (nothing moved the view again). So:
// - nothing is built while the app is inactive or in the background, and a
// size seen then is only a reason to look again once it is active;
// - after a resume, a size event or a new swapchain the surface is read
// every frame for a while (and once a second always), and the swapchain
// follows it only when they differ;
// - VK_SUBOPTIMAL_KHR and VK_ERROR_OUT_OF_DATE_KHR are reasons to build
// again (a SUBOPTIMAL at an unchanged size at most once per backoff);
// - on iOS a view that doesn't fill its window is fitted back first (the
// surface is its layer: following it would follow SDL's mistake);
// - a landscape-only app (SpeedBreaker on iPhone) never takes a portrait
// swapchain while its interface is landscape;
// - the UI lays out at the swapchain's size in points, not a window size
// that disagrees with it.
#pragma once
#include <cstdint>
namespace video
{
struct Extent
{
uint32_t width = 0, height = 0;
bool Empty() const { return width == 0 || height == 0; }
bool Portrait() const { return height > width; }
friend bool operator==(const Extent&, const Extent&) = default;
};
enum class Orientation : uint8_t
{
Unknown,
Landscape,
Portrait,
};
const char* OrientationName(Orientation orientation);
// The size to build a swapchain at for a surface of `surface`. A
// landscape-only app refuses a portrait surface unless its interface is
// portrait too: then it takes the same size turned to landscape (which
// is what its view's layer measures once fitted back to its window).
struct ExtentChoice
{
Extent extent;
bool refused = false; // `surface` itself was refused
};
ExtentChoice ChooseExtent(Extent surface, Orientation interface, bool landscapeOnly, bool refusePortrait = true);
// What vkAcquireNextImageKHR or vkQueuePresentKHR said, other than success.
enum class SurfaceResult : uint8_t
{
Suboptimal,
OutOfDate,
};
// What the presenter read this frame.
struct SurfaceState
{
bool active = true; // the app is in front (not inactive, not in the background)
bool surfaceRead = false; // `surface` (and the fields after it) were read this frame (WantsSurface)
Extent surface; // the surface's extent now (Vulkan's currentExtent, else the window's pixels)
Extent swapchain; // the current swapchain's (0x0: none)
Orientation interface = Orientation::Unknown; // the interface's (iOS: the window scene's), Unknown elsewhere
bool landscapeOnly = false; // the app shows only in landscape here (iPhone)
bool viewFitsWindow = true; // iOS: SDL's view fills its UIWindow (always elsewhere)
};
enum class SwapAction : uint8_t
{
None,
Recreate, // build the swapchain again, at `extent`
RepairView, // fit the view to its window (iOS), then look again next frame
};
struct SwapDecision
{
SwapAction action = SwapAction::None;
const char* reason = "";
Extent extent; // Recreate: the size (ChooseExtent's)
bool refused = false; // the surface's own size was refused
bool settled = false; // the watch after a resume ended this frame: the presenter logs where it stands
};
const char* SwapActionName(SwapAction action);
// The rules, a field each, so the tests can plant a wrong one.
struct SwapchainRules
{
bool holdWhileInactive = true; // nothing built while the app is inactive or in the background
bool refusePortrait = true; // landscape-only: no portrait swapchain under a landscape interface
bool repairView = true; // a view that doesn't fill its window is fitted back before anything else
bool followSuboptimal = true; // VK_SUBOPTIMAL_KHR builds again
uint32_t settleFrames = 120; // frames the surface is read every frame after a resume, a size event or a new swapchain
uint32_t periodFrames = 60; // otherwise once every this many frames
uint32_t suboptimalBackoff = 30; // a SUBOPTIMAL at an unchanged size builds again at most once per this many frames
uint32_t repairEvery = 30; // after the first three, a repair is tried once per this many frames
uint32_t repairPatience = 180; // a view that won't fit: after this many frames the swapchain follows its layer anyway
};
class SwapchainPolicy
{
public:
explicit SwapchainPolicy(SwapchainRules rules = {}) : rules_(rules) {}
void Suspended(); // the app went inactive (or to the background)
void Resumed(); // it is active again: watch the surface
void SizeChanged(); // a window, pixel-size or view-size event
void Reported(SurfaceResult result);
void Created(Extent extent); // a swapchain was built (on any path)
// Whether to read the surface (and on iOS the view) this frame.
bool WantsSurface() const;
// Once a frame, before acquiring an image.
SwapDecision Decide(const SurfaceState& state);
const SwapchainRules& Rules() const { return rules_; }
private:
SwapchainRules rules_;
uint64_t frame_ = 0; // active frames decided
uint32_t settleLeft_ = 0; // frames left reading the surface every frame
bool sizePending_ = false;
bool suboptimal_ = false;
bool outOfDate_ = false;
bool resumeWatch_ = false; // settleLeft_ counts down from a resume
uint32_t misfitFrames_ = 0; // frames the view hasn't filled its window
uint32_t refusedFrames_ = 0; // frames the surface has been refused
uint64_t lastSameRecreate_ = 0; // frame_ of the last SUBOPTIMAL rebuild at an unchanged size (0: none)
};
// The UI's display size (points) and framebuffer scale for a swapchain
// of `framebuffer` pixels, given the window's size in points. While they
// agree (to 2 points) the window's size stands, as before; when they
// don't (a view SDL turned, or a swapchain kept while it is fitted back)
// the swapchain's own shape wins, at the window's pixels per point.
struct UiDisplay
{
float width = 0, height = 0; // ImGui's DisplaySize
float scaleX = 1, scaleY = 1; // DisplayFramebufferScale
bool fromSwapchain = false; // the window's size was overruled
};
UiDisplay FitUiDisplay(float windowWidth, float windowHeight, Extent framebuffer);
}
+157 -20
View File
@@ -1,30 +1,167 @@
# Building SpeedBreaker for iPhone and iPad
# SpeedBreaker on iPhone and iPad
There's no download for iPhone and iPad, and SpeedBreaker isn't on the App Store. You build it on
a Mac and install it on your own device, signed with your own Apple developer account. Nothing
here is distributed: the app you build is yours.
SpeedBreaker isn't on the App Store. There are two ways to get it on your iPhone or iPad:
## What you need
- **Sideload the IPA** (experimental, from v0.1.2): a sideloading tool signs it with your own
Apple Account and installs it. Read [the memory caveat](#the-memory-caveat) first: with a free
Apple Account it may not start, depending on the tool.
- **Build it yourself** on a Mac with Xcode, signed with your own Apple developer account: see
[Building it yourself](#building-it-yourself). This is how it was developed and tested.
- **A Mac** with Apple silicon and **Xcode** (built with Xcode 26.4), plus everything the Mac
build needs: see [Building from source](../../README.md#building-from-source), step 1.
- **Your own disc image** of the USA Xbox 360 disc, for step 2 of that guide (the game's code is
translated from it on your Mac) and later for the installer on the device.
## What every device needs
- **iOS or iPadOS 17 or later** and an **A15-class chip or newer**: iPhone 13 and later, iPads
with an M-series chip. It was tested on an iPhone 15 Pro Max, an iPhone 18 Pro Max and an iPad
Pro 12.9-inch (M2), on version 27. Devices with 6 GB of memory or more are recommended; 4 GB
ones (iPhone 13 and 13 mini, iPhone SE 3rd generation, iPad mini 6th generation) are untested
and likely too small.
- **A controller, strongly recommended**: an Xbox or PlayStation controller over Bluetooth, or a
Backbone or similar. There are no touch controls for driving (a keyboard works too). A
three-finger tap opens the settings.
- **Your own disc image** of the USA Xbox 360 disc (an `.iso`, about 7.8 GB), and room for it plus
the installed game (about 7.1 GB) while it installs. You can delete the image afterwards.
## Sideloading the IPA (experimental)
The IPA, `SpeedBreaker-<version>-ios.ipa`, is signed by nobody: it carries only the entitlements
the game asks for, and the tool you sideload it with signs it with your Apple Account. Check the
download against its `.sha256` file (`shasum -a 256 -c SpeedBreaker-<version>-ios.ipa.sha256`).
### The memory caveat
SpeedBreaker lays out the Xbox 360's memory in one block of 4 GB of address space, needs about 3 GB
more address space besides, and at its busiest uses about 2.5 GB of memory ([Memory](#memory)).
iOS doesn't give an app that much by default, so the app asks for two entitlements:
- **Extended Virtual Addressing** lifts iOS's address-space limit. Only a paid Apple Developer
Program account (or an enterprise one) can sign it: Apple's
[capability table](https://developer.apple.com/help/account/reference/supported-capabilities-ios)
lists it for those memberships and not for a free Apple Account, Xcode refuses it for a free
Personal Team, and the sideloading tools drop it for free accounts.
- **Increased Memory Limit** raises the memory limit, from about 3.3 GB to about 6 GB on an 8 GB
iPhone (about 2.9 GB without it on a 6 GB one). On iOS 16 and later, on devices that support
it, it also lifts the address-space limit as Extended Virtual Addressing does
([xnu, kern_exec.c](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_exec.c)).
Apple gives it to free Apple Accounts too, but not every tool asks for it.
What that means for each way of signing (as of October 2026; untested with a free account so far):
| Signed with | Entitlements the app gets | Expected |
|---|---|---|
| A paid Apple Developer account, any tool, or Xcode | both | works, as tested |
| A free Apple Account, [AltStore Classic](https://altstore.io) 2.2 or later | Increased Memory Limit | likely works on devices that support it |
| A free Apple Account, Xcode Personal Team (building it yourself) | Increased Memory Limit, after removing the other one | likely works on devices that support it |
| A free Apple Account, [SideStore](https://sidestore.io) 0.7 | neither ([SideStore #1616](https://github.com/SideStore/SideStore/issues/1616)) | may start; iOS may close it in busy scenes |
| A free Apple Account, [Sideloadly](https://sideloadly.io) | unknown | unknown until tested |
Without either entitlement iOS gives an app 17 GB of address space, about 7 GB of it usable: the
4 GB can still fit on a device with more than 3 GB of memory (about 5 GB is free in one piece above
the system's shared region, and the game reserves its 4 GB first thing), but everything else then
has to fit in the 3 GB or so left, and the game in iOS's default memory limit. When the 4 GB
doesn't fit, the game says so instead of closing: a page titled **This copy of SpeedBreaker can't
start**, whose Details line names the entitlements this copy was signed with. When it starts with
iOS's default address space, or a memory limit below about 3 GB, a notice says iOS may close the
game in busy scenes.
### Free Apple Account limits
These are Apple's, whatever the tool ([AltStore's FAQ](https://faq.altstore.io/altstore-classic/your-altstore),
[error codes](https://faq.altstore.io/altstore-world/error-codes)):
- **7 days.** An app signed by a free account stops opening after 7 days. AltStore and SideStore
refresh it in the background or when you tap Refresh; with Sideloadly you sideload it again.
Your installed game, saves and settings stay as long as the app keeps its bundle identifier
(the same tool and the same Apple Account).
- **3 apps.** At most 3 sideloaded apps can be active on a device at once (AltStore and SideStore
count themselves), and an account can register at most 10 App IDs in 7 days.
- **Developer Mode** must be on: Settings > Privacy & Security > Developer Mode (the device
restarts).
A paid account signs for a year, without the 3-app limit.
### Steps
1. Download the IPA and its `.sha256` from the release page, and check it.
2. Sideload it with your tool of choice, signing in to it with your Apple Account:
- **AltStore Classic** (AltServer on a Mac or PC, then AltStore on the device): My Apps, +, and
pick the IPA.
- **SideStore**: +, and pick the IPA.
- **Sideloadly** (Mac or PC, with a cable): drag the IPA in, choose the device, enter your Apple
Account and press Start.
3. On the device, trust the developer: Settings > General > VPN & Device Management, your Apple
Account, Trust. Turn on Developer Mode if iOS asks.
4. Put your disc image in **Files > On My iPhone (or iPad) > SpeedBreaker**: AirDrop it to Files,
drag it into the device's Files tab in Finder over a cable, or download it from iCloud Drive
first (the installer can't read a file still in the cloud).
5. Start SpeedBreaker: the installer finds the image, checks it against the disc and installs the
game. See [iPhone and iPad](../../README.md#iphone-and-ipad) in the main README for the rest.
If it doesn't work: the page's Details line, or Settings > Advanced > Save Bug Report (a zip in
the Files app's SpeedBreaker folder) after iOS closed the game, says what happened; the log's
`[memory]` lines give the entitlements, the address space and the memory limit, and once a minute
how close the game came to it. iOS's own record of closing an app for memory is in Settings >
Privacy & Security > Analytics & Improvements > Analytics Data, as a JetsamEvent report.
### Making the IPA
```sh
export IOS_DEPS=~/speedbreaker-ios-deps # step 2 of Building it yourself
scripts/ios/make_ipa.sh
```
This builds the app without signing it (`IOS_UNSIGNED=1 scripts/ios/build.sh`: no team, no
Apple Account, no provisioning profile) and packages `build/ios/ipa/SpeedBreaker-<version>-ios.ipa`
(`Payload/SpeedBreaker.app`, zipped), its `.sha256`, and the entitlements beside it. The version
is `project()`'s in the top-level CMakeLists.txt. `--app <SpeedBreaker.app>` packages a Release
build you already have instead, signed or not: any signature and `embedded.mobileprovision` are
removed. The app gets a generic bundle identifier (the tools give it one of yours) and an ad-hoc
signature with no certificate or team that holds only `SpeedBreaker/SpeedBreaker.entitlements`
(or `--entitlements <plist>`'s, to test with fewer), which is where the sideloading tools read the
entitlements an app asks for. The executable's debug map, which names the folders it was built in,
is stripped; its full symbols go to `build/ios/ipa/symbols/SpeedBreaker-<version>-<commit>-ios.dSYM`,
to keep and not to publish. The license notices go in `SpeedBreaker.app/licenses` (`COPYING`,
`NOTICE`, `THIRD_PARTY_NOTICES.md` and `LICENSES/`), as in the other downloads. Before writing the IPA it checks that no home-folder path, team,
provisioning profile or device ID is left in it; `IPA_SCAN_EXTRA="A1B2C3D4E5|your name"` adds
strings of your own to look for. The same app always gives the same IPA.
## Memory
What the game needs, measured on a Mac (macOS 27, Apple silicon) in a scripted run into the first
race, with the same code as the iOS app:
- **Address space:** one block of 4 GB for the guest (no smaller layout exists: the recompiled code
addresses guest memory as one base plus a 32-bit address, and the game's memory runs to
0xFFFFFFFF), plus about 3 GB for everything else at 1x internal resolution, the iPhone's
default (the graphics driver's memory, the game's heaps, thread stacks), 3.8 GB at 2x2.
- **Memory footprint, peak:** 2.2 GB at 1x, 2.8 GB at 2x2 (1440p). The graphics memory in that
race was 1.18 GB at 1x; on an iPhone 15 Pro Max in free roam it was 1.46 GB, so expect about
2.5 GB there.
- On iOS the log's `[memory]` lines give the real figures: the address space's top (0xfc0000000
or more with an entitlement, 0x458000000 without), the memory limit, and once a minute the
footprint, its peak and what is left.
## Building it yourself
### What you need
- **A Mac** with Apple silicon and **Xcode** (built with Xcode 26.4 and 27.0), plus everything the
Mac build needs: see [Building from source](../../README.md#building-from-source), step 1.
- **Your own disc image**, for step 2 of that guide (the game's code is translated from it on your
Mac) and later for the installer on the device.
- **An Apple developer account.** SpeedBreaker was built and tested with a paid Apple Developer
Program membership. It asks for two memory capabilities, Extended Virtual Addressing and
Increased Memory Limit, which a free Apple Account's Personal Team may not be able to sign;
apps signed by a free account also stop opening after 7 days.
- **An iPhone or iPad** with iOS or iPadOS 17 or later and an A15-class chip or newer (iPhone 13
and later, M-series iPads), and a controller or keyboard. Tested on an iPhone 15 Pro Max and an
iPad Pro 12.9-inch (M2), both on version 27.
Program membership. A free Apple Account's Personal Team can't sign Extended Virtual Addressing:
Xcode stops with "Personal development teams ... do not support the Extended Virtual Addressing
capability". Delete that key (and its `<true/>`) from `scripts/ios/SpeedBreaker/SpeedBreaker.entitlements`
first and keep Increased Memory Limit (see [the memory caveat](#the-memory-caveat)); apps signed
by a free account also stop opening after 7 days.
## 1. The game's code
### 1. The game's code
On the Mac, follow [Building from source](../../README.md#building-from-source) steps 1 and 2:
`scripts/setup_tools.sh`, your disc's `default.xex` in `game/files`, and XenonRecomp, which
writes `ppc/`. You don't need the Mac build itself (step 3).
## 2. The iOS libraries
### 2. The iOS libraries
```sh
scripts/ios/build_deps.sh ~/speedbreaker-ios-deps
@@ -34,7 +171,7 @@ This downloads and builds SDL3, glslang and FFmpeg's XMA decoders for iOS, and u
static library, into the folder you name (a few minutes; the sources, about 65 MB, are checked
against their SHA-256). Nothing is installed anywhere else.
## 3. Signing
### 3. Signing
Find your **team ID**: in Xcode, Settings > Accounts, select your account and team, or on
developer.apple.com under Membership details. It's 10 characters, like `A1B2C3D4E5`. Pick a
@@ -44,7 +181,7 @@ team.
On the device, turn on **Developer Mode** (Settings > Privacy & Security > Developer Mode; it
restarts), then connect it to the Mac with a cable and tap **Trust**.
## 4. Build and install
### 4. Build and install
```sh
export IOS_DEPS=~/speedbreaker-ios-deps
@@ -64,7 +201,7 @@ device connected, set `IOS_DEVICE` to the one you want (`xcrun devicectl list de
If the app doesn't open and iOS says the developer isn't trusted, go to Settings > General > VPN &
Device Management, choose your developer account and trust it.
## 5. Play
### 5. Play
Copy your `.iso` into **Files > On My iPhone (or iPad) > SpeedBreaker** and start the app: the
installer finds it, checks it and installs the game. See
+25 -5
View File
@@ -17,13 +17,24 @@
# IOS_BUNDLE_ID the app's bundle identifier (unique to the team)
# IOS_DEVICE the device to install on (`xcrun devicectl list
# devices`; the only connected one by default)
# IOS_UNSIGNED=1 an app nobody signed, for make_ipa.sh (an IPA that
# players sideload): no team, no account, no profile;
# in build/ios/xcode-unsigned. IOS_BUNDLE_ID is then
# optional (a generic one), and "install" refused.
# No game data: the game installs from the player's disc image on the device.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
export DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}
: "${IOS_DEPS:?set IOS_DEPS: the folder scripts/ios/build_deps.sh made (scripts/ios/README.md)}"
: "${DEVELOPMENT_TEAM:?set DEVELOPMENT_TEAM: your Apple developer team ID (scripts/ios/README.md)}"
: "${IOS_BUNDLE_ID:?set IOS_BUNDLE_ID: a bundle identifier of your own, such as com.yourname.SpeedBreaker}"
UNSIGNED=${IOS_UNSIGNED:-0}
if [ "$UNSIGNED" = 1 ]; then
[ "${1:-}" = install ] && { echo "build.sh: IOS_UNSIGNED=1 makes an app nobody signed, which no device installs: make_ipa.sh packages it for sideloading" >&2; exit 2; }
# Sideloading tools give it an identifier of the player's own anyway.
IOS_BUNDLE_ID=${IOS_BUNDLE_ID:-io.github.speedbreakerproject.SpeedBreaker}
else
: "${DEVELOPMENT_TEAM:?set DEVELOPMENT_TEAM: your Apple developer team ID (scripts/ios/README.md)}"
: "${IOS_BUNDLE_ID:?set IOS_BUNDLE_ID: a bundle identifier of your own, such as com.yourname.SpeedBreaker}"
fi
PREFIX=$IOS_DEPS/prefix
MOLTENVK=$IOS_DEPS/MoltenVK
BUILD=$ROOT/build/ios
@@ -67,12 +78,21 @@ LIBS+=(-weak_framework CoreHaptics)
VERSION=$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD/CMakeCache.txt")
BUILD_NUMBER=$(git -C "$ROOT" rev-list --count HEAD 2>/dev/null || echo 1)
# Signed for the team (Xcode's automatic signing makes the profile), or for
# make_ipa.sh not at all: then no account is asked anything.
if [ "$UNSIGNED" = 1 ]; then
DERIVED=$BUILD/xcode-unsigned
SIGNING=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= DEVELOPMENT_TEAM=)
else
DERIVED=$BUILD/xcode
SIGNING=(-allowProvisioningUpdates DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM")
fi
xcodebuild -project "$ROOT/scripts/ios/SpeedBreaker.xcodeproj" -scheme SpeedBreaker -configuration Release \
-destination generic/platform=iOS -derivedDataPath "$BUILD/xcode" -allowProvisioningUpdates -quiet \
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" PRODUCT_BUNDLE_IDENTIFIER="$IOS_BUNDLE_ID" \
-destination generic/platform=iOS -derivedDataPath "$DERIVED" -quiet "${SIGNING[@]}" \
PRODUCT_BUNDLE_IDENTIFIER="$IOS_BUNDLE_ID" \
MARKETING_VERSION="${VERSION:-0.0.0}" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
OTHER_LDFLAGS="${LIBS[*]}" build
APP=$BUILD/xcode/Build/Products/Release-iphoneos/SpeedBreaker.app
APP=$DERIVED/Build/Products/Release-iphoneos/SpeedBreaker.app
nm -gU "$APP/SpeedBreaker" | grep -q ' _vkGetInstanceProcAddr$' || { echo "build.sh: vkGetInstanceProcAddr was stripped" >&2; exit 1; }
echo "$APP (v${VERSION:-?}, build $BUILD_NUMBER, $(du -sh "$APP" | cut -f1))"
+184
View File
@@ -0,0 +1,184 @@
#!/bin/bash
# SpeedBreaker. GPL-3.0-or-later (see COPYING).
#
# scripts/ios/make_ipa.sh [--app <SpeedBreaker.app>] [--out <folder>] [--entitlements <plist>]:
# an IPA for sideloading (Sideloadly, AltStore, SideStore and the like, which
# sign it with the player's own Apple Account):
# <folder>/SpeedBreaker-<version>-ios.ipa (build/ios/ipa by default), its
# .sha256, and the entitlements it asks for beside it (.entitlements).
# EXPERIMENTAL: see README.md, "Sideloading the IPA".
# - The app: built here without signing (IOS_UNSIGNED=1 build.sh, which
# needs IOS_DEPS: README.md, step 2), or --app, a Release build for
# devices, signed or not, of this version.
# - Nobody's signature: a developer signature and embedded.mobileprovision
# are removed. The executable gets an ad-hoc signature (no certificate,
# no team) that holds only the entitlements in SpeedBreaker.entitlements
# (Extended Virtual Addressing, Increased Memory Limit), or --entitlements'
# (a test with fewer): an app's own signature is where the sideloading
# tools read what it asks for.
# - No debug map: the executable's links to the object files it was built
# from (with this Mac's paths) are stripped; the full symbols go to
# <folder>/symbols/SpeedBreaker-<version>-<commit>-ios.dSYM, for us (never
# published: they map a player's crash back to the build), as the Mac
# app's do (scripts/macos/make_app.py).
# - A generic bundle identifier and display name: the tools replace the
# identifier with one of the player's own.
# - The license notices in SpeedBreaker.app/licenses (COPYING, NOTICE,
# THIRD_PARTY_NOTICES.md, LICENSES/), as the other downloads carry them.
# - The version is CMakeLists.txt's project(): --app must have it too.
# - Checked before it's written: no team ID, provisioning profile, device ID
# or home folder path anywhere in the payload. IPA_SCAN_EXTRA="a|b|c"
# adds strings of your own to look for (your team ID, name, devices).
# Reproducible: the same app gives the same IPA (sorted, fixed times).
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
export DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}
APP=
OUT=$ROOT/build/ios/ipa
ENTITLEMENTS=$ROOT/scripts/ios/SpeedBreaker/SpeedBreaker.entitlements
while [ $# -gt 0 ]; do
case $1 in
--app) APP=${2:?--app needs a path}; shift 2 ;;
--out) OUT=${2:?--out needs a folder}; shift 2 ;;
--entitlements) ENTITLEMENTS=${2:?--entitlements needs a plist}; shift 2 ;;
-h|--help) sed -n '3,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "make_ipa.sh: unknown argument $1 (--help)" >&2; exit 2 ;;
esac
done
fail() { echo "make_ipa.sh: $*" >&2; exit 1; }
# grep -q on a long pipe stops reading early, and with pipefail the writer's
# SIGPIPE then fails the pipe: count instead, reading everything.
has() { [ "$(grep -c -E -e "$1")" != 0 ]; }
VERSION=$(sed -n 's/^project(SpeedBreaker VERSION \([0-9][0-9.]*\) .*/\1/p' "$ROOT/CMakeLists.txt")
[ -n "$VERSION" ] || fail "no version in CMakeLists.txt's project()"
BUNDLE_ID=io.github.speedbreakerproject.SpeedBreaker
NAME=SpeedBreaker-$VERSION-ios
plutil -lint -s "$ENTITLEMENTS" || fail "$ENTITLEMENTS isn't a property list"
if [ -z "$APP" ]; then
IOS_UNSIGNED=1 IOS_BUNDLE_ID=$BUNDLE_ID "$ROOT/scripts/ios/build.sh"
APP=$ROOT/build/ios/xcode-unsigned/Build/Products/Release-iphoneos/SpeedBreaker.app
fi
[ -f "$APP/SpeedBreaker" ] && [ -f "$APP/Info.plist" ] || fail "no SpeedBreaker.app at $APP"
[ "$(plutil -extract DTPlatformName raw "$APP/Info.plist" 2>/dev/null)" = iphoneos ] \
|| fail "$APP isn't built for iPhone and iPad devices (DTPlatformName iphoneos)"
APP_VERSION=$(plutil -extract CFBundleShortVersionString raw "$APP/Info.plist")
[ "$APP_VERSION" = "$VERSION" ] || fail "$APP is v$APP_VERSION, CMakeLists.txt says $VERSION: build it again"
[ "$(lipo -archs "$APP/SpeedBreaker")" = arm64 ] || fail "the executable isn't arm64 alone ($(lipo -archs "$APP/SpeedBreaker"))"
STAGE=$(mktemp -d "${TMPDIR:-/tmp}/make_ipa.XXXXXX")
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/Payload"
P=$STAGE/Payload/SpeedBreaker.app
ditto --norsrc --noextattr --noqtn "$APP" "$P"
# Nobody's signature or profile, and nothing of the Mac's.
rm -rf "$P/_CodeSignature" "$P/embedded.mobileprovision"
codesign --remove-signature "$P/SpeedBreaker" 2>/dev/null || true
find "$P" \( -name .DS_Store -o -name '._*' \) -delete
[ -z "$(find "$P" -name '*.mobileprovision' -o -name '*.xcent')" ] || fail "a profile is left in the app"
# The debug map out (it names this Mac's folders), the full symbols kept
# for us. A stripped --app has none: nothing to keep.
if nm -ap "$P/SpeedBreaker" 2>/dev/null | has ' OSO '; then
COMMIT=$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)
[ -z "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] || COMMIT=$COMMIT-dirty
DSYM=$OUT/symbols/SpeedBreaker-$VERSION-$COMMIT-ios.dSYM
mkdir -p "$OUT/symbols"
rm -rf "$DSYM"
dsymutil "$APP/SpeedBreaker" -o "$DSYM" 2>/dev/null || fail "dsymutil failed"
strip -S "$P/SpeedBreaker"
fi
nm -gU "$P/SpeedBreaker" | has ' _vkGetInstanceProcAddr$' || fail "vkGetInstanceProcAddr is gone (SDL looks it up)"
# Rewritten with its keys sorted (plutil -replace orders them differently
# from one run to the next, and the signature seals the file).
python3 -I - "$P/Info.plist" "$BUNDLE_ID" <<'PY'
import plistlib, sys
path, bundle_id = sys.argv[1:]
with open(path, 'rb') as f:
info = plistlib.load(f)
info['CFBundleIdentifier'] = bundle_id
info['CFBundleDisplayName'] = 'SpeedBreaker'
with open(path, 'wb') as f:
plistlib.dump(info, f, fmt=plistlib.FMT_BINARY, sort_keys=True)
PY
plutil -lint -s "$P/Info.plist"
# The license notices, as the Mac app and the SteamOS downloads carry them:
# an IPA is a download, and the libraries built into it ask for theirs.
# SpeedBreaker.app/licenses: COPYING, NOTICE, THIRD_PARTY_NOTICES.md and
# LICENSES/, which cover SpeedBreaker and everything in the app.
L=$P/licenses
rm -rf "$L"
mkdir -p "$L"
cp "$ROOT/COPYING" "$ROOT/NOTICE" "$ROOT/THIRD_PARTY_NOTICES.md" "$L/"
ditto --norsrc --noextattr --noqtn "$ROOT/LICENSES" "$L/LICENSES"
cat > "$L/README.txt" <<EOF
SpeedBreaker v$VERSION for iPhone and iPad is free software under the GPL-3.0-or-later
(COPYING, NOTICE). What it is built from and with, and each license, is in
THIRD_PARTY_NOTICES.md and LICENSES/.
Built into this app, besides SpeedBreaker's own code and what THIRD_PARTY_NOTICES.md
lists as compiled into every platform: SDL3 (zlib; with HIDAPI and yuv2rgb,
BSD-3-Clause), glslang (its own list, LICENSES/glslang.txt), MoltenVK (Apache-2.0;
with SPIRV-Cross and SPIRV-Tools, Apache-2.0, and cereal, BSD-3-Clause), the
Vulkan and SPIRV headers, and FFmpeg's XMA decoders (LGPL-2.1-or-later), all
statically linked (scripts/ios/build_deps.sh says which versions).
Source: https://github.com/SpeedBreakerProject/speedbreaker (tag v$VERSION), and the
LGPL parts' complete source in that release's third-party source archive.
EOF
# The entitlements, where the tools read them: an ad-hoc signature (XML and
# DER), no identity, no time stamp.
codesign --force --sign - --timestamp=none --generate-entitlement-der --entitlements "$ENTITLEMENTS" "$P"
codesign --verify --strict "$P"
SIG=$(codesign -dvv "$P" 2>&1)
grep -q '^Signature=adhoc$' <<<"$SIG" || fail "the signature isn't ad-hoc"
grep -q '^TeamIdentifier=not set$' <<<"$SIG" || fail "the signature names a team"
# Exactly the file's entitlements, nothing more (no team, no get-task-allow).
keys() { plutil -convert json -o - - | python3 -I -c 'import json, sys; print(" ".join(sorted(f"{k}={v}" for k, v in json.load(sys.stdin).items())))'; }
WANTED=$(keys < "$ENTITLEMENTS")
SIGNED=$(codesign -d --entitlements - --xml "$P" 2>/dev/null | keys)
[ "$SIGNED" = "$WANTED" ] || fail "the signature's entitlements ($SIGNED) aren't $(basename "$ENTITLEMENTS")'s ($WANTED)"
# Nothing personal: no home folders, profiles, teams or devices. A device's
# UDID (since 2018: 8 hex digits, a dash, 16) is looked for in any form.
PATTERNS=(/Users/ /home/ /private/var/mobile ProvisionedDevices embedded.mobileprovision com.apple.developer.team-identifier
application-identifier TeamIdentifier DEVELOPMENT_TEAM)
if [ -n "${IPA_SCAN_EXTRA:-}" ]; then
IFS='|' read -r -a EXTRA <<<"$IPA_SCAN_EXTRA"
PATTERNS+=("${EXTRA[@]}")
fi
HITS=0
for pattern in "${PATTERNS[@]}"; do
[ -n "$pattern" ] || continue
if found=$(LC_ALL=C grep -r -a -i -l -F -e "$pattern" "$STAGE/Payload"); then
echo "make_ipa.sh: \"$pattern\" found in: ${found//$STAGE\//}" >&2
HITS=$((HITS + 1))
fi
done
if found=$(LC_ALL=C grep -r -a -l -E '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}([^0-9A-Fa-f]|$)' "$STAGE/Payload"); then
echo "make_ipa.sh: something shaped like a device UDID in: ${found//$STAGE\//}" >&2
HITS=$((HITS + 1))
fi
[ "$HITS" = 0 ] || fail "$HITS personal string(s) in the payload: not written"
# A sorted zip with fixed times (2000-01-01, or SOURCE_DATE_EPOCH) and no
# extra fields: the same app, the same IPA, whatever the checkout's state.
EPOCH=${SOURCE_DATE_EPOCH:-946684800}
STAMP=$(TZ=UTC date -r "$EPOCH" +%Y%m%d%H%M.%S)
find "$STAGE/Payload" -exec env TZ=UTC touch -h -t "$STAMP" {} +
mkdir -p "$OUT"
rm -f "$OUT/$NAME.ipa" "$OUT/$NAME.ipa.sha256"
(cd "$STAGE" && find Payload | LC_ALL=C sort | TZ=UTC zip -q -X -y -9 -@ "$STAGE/$NAME.ipa")
unzip -Z1 "$STAGE/$NAME.ipa" | has '^(Payload/|Payload/SpeedBreaker\.app/.*)$' || fail "the IPA is empty"
unzip -Z1 "$STAGE/$NAME.ipa" | grep -v -E '^(Payload/|Payload/SpeedBreaker\.app/.*)$' | has . && fail "the IPA holds more than Payload/SpeedBreaker.app"
mv "$STAGE/$NAME.ipa" "$OUT/$NAME.ipa"
cp "$ENTITLEMENTS" "$OUT/$NAME.entitlements"
(cd "$OUT" && shasum -a 256 "$NAME.ipa" > "$NAME.ipa.sha256")
echo "$OUT/$NAME.ipa ($(du -h "$OUT/$NAME.ipa" | cut -f1 | tr -d ' '), sha256 $(cut -d' ' -f1 "$OUT/$NAME.ipa.sha256"))"
echo " $BUNDLE_ID v$VERSION (build $(plutil -extract CFBundleVersion raw "$P/Info.plist")), ad-hoc signed with" \
"$(sed 's/com\.apple\.developer\.//g; s/=True//g' <<<"$WANTED"); no team, profile or device"
+131 -13
View File
@@ -56,13 +56,29 @@
# --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 json, os, plistlib, re, shutil, subprocess, sys, tempfile
# The dmg is an HFS+ volume (Mac OS Extended) on a GPT disk, compressed
# (UDZO), made by hdiutil while it works: macOS 27 deprecates hdiutil's
# create, attach, detach and imageinfo (they still work; -quiet hides the
# warning) in favour of `diskutil image`. Where hdiutil is missing or fails,
# `diskutil image` makes the same image (its "create from <folder>" makes
# APFS only, so this goes by a blank image, partitioned and filled, then
# converted): the same files, modes, owners and extended attributes. What
# differs, none of it visible in Finder: the GPT partition's name, the
# volume's size (the content + 10% + 8 MB, against hdiutil's estimate), the
# volume root's owner on disk (root, which a mount ignores; hdiutil's is the
# builder), and the compression (diskutil has no zlib level to set; on
# v0.1.1's app its dmg came out 4% smaller than hdiutil's level 9).
# DMG_TOOL=hdiutil or DMG_TOOL=diskutil uses only that one.
import json, os, plistlib, re, shutil, stat, 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('--')]
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)
DMG_TOOL = os.environ.get('DMG_TOOL', '') # '' (hdiutil, else diskutil), 'hdiutil' or 'diskutil'
if DMG_TOOL not in ('', 'hdiutil', 'diskutil'):
raise SystemExit(f'make_app.py: DMG_TOOL={DMG_TOOL}: use hdiutil or diskutil, or leave it unset')
for a in OPTIONS:
if a != '--dmg' and not a.startswith('--min-macos='):
raise SystemExit(f'make_app.py: unknown option {a} (usage: make_app.py [build dir] [output dir] [--dmg] [--min-macos=<version>])')
@@ -332,8 +348,88 @@ def copy_licenses(resources, libraries):
raise SystemExit('make_app.py: no license file found for ' + ', '.join(missing))
def failure(e):
"""What a failed command said (its last line), for a message."""
said = (e.stderr or e.stdout or '').strip().splitlines()
return said[-1] if said else f'exit {e.returncode}'
def diskutil(*args):
"""Runs `diskutil <args>`; a failure ends the script, saying which step."""
try:
return run('diskutil', *args)
except subprocess.CalledProcessError as e:
raise SystemExit(f'make_app.py: diskutil {" ".join(args)}: {failure(e)}')
def attached_disks(plist_text):
"""`diskutil image attach --plist`'s devices: [(disk, mount point or None)], the whole disk first."""
entities = plistlib.loads(plist_text.encode()).get('system-entities', [])
disks = [(e['dev-entry'].removeprefix('/dev/'), e.get('mount-point')) for e in entities if 'dev-entry' in e]
if not disks or not re.fullmatch(r'disk\d+', disks[0][0]):
raise SystemExit(f'make_app.py: diskutil image attach named no disk: {entities}')
return disks
def dmg_with_diskutil(stage, dmg, tmp):
"""The dmg hdiutil makes from `stage`, made with `diskutil image`: a blank
image, a GPT disk with one HFS+ volume named SpeedBreaker on it, the
stage copied in, then converted to UDZO."""
size = 0
for root, dirs, files in os.walk(stage):
for n in dirs + files:
size += (os.lstat(os.path.join(root, n)).st_size + 4095) // 4096 * 4096
size = (size + size // 10 + (8 << 20) + (1 << 20) - 1) // (1 << 20) << 20
blank = os.path.join(tmp, 'blank.dmg')
diskutil('image', 'create', 'blank', '--fs', 'None', '--size', str(size), blank)
disk = attached_disks(diskutil('image', 'attach', '--plist', '--noMount', '--nobrowse', blank))[0][0]
try:
# Mac OS Extended, not journaled, as hdiutil's -fs HFS+. It mounts the
# new volume in /Volumes for a moment; it's filled at a mount point of
# our own, out of Finder's sight.
diskutil('partitionDisk', '/dev/' + disk, '1', 'GPT', 'HFS+', 'SpeedBreaker', '100%')
volume = disk + 's1'
info = plistlib.loads(diskutil('info', '-plist', volume).encode())
if info.get('FilesystemType') != 'hfs' or info.get('VolumeName') != 'SpeedBreaker':
raise SystemExit(f'make_app.py: {volume} is {info.get("FilesystemType")} "{info.get("VolumeName")}", not hfs "SpeedBreaker"')
if info.get('MountPoint'):
diskutil('unmount', volume)
mnt = os.path.join(tmp, 'volume')
os.makedirs(mnt)
diskutil('mount', 'nobrowse', '-mountPoint', mnt, volume)
run('ditto', stage, mnt)
os.chmod(mnt, stat.S_IMODE(os.stat(stage).st_mode)) # the volume's root as hdiutil makes it
shutil.rmtree(os.path.join(mnt, '.fseventsd'), ignore_errors=True) # the event log macOS starts at mount
diskutil('unmount', volume)
finally:
subprocess.run(['diskutil', 'eject', disk], capture_output=True)
diskutil('image', 'create', 'from', '--format', 'UDZO', blank, dmg)
def check_dmg_with_diskutil(dmg, tmp):
"""hdiutil verify's place when diskutil made the dmg: a compressed GPT +
HFS+ image that mounts, holding the app and the Applications shortcut,
and the app's code signature checks every file in it."""
info = plistlib.loads(diskutil('image', 'info', '--plist', dmg).encode())
hints = [p.get('content-hint') for p in info.get('Partitions', [])]
if info.get('Image Format') != 'UDZO' or hints != ['GUID_partition_scheme', 'Apple_HFS']:
raise SystemExit(f'make_app.py: {dmg} is {info.get("Image Format")} {hints}, not UDZO with one HFS+ volume')
mnt = os.path.join(tmp, 'check')
os.makedirs(mnt)
disks = attached_disks(diskutil('image', 'attach', '--plist', '--readOnly', '--nobrowse', '--mountPoint', mnt, dmg))
try:
top = sorted(os.listdir(mnt))
link = os.path.join(mnt, 'Applications')
if top != ['Applications', 'SpeedBreaker.app'] or not os.path.islink(link) or os.readlink(link) != '/Applications':
raise SystemExit(f'make_app.py: {dmg} holds {top}, not SpeedBreaker.app and a shortcut to /Applications')
run('codesign', '--verify', '--deep', '--strict', os.path.join(mnt, 'SpeedBreaker.app'))
finally:
subprocess.run(['diskutil', 'eject', disks[0][0]], capture_output=True)
def make_dmg(app, version):
"""SpeedBreaker-macos.dmg: the app and a shortcut to Applications."""
"""SpeedBreaker-macos.dmg: the app and a shortcut to Applications. Returns
it and the tool that made it."""
dmg = os.path.join(OUT, 'SpeedBreaker-macos.dmg')
versioned = os.path.join(OUT, f'SpeedBreaker-{version}-macos.dmg')
with tempfile.TemporaryDirectory() as tmp:
@@ -344,15 +440,37 @@ def make_dmg(app, version):
for f in (dmg, versioned):
if os.path.exists(f):
os.remove(f)
run('hdiutil', 'create', '-quiet', '-volname', 'SpeedBreaker', '-srcfolder', stage, '-fs', 'HFS+', '-format', 'UDZO',
'-imagekey', 'zlib-level=9', '-ov', dmg)
os.link(dmg, versioned)
for f in (dmg, versioned):
digest = run('shasum', '-a', '256', f).split()[0]
with open(f + '.sha256', 'w') as out:
out.write(f'{digest} {os.path.basename(f)}\n') # sha256sum -c / shasum -a 256 -c
run('hdiutil', 'verify', '-quiet', dmg)
return dmg
tool = None
if DMG_TOOL != 'diskutil':
if shutil.which('hdiutil'):
try:
run('hdiutil', 'create', '-quiet', '-volname', 'SpeedBreaker', '-srcfolder', stage, '-fs', 'HFS+',
'-format', 'UDZO', '-imagekey', 'zlib-level=9', '-ov', dmg)
tool = 'hdiutil'
except subprocess.CalledProcessError as e:
if DMG_TOOL == 'hdiutil':
raise SystemExit(f'make_app.py: hdiutil create: {failure(e)}')
print(f'make_app.py: hdiutil create failed ({failure(e)}): making the dmg with diskutil image',
file=sys.stderr)
if os.path.exists(dmg):
os.remove(dmg)
elif DMG_TOOL == 'hdiutil':
raise SystemExit('make_app.py: DMG_TOOL=hdiutil, but this Mac has no hdiutil')
else:
print('make_app.py: no hdiutil on this Mac: making the dmg with diskutil image', file=sys.stderr)
if not tool:
dmg_with_diskutil(stage, dmg, tmp)
tool = 'diskutil'
os.link(dmg, versioned)
for f in (dmg, versioned):
digest = run('shasum', '-a', '256', f).split()[0]
with open(f + '.sha256', 'w') as out:
out.write(f'{digest} {os.path.basename(f)}\n') # sha256sum -c / shasum -a 256 -c
if tool == 'hdiutil':
run('hdiutil', 'verify', '-quiet', dmg)
else:
check_dmg_with_diskutil(dmg, tmp)
return dmg, tool
def main():
@@ -521,8 +639,8 @@ def main():
print(f'{app} ({size} MB, v{version}, {commit}{"-dirty" if dirty else ""}, macOS {minimum}+): '
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')
dmg, tool = make_dmg(app, version)
print(f'{dmg} ({os.path.getsize(dmg) // (1 << 20)} MB, made with {tool}) and {os.path.basename(dmg)}.sha256')
if __name__ == '__main__':
@@ -110,10 +110,22 @@ the run before is kept as `previous-run.log`.
- The installed game: `game/` in that folder, or the folder you gave
`--dest`.
- Shader caches (safe to delete): `~/.cache/speedbreaker/`.
- In portable mode (below), all of these are in this folder instead.
- To uninstall: delete this folder, `~/.local/share/speedbreaker/` (that
deletes your saves too), `~/.cache/speedbreaker/` and the game's folder
if you chose one, and remove the shortcut from Steam.
## Portable mode
An empty file named `portable.txt` next to `speedbreaker.sh` keeps
everything in this folder: saves, `settings.toml` and logs in `data/`, the
game in `data/game`, shader caches in `cache/`. The whole folder can then be
moved or copied to another machine and played there (add it to Steam again
there). Making the file starts fresh; to bring your current saves, settings
and game along, run `~/Games/SpeedBreaker/speedbreaker.sh --portable on`
from Konsole instead (it copies them, checking every game file, and makes
the file; nothing is deleted). `--portable off` copies them back.
## Licenses
SpeedBreaker is free software under the GNU General Public License, version
+58 -1
View File
@@ -45,7 +45,8 @@ game can't start from there.
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
(they're elsewhere, below, or in this folder's `data/` in portable mode,
which an update leaves alone), 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.
@@ -125,6 +126,14 @@ own resolution; the busiest scenes can dip below. A Steam Machine holds 60 at
menu > Advanced > Performance Overlay, or Steam's own performance overlay
(Quick Access > Performance).
On a laptop with two GPUs (one built into the processor, and an NVIDIA or
AMD graphics card), the game runs on the graphics card. `last-run.log` lists
the GPUs it found and the one it uses (the `[video] Vulkan devices:` line).
To use another, add `NFSMW_GPU=<its number or part of its name>` to the
launch options, e.g. `NFSMW_GPU=intel %command%`. With NVIDIA's driver, the
launcher also turns on NVIDIA's PRIME render offload
(`__NV_PRIME_RENDER_OFFLOAD=1`; a value you set yourself is kept).
## Reporting problems
Settings menu > Advanced > **Save Bug Report** puts the recent logs, any
@@ -146,10 +155,58 @@ ends with where in the program it was.
`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/`.
- In portable mode (below), all of these are in this folder instead.
- `speedbreaker.sh --where` prints where the game, your data and the caches are.
- To uninstall: delete this folder, `~/.local/share/speedbreaker/` (that
deletes your saves too), `~/.cache/speedbreaker/` and the game's folder
if you chose one, and remove the shortcut from Steam.
## Portable mode
To keep everything in this folder (your saves and settings, the logs, the
shader caches and the installed game), so the whole folder can be moved or
copied to another Steam Deck, Steam Machine or Linux PC and played there,
make an empty file named `portable.txt` here, next to `speedbreaker.sh`
(Dolphin: right-click > Create New > Text File; or `touch portable.txt` in
Konsole). From then on:
- Saves, `settings.toml` and logs (with crash reports and what Save Bug
Report collects) are in `data/` here, and the game installs into
`data/game`. Shader caches go in `cache/` (safe to delete; another
machine's graphics card rebuilds its own).
- Nothing is read from or written to `~/.local/share/speedbreaker` or
`~/.cache/speedbreaker`.
- The installer's To row offers this folder (and Choose a folder..., which
still takes any folder; one outside this folder won't move with it).
- Unpacking an update over the folder keeps `portable.txt`, `data/` and
`cache/`.
Making the file starts fresh: the first start installs the game into
`data/game` and settings start at their defaults. To bring what you have now
along instead (settings, saves and the installed game), don't make the file
yourself; run this in Konsole:
~/Games/SpeedBreaker/speedbreaker.sh --portable on
It copies `~/.local/share/speedbreaker` into `data/` and the game into
`data/game` (checking every file, a few minutes), then makes `portable.txt`.
Nothing is deleted: the old copies stay until you delete them. To go back,
`speedbreaker.sh --portable off` copies `data/` back to
`~/.local/share/speedbreaker` (anything already there is kept, renamed to
`speedbreaker.before-portable-off-<date>-<time>`) and removes
`portable.txt`. Deleting `portable.txt` by hand goes back too, to whatever
`~/.local/share/speedbreaker` held before, with nothing copied.
To move it: copy or move the whole folder (with `data/`, which holds the
game: about 7 GB), then add `speedbreaker.sh` to Steam again on the other
machine (step 2). The game finds itself wherever the folder is. Keep the
folder on internal storage or on a card or drive formatted by SteamOS
(ext4): on one formatted for Windows (exFAT, FAT32, NTFS) the program can't
start, though such a drive is fine for carrying a copy of the folder. On a
read-only drive the game plays but says it can't keep settings or saves.
Portable mode is for SteamOS and Linux; the Mac app keeps its data in the
Mac's own folders.
## Licenses
SpeedBreaker is free software under the GNU General Public License, version
+96 -5
View File
@@ -30,14 +30,25 @@
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
BIN="$HERE/lib/SpeedBreaker"
# Portable mode (README.md): a portable.txt here keeps the game's settings,
# saves, logs and caches in this folder (data/ and cache/); the game itself
# looks for the file, this script only reports it and keeps its own log out
# of the home folder then.
PORTABLE=0
[ -f "$HERE/portable.txt" ] && PORTABLE=1
# 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
# (`speedbreaker.sh --install ...`).
# the last place a log can go; in portable mode, straight there). On a
# terminal it's shown as well (`speedbreaker.sh --install ...`).
LOG_DIR="$HERE"
if [ ! -w "$LOG_DIR" ]; then
LOG_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/speedbreaker"
mkdir -p "$LOG_DIR" 2>/dev/null
[ -w "$LOG_DIR" ] || LOG_DIR="${TMPDIR:-/tmp}"
if [ "$PORTABLE" = 1 ]; then
LOG_DIR="${TMPDIR:-/tmp}"
else
LOG_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/speedbreaker"
mkdir -p "$LOG_DIR" 2>/dev/null
[ -w "$LOG_DIR" ] || LOG_DIR="${TMPDIR:-/tmp}"
fi
fi
[ -f "$LOG_DIR/last-run.log" ] && mv -f "$LOG_DIR/last-run.log" "$LOG_DIR/previous-run.log"
if [ -t 2 ]; then
@@ -48,6 +59,7 @@ fi
say() { echo "[launcher] $*"; }
say "$(cat "$HERE/VERSION" 2>/dev/null || echo "SpeedBreaker (no VERSION file)"), started $(date '+%F %T %z')"
[ "$PORTABLE" = 1 ] && say "portable mode: portable.txt in $HERE (data/ and cache/ beside it)"
# 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
@@ -145,6 +157,85 @@ fi
say "C library: $([ "$use_glibc" = 1 ] && echo bundled || echo "the system's") (system ${host_glibc:-none}, bundled ${bundled_glibc:-none});" \
"C++ library: $([ "$use_cxx" = 1 ] && echo bundled || echo "the system's") (system ${host_cxx:-none}, bundled ${bundled_cxx:-none})"
# Laptops with two GPUs: an Intel or AMD one in the processor, beside an
# NVIDIA graphics card. The game itself draws with the discrete GPU (its
# log's "[video] Vulkan devices:" line lists them all; NFSMW_GPU=<index or
# part of a name> picks another). With NVIDIA's own driver, this also turns
# on NVIDIA's PRIME render offload for the game, as NVIDIA documents for
# Vulkan programs: __NV_PRIME_RENDER_OFFLOAD=1 enables NVIDIA's Vulkan layer,
# which lists the NVIDIA GPU first. The idea and its first version: GitHub
# PR #6 by lydacious (tested on an i9-13900H laptop with an RTX 3050: the game
# had run on the Intel GPU, and ran at 60 fps on the NVIDIA one).
# Only when a GPU is driven by NVIDIA's kernel driver, another GPU is in use
# beside it, and NVIDIA's Vulkan driver is where the Vulkan loader looks for
# drivers; never over a value already set (__NV_PRIME_RENDER_OFFLOAD=0 in
# Steam's launch options turns it off). Left out on purpose:
# VK_DRIVER_FILES=<NVIDIA's driver only>: the other GPU would vanish, so a
# broken NVIDIA driver (updated, not yet rebooted) would leave the game no
# GPU instead of the integrated one, and NFSMW_GPU couldn't pick it; the
# game prefers the NVIDIA GPU without it.
# __GLX_VENDOR_LIBRARY_NAME=nvidia: OpenGL only, which the game doesn't
# use; it would only move programs the game starts (a file dialog, the
# web browser) onto the NVIDIA GPU.
# One log line names the PCI GPUs (none on the Steam Frame, whose GPU isn't
# on PCI: no line) and what was done. tests/launcher_prime_test.sh runs these
# two functions on made-up /sys, /proc and Vulkan folders (<root>).
# nvidia_icd [root]: NVIDIA's Vulkan driver manifest, where the Vulkan loader
# looks (its folders, in its order), or nothing.
nvidia_icd() {
local root="${1:-}" IFS=: dir name
for dir in ${XDG_CONFIG_HOME:-$HOME/.config} ${XDG_CONFIG_DIRS:-/etc/xdg} /etc \
${XDG_DATA_HOME:-$HOME/.local/share} ${XDG_DATA_DIRS:-/usr/local/share:/usr/share} /usr/local/share /usr/share; do
[ -n "$dir" ] || continue
for name in nvidia_icd.json "nvidia_icd.$(uname -m).json"; do
if [ -f "$root$dir/vulkan/icd.d/$name" ]; then
echo "$dir/vulkan/icd.d/$name"
return 0
fi
done
done
return 1
}
# prime_offload [root]: the above.
prime_offload() {
local root="${1:-}" d class vendor device driver kind version gpus="" nvidia=0 others=0 icd
for d in "$root"/sys/bus/pci/devices/*; do
class="$(cat "$d/class" 2>/dev/null)" || continue
case "$class" in 0x03*) ;; *) continue ;; esac # display controllers: VGA, 3D, other
vendor="$(cat "$d/vendor" 2>/dev/null)"
device="$(cat "$d/device" 2>/dev/null)"
driver="$(readlink "$d/driver" 2>/dev/null)"
driver="${driver##*/}"
case "$vendor" in
0x10de) kind=NVIDIA ;; 0x8086) kind=Intel ;; 0x1002) kind=AMD ;; *) kind=PCI ;;
esac
gpus="$gpus${gpus:+, }$kind ${vendor#0x}:${device#0x} (${driver:-no driver})"
if [ "$vendor" = 0x10de ]; then
[ "$driver" = nvidia ] && nvidia=$((nvidia + 1))
else
case "$driver" in ""|vfio-pci|pci-stub) ;; *) others=$((others + 1)) ;; esac # not passed to a VM
fi
done
[ -n "$gpus" ] || return 0
if [ "$nvidia" = 0 ] || [ "$others" = 0 ] || [ ! -d "$root/proc/driver/nvidia" ]; then
say "graphics: $gpus"
return 0
fi
version="$(sed -n '1s/.* \([0-9][0-9.]*[0-9]\) .*/\1/p' "$root/proc/driver/nvidia/version" 2>/dev/null)"
gpus="$gpus; NVIDIA driver${version:+ $version} beside another GPU"
if ! icd="$(nvidia_icd "$root")"; then
say "graphics: $gpus, but no NVIDIA Vulkan driver (nvidia_icd.json) where the Vulkan loader looks: no PRIME render offload"
elif [ -n "${__NV_PRIME_RENDER_OFFLOAD+set}" ]; then
say "graphics: $gpus; NVIDIA's Vulkan driver $icd; __NV_PRIME_RENDER_OFFLOAD=$__NV_PRIME_RENDER_OFFLOAD was already set: left as it is"
else
export __NV_PRIME_RENDER_OFFLOAD=1
say "graphics: $gpus; NVIDIA's Vulkan driver $icd; __NV_PRIME_RENDER_OFFLOAD=1 (PRIME render offload: NVIDIA's layer lists its GPU first)"
fi
}
prime_offload
# Game Mode (gamescope, from Steam's environment): the game runs fullscreen
# at the resolution Steam sets for it (Properties > General > Game
# Resolution). SDL uses X11 (gamescope's Xwayland), which is how gamescope
+12
View File
@@ -94,6 +94,12 @@ if [ -e "$STAGE" ] && { [ -e "$STAGE/.git" ] || [ ! -f "$STAGE/speedbreaker.sh"
echo "make_bundle.sh: $STAGE exists and isn't a bundle folder; not replacing it (give another output folder)" >&2
exit 1
fi
# A bundle folder someone played from in portable mode holds their saves (and
# maybe the game) in data/: never deleted here.
if [ -e "$STAGE/portable.txt" ] || [ -e "$STAGE/data" ]; then
echo "make_bundle.sh: $STAGE is a portable folder (portable.txt or data/: saves); not replacing it (give another output folder)" >&2
exit 1
fi
rm -rf "$STAGE"
mkdir -p "$STAGE/lib" "$STAGE/licenses"
[ "$RUNTIMES" = bundled ] && mkdir -p "$STAGE/lib/cxx" "$STAGE/lib/glibc"
@@ -258,6 +264,12 @@ for pkg in "${!packages[@]}"; do
done
done
# Never a portable-mode switch or data folder (README.md, "Portable mode"):
# unpacked over a player's folder, the download must leave theirs as it is.
for f in portable.txt data cache; do
[ -e "$STAGE/$f" ] && { echo "make_bundle.sh: $STAGE/$f: portable mode's, never part of the download" >&2; exit 1; }
done
# Never game data (the installer brings it from the player's own disc).
if find "$STAGE" \( -iname '*.xex' -o -iname '*.iso' -o -iname '*.bin' -o -iname '*.xmv' -o -iname '*.big' \) | grep -q .; then
echo "make_bundle.sh: game data in $STAGE" >&2
+50
View File
@@ -22,12 +22,35 @@ 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 internal resolution as a ratio, 1.5x included: guest <-> host pixels
# and the scaled resolve memory's layout (gpu/render_scale.h, header only);
# renderer.cpp is read to check its scaled-memory shaders use it.
sb_test(render_scale_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)
# A saved pipeline cache from another GPU, driver or OS version, and the log
# line that says so (gpu/pipeline_cache_header.h, header only).
sb_test(pipeline_cache_header_test)
sb_test(picture_fit_test SOURCES ${RT}/video/picture_fit.cpp)
# Which Vulkan device the presenter draws with, and its log line
# (video/gpu_choice.h, header only).
sb_test(gpu_choice_test)
# The sun's glow fitted to the view scales (game/glare.h, header only), on
# video::FitPicture's pictures.
sb_test(glare_fit_test SOURCES ${RT}/video/picture_fit.cpp)
# The post-processing's split at the 16:9 band's edge (video/band_filter.h,
# video::BandOf) against the edge-bar fills and a model of the filters.
sb_test(band_filter_test SOURCES ${RT}/video/picture_fit.cpp)
# Internal Resolution Auto's budget on iPhones and iPads, chip by chip
# (video/scale_budget.cpp), and the scale it makes on the phones' screens.
sb_test(scale_budget_test SOURCES ${RT}/video/scale_budget.cpp ${RT}/video/picture_fit.cpp)
sb_test(refresh_fit_test SOURCES ${RT}/video/refresh_fit.cpp)
sb_test(frame_rate_policy_test SOURCES ${RT}/video/frame_rate_policy.cpp)
# When the presenter builds its swapchain again and at what size (an iPhone's
# view turned portrait by SDL, sizes seen while inactive, SUBOPTIMAL), and the
# UI's display size: scenarios, then planted wrong rules that must fail one.
sb_test(swapchain_policy_test SOURCES ${RT}/video/swapchain_policy.cpp)
# Game time in whole vblanks (game/whole_vblanks.h), header only.
sb_test(whole_vblanks_test)
sb_test(guest_time_test SOURCES ${RT}/cpu/guest_time.cpp)
@@ -47,11 +70,38 @@ endif()
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
${RT}/user/portable.cpp
ARGS ${CMAKE_BINARY_DIR}/install_folder_scratch) # not beside the test: that's its "own folder", refused
# Portable mode (user/portable.h) and --portable on|off
# (install/portable_switch.h): the layout, the records that move with
# the folder, the placement rules inside it, the startup write check, and
# the copies (a stand-in copies the game), in a scratch folder.
sb_test(portable_test SOURCES ${RT}/user/portable.cpp ${RT}/install/portable_switch.cpp ${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}/portable_scratch)
if(APPLE AND NOT IOS)
target_link_libraries(install_folder_test PRIVATE "-framework CoreFoundation") # placement.cpp's iCloud check
target_link_libraries(portable_test PRIVATE "-framework CoreFoundation")
endif()
endif()
# The SteamOS launcher's hybrid-graphics part (NVIDIA PRIME render offload,
# scripts/steamos/bundle/speedbreaker.sh) on made-up /sys, /proc and Vulkan
# folders. Bash, so not on Windows.
if(NOT WIN32)
find_program(SB_BASH bash)
if(SB_BASH)
add_test(NAME launcher_prime_test COMMAND ${SB_BASH} ${CMAKE_CURRENT_SOURCE_DIR}/launcher_prime_test.sh
${ROOT}/scripts/steamos/bundle/speedbreaker.sh)
endif()
endif()
# The entitlements in a Mach-O's code signature (platform/code_signature.h),
# on files built byte by byte: what the iOS app reads from its own.
sb_test(code_signature_test SOURCES ${RT}/platform/code_signature.cpp)
# The [memory] line, the page shown when the guest's 4 GB can't be reserved
# (an iOS copy without Extended Virtual Addressing) and the low-memory notice
# (kernel/memory_status.h).
sb_test(memory_status_test SOURCES ${RT}/kernel/memory_status.cpp ${RT}/platform/code_signature.cpp)
# 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)
+314
View File
@@ -0,0 +1,314 @@
// Tests for the post-processing's band split: video::BandOf
// (runtime/video/picture_fit.cpp) and video::band (runtime/video/
// band_filter.h, which video/presenter.cpp's GLSL follows line for line).
// From the repo root:
// clang++ -std=c++20 -O2 -UNDEBUG -Iruntime tests/band_filter_test.cpp runtime/video/picture_fit.cpp -o build/band_filter_test
// build/band_filter_test
// What it checks:
// - BandOf's rows and columns are those whose centres lie inside the band
// (a pixel-centre oracle), and with the edge-bar fills' rows
// (gpu::bars::BlackoutRects) they split the frame with no gap and no
// overlap, at every internal resolution (a fuzz over screen shapes too);
// - the scaling pass, modelled along the axis that is split (bilinear,
// Catmull-Rom with its 2x2 clamp and sharpening, and the 7-fetch
// variant), on a frame whose strips are black and whose band is white:
// every screen row whose centre maps into a strip is exactly black and
// every one in the band exactly white (the career intro's movie
// letterbox bled ~5% of the band into the strip's last row before);
// - every fetch the anti-aliasing pass makes for a pixel reads only its
// own span's texels.
// The same checks run with planted faults (no split, a band edge one texel
// off either way, clamping to the span's edges instead of its texel
// centres): each must fail.
#include <gpu/edge_bars.h>
#include <video/band_filter.h>
#include <video/picture_fit.h>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <functional>
#include <iterator>
#include <random>
#include <vector>
static int g_failures = 0;
static bool g_print = true;
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; if (g_print) { printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } } while (0)
using video::band::Span;
// How a pixel's span is found and a tap kept in it: the code under test, or
// a planted fault.
struct Model
{
const char* name;
// The band's [lo, hi) as the presenter passes it, from BandOf's.
std::function<void(int32_t& lo, int32_t& hi, int32_t size)> band = [](int32_t&, int32_t&, int32_t) {};
std::function<Span(float t, int32_t lo, int32_t hi, int32_t size)> spanOf = video::band::SpanOf;
std::function<float(float t, Span s)> clamp = video::band::Clamp;
std::function<int32_t(int32_t i, Span s)> clampTexel = video::band::ClampTexel;
};
// One axis of the frame image: texel values, the sampler's clamp to edge.
struct Line
{
std::vector<float> v;
float Texel(int32_t i) const
{
return v[size_t(std::clamp(i, 0, int32_t(v.size()) - 1))];
}
// A bilinear fetch at coordinate t (texel units).
float At(float t) const
{
const float x = t - 0.5f, f = x - std::floor(x);
const int32_t i = int32_t(std::floor(x));
return Texel(i) * (1.0f - f) + Texel(i + 1) * f;
}
};
// The scaling pass at frame coordinate pos (a screen pixel's centre), one
// axis. kind 0 bilinear, 1 Catmull-Rom (kScaleFs), 2 the 7-fetch variant
// (kScaleFastFs; along one axis its gather is the 2 texels round pos).
static float Scale(const Line& line, float pos, int kind, float sharpen, const Model& m, int32_t lo, int32_t hi)
{
const int32_t size = int32_t(line.v.size());
const Span s = m.spanOf(pos, lo, hi, size);
auto at = [&](float t) { return line.At(m.clamp(t, s)); };
if (kind == 0)
return at(pos);
const float c = std::floor(pos - 0.5f) + 0.5f, f = pos - c;
const float w0 = f * (-0.5f + f * (1.0f - 0.5f * f)), w1 = 1.0f + f * f * (-2.5f + 1.5f * f);
const float w2 = f * (0.5f + f * (2.0f - 1.5f * f)), w3 = f * f * (-0.5f + 0.5f * f), w12 = w1 + w2;
if (kind == 1)
{
const float cr = at(c - 1.0f) * w0 + at(c + w2 / w12) * w12 + at(c + 2.0f) * w3;
const float a = at(c), b = at(c + 1.0f);
return std::clamp(cr + sharpen * (cr - at(pos)), std::min(a, b), std::max(a, b));
}
const int32_t first = int32_t(c - 0.5f);
const float a = line.Texel(m.clampTexel(first, s)), d = line.Texel(m.clampTexel(first + 1, s));
const float middle = a + (d - a) * (w2 / w12), bilinear = a + (d - a) * f;
const float cr = (at(c - 1.0f) * w0 + middle * w12 + at(c + 2.0f) * w3) / (w0 + w12 + w3);
return std::clamp(cr + sharpen * (cr - bilinear), std::min(a, d), std::max(a, d));
}
struct Shape
{
uint32_t w, h;
};
// Screens: Vert+ (16:10, 3:2, 4:3, iPad, 5:4 letterboxed to 4:3), Hor+
// (ultrawides, a phone, 32:9).
constexpr Shape kScreens[] = { { 1280, 800 }, { 1920, 1200 }, { 2560, 1600 }, { 1200, 800 }, { 2160, 1440 }, { 1024, 768 },
{ 2732, 2048 }, { 1280, 1024 }, { 2560, 1080 }, { 3440, 1440 }, { 2796, 1290 }, { 3840, 1080 } };
// Internal resolutions: 1x, 2x2, 3x2, 1x2, 3x3.
constexpr Shape kImages[] = { { 1280, 720 }, { 2560, 1440 }, { 3840, 1440 }, { 1280, 1440 }, { 3840, 2160 } };
// One screen, one image size: the scaling pass along the split axis on a
// black-strips, white-band frame. Returns the failures it added.
static int ScaleCase(const Model& m, const Shape& screen, const Shape& image, int kind)
{
const int before = g_failures;
const video::PictureFit fit = video::FitPicture(screen.w, screen.h, 1280.0f, 720.0f, true, true);
const video::FrameBand band = video::BandOf(image.w, image.h, fit.scaleX, fit.scaleY);
const bool tall = fit.scaleY < 1.0f;
const int32_t size = int32_t(tall ? image.h : image.w);
int32_t lo = tall ? band.y0 : band.x0, hi = tall ? band.y1 : band.x1;
const int32_t trueLo = lo, trueHi = hi;
m.band(lo, hi, size);
Line line;
line.v.resize(size_t(size));
for (int32_t i = 0; i < size; i++)
line.v[size_t(i)] = i >= trueLo && i < trueHi ? 1.0f : 0.0f;
// The picture's pixels along that axis (the presenter's viewport).
const int32_t pixels = tall ? fit.height : fit.width;
int strip = 0, inBand = 0;
for (int32_t r = 0; r < pixels; r++)
{
const float pos = (float(r) + 0.5f) / float(pixels) * float(size);
const float v = Scale(line, pos, kind, 0.3f, m, lo, hi);
const bool want = pos >= float(trueLo) && pos < float(trueHi);
if (want)
{
inBand++;
CHECK(std::fabs(v - 1.0f) <= 1e-5f, "%ux%u, image %ux%u, filter %d: band pixel %d (frame %.3f) reads %.5f, want 1", screen.w, screen.h,
image.w, image.h, kind, r, pos, v);
}
else
{
strip++;
CHECK(std::fabs(v) <= 1e-5f, "%ux%u, image %ux%u, filter %d: strip pixel %d (frame %.3f) reads %.5f, want 0 (bleed)", screen.w,
screen.h, image.w, image.h, kind, r, pos, v);
}
}
CHECK(strip > 0 && inBand > 0, "%ux%u: %d strip and %d band pixels", screen.w, screen.h, strip, inBand);
return g_failures - before;
}
// Every fetch the anti-aliasing pass can make for pixel i (its neighbours,
// its edge walk up to 24 texels away, half-texel steps, the final sample's
// offset) reads only texels of i's own span. Returns the failures it added.
static int AaCase(const Model& m, int32_t size, int32_t lo0, int32_t hi0)
{
const int before = g_failures;
int32_t lo = lo0, hi = hi0;
m.band(lo, hi, size);
for (int32_t i = 0; i < size; i++)
{
const Span own = video::band::SpanOf(float(i) + 0.5f, lo0, hi0, size); // where the pixel truly is
const Span s = m.spanOf(float(i) + 0.5f, lo, hi, size);
for (float d = -24.0f; d <= 24.0f; d += 0.25f)
{
const float t = m.clamp(float(i) + 0.5f + d, s) - 0.5f;
const int32_t a = int32_t(std::floor(t));
const float f = t - float(a);
const bool okA = std::clamp(a, 0, size - 1) >= own.a && std::clamp(a, 0, size - 1) < own.b;
const bool okB = f == 0.0f || (std::clamp(a + 1, 0, size - 1) >= own.a && std::clamp(a + 1, 0, size - 1) < own.b);
CHECK(okA && okB, "size %d band [%d,%d): pixel %d's fetch at %+.2f reads texel %d%s outside its span [%d,%d)", size, lo0, hi0, i, d,
okA ? a + 1 : a, okA ? "" : " (or its neighbour)", own.a, own.b);
if (g_failures - before > 8)
return g_failures - before;
}
}
return g_failures - before;
}
static int AllCases(const Model& m)
{
int failed = 0;
for (const Shape& s : kScreens)
for (const Shape& im : kImages)
for (int kind = 0; kind < 3; kind++)
failed += ScaleCase(m, s, im, kind) ? 1 : 0;
// The anti-aliasing at the frame's resolution: 16:10, 4:3, 3:2 rows and
// ultrawide columns at 1x and 2x.
for (const Shape& s : { Shape{ 1280, 800 }, Shape{ 1024, 768 }, Shape{ 1200, 800 }, Shape{ 2560, 1080 } })
for (const Shape& im : { kImages[0], kImages[1] })
{
const video::PictureFit fit = video::FitPicture(s.w, s.h, 1280.0f, 720.0f, true, true);
const video::FrameBand b = video::BandOf(im.w, im.h, fit.scaleX, fit.scaleY);
failed += fit.scaleY < 1.0f ? (AaCase(m, int32_t(im.h), b.y0, b.y1) ? 1 : 0) : (AaCase(m, int32_t(im.w), b.x0, b.x1) ? 1 : 0);
}
return failed;
}
// BandOf against the pixel-centre oracle, and against the edge-bar fills:
// with the black frame's fill rows (gpu::bars::BlackoutRects, through a
// full-frame front-end viewport: NDC y = -clip y) every row is in exactly
// one of the band and the fill. Columns likewise under Hor+.
static void BandRowsCase(uint32_t w, uint32_t h, float sx, float sy)
{
const video::FrameBand b = video::BandOf(w, h, sx, sy);
for (int axis = 0; axis < 2; axis++)
{
const float scale = axis ? sy : sx;
const int32_t size = int32_t(axis ? h : w), lo = axis ? b.y0 : b.x0, hi = axis ? b.y1 : b.x1;
if (scale >= 1.0f)
{
CHECK(lo == 0 && hi == size, "%ux%u scale %.4f: axis %d band [%d,%d), want the whole %d", w, h, scale, axis, lo, hi, size);
continue;
}
int wrong = 0;
for (int32_t i = 0; i < size; i++)
{
const float ndc = (float(i) + 0.5f) / float(size) * 2.0f - 1.0f;
if (std::fabs(std::fabs(ndc) - scale) * float(size) * 0.5f < 1.0f / 256.0f)
continue; // a tie, within the rasteriser's snap
wrong += (std::fabs(ndc) < scale) != (i >= lo && i < hi);
}
CHECK(wrong == 0, "%ux%u scale %.4f: %d %s differ from the pixel-centre oracle", w, h, scale, wrong, axis ? "rows" : "columns");
}
const float ps[4] = { 1.0f, -1.0f, 0.0f, 0.0f };
gpu::bars::Rect r[2]{};
const uint32_t n = gpu::bars::BlackoutRects(ps, float(w), float(h), { 0, 0, int32_t(w), int32_t(h) }, sx, sy, r);
int wrongPx = 0;
for (int32_t y = 0; y < int32_t(h); y += (y % 97 == 0 ? 1 : 7))
for (int32_t x = 0; x < int32_t(w); x += 13)
{
int in = (x >= b.x0 && x < b.x1 && y >= b.y0 && y < b.y1) ? 1 : 0;
for (uint32_t i = 0; i < n; i++)
in += (x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1) ? 1 : 0;
wrongPx += in != 1;
}
// Every row and column at the edges themselves.
for (int32_t y : { b.y0 - 1, b.y0, b.y1 - 1, b.y1 })
for (int32_t x : { b.x0 - 1, b.x0, b.x1 - 1, b.x1, int32_t(w) / 2 })
{
if (x < 0 || y < 0 || x >= int32_t(w) || y >= int32_t(h))
continue;
int in = (x >= b.x0 && x < b.x1 && y >= b.y0 && y < b.y1) ? 1 : 0;
for (uint32_t i = 0; i < n; i++)
in += (x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1) ? 1 : 0;
wrongPx += in != 1;
}
CHECK(wrongPx == 0, "%ux%u scale x %.4f y %.4f: %d pixel(s) in both or neither of the band and the black frame's fill", w, h, sx, sy, wrongPx);
}
int main()
{
printf("BandOf: the band's rows and columns, and with the edge-bar fills a partition of the frame:\n");
for (const Shape& s : kScreens)
{
const video::PictureFit fit = video::FitPicture(s.w, s.h, 1280.0f, 720.0f, true, true);
for (const Shape& im : kImages)
BandRowsCase(im.w, im.h, fit.scaleX, fit.scaleY);
const video::FrameBand b = video::BandOf(1280, 720, fit.scaleX, fit.scaleY);
printf(" %ux%u: view scale x %.4f y %.4f, 1x frame band x [%d,%d) y [%d,%d)\n", s.w, s.h, fit.scaleX, fit.scaleY, b.x0, b.x1, b.y0, b.y1);
}
{
// 16:9 and the letterboxed shapes: the whole frame, nothing split.
for (const Shape& s : { Shape{ 1280, 720 }, Shape{ 1920, 1080 }, Shape{ 1366, 768 }, Shape{ 1360, 768 } })
{
const video::PictureFit fit = video::FitPicture(s.w, s.h, 1280.0f, 720.0f, true, true);
const video::FrameBand b = video::BandOf(1280, 720, fit.scaleX, fit.scaleY);
CHECK(b.x0 == 0 && b.x1 == 1280 && b.y0 == 0 && b.y1 == 720, "%ux%u: band x [%d,%d) y [%d,%d), want the whole frame", s.w, s.h, b.x0,
b.x1, b.y0, b.y1);
}
// A fuzz: random view scales (one below 1) and image sizes.
std::mt19937 rng(12345);
std::uniform_real_distribution<float> scale(0.5f, 0.999f);
std::uniform_int_distribution<int> size(200, 4000), coin(0, 1);
for (int i = 0; i < 2000; i++)
{
const float sc = scale(rng);
const bool tall = coin(rng);
BandRowsCase(uint32_t(size(rng)), uint32_t(size(rng)), tall ? 1.0f : sc, tall ? sc : 1.0f);
}
printf(" fuzz: 2000 random scales and image sizes\n");
}
const int bandFailures = g_failures;
const Model fixed{ "band_filter.h" };
const int failed = AllCases(fixed);
printf("scaling (3 filters x %zu screens x %zu images) and anti-aliasing fetches: %d case(s) failed\n", std::size(kScreens), std::size(kImages), failed);
printf("planted faults (each must fail):\n");
const Model planted[] = {
{ "no split (v0.1.1)", [](int32_t& lo, int32_t& hi, int32_t size) { lo = 0, hi = size; } },
{ "band one texel early", [](int32_t& lo, int32_t& hi, int32_t) { lo -= 1, hi -= 1; } },
{ "band one texel late", [](int32_t& lo, int32_t& hi, int32_t) { lo += 1, hi += 1; } },
{ "band one texel wider", [](int32_t& lo, int32_t& hi, int32_t) { lo -= 1, hi += 1; } },
{ "clamp to the span's edges", [](int32_t&, int32_t&, int32_t) {}, video::band::SpanOf,
[](float t, Span s) { return std::clamp(t, float(s.a), float(s.b)); } },
{ "no clamp of the gather", [](int32_t&, int32_t&, int32_t) {}, video::band::SpanOf, video::band::Clamp,
[](int32_t i, Span) { return i; } },
};
for (const Model& m : planted)
{
const int saved = g_failures;
g_print = false;
const int n = AllCases(m);
g_print = true;
g_failures = saved;
printf(" %-28s %d case(s) failed\n", m.name, n);
CHECK(n > 0, "planted \"%s\" passed every case", m.name);
}
if (g_failures)
{
printf("%d check(s) FAILED (%d in the band rows)\n", g_failures, bandFailures);
return 1;
}
printf("all checks passed\n");
return 0;
}
+311
View File
@@ -0,0 +1,311 @@
// Tests for platform/code_signature.h: the entitlements in a Mach-O's
// embedded code signature (the iOS app reads its own, kernel/memory_status.h),
// on Mach-O files built here byte by byte: thin and fat, XML and DER
// entitlements, an ad-hoc signature without any, unsigned, and every
// truncation and many corruptions of a good one (none may read out of
// bounds: build it once with -fsanitize=address too). From the repo root:
// clang++ -std=c++20 -O2 -Iruntime tests/code_signature_test.cpp runtime/platform/code_signature.cpp -o build/code_signature_test
// build/code_signature_test [a real executable, to print its entitlements]
#include <platform/code_signature.h>
#include <cstdio>
#include <fstream>
#include <iterator>
#include <random>
#include <string>
#include <vector>
using namespace platform::codesign;
static int g_failures = 0;
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
namespace
{
using Bytes = std::vector<uint8_t>;
constexpr const char* kVa = "com.apple.developer.kernel.extended-virtual-addressing";
constexpr const char* kMem = "com.apple.developer.kernel.increased-memory-limit";
void Be32(Bytes& b, uint32_t v)
{
for (int s = 24; s >= 0; s -= 8)
b.push_back(uint8_t(v >> s));
}
void Be64(Bytes& b, uint64_t v)
{
Be32(b, uint32_t(v >> 32));
Be32(b, uint32_t(v));
}
void Le32(Bytes& b, uint32_t v)
{
for (int s = 0; s < 32; s += 8)
b.push_back(uint8_t(v >> s));
}
void Append(Bytes& b, const std::string& s)
{
b.insert(b.end(), s.begin(), s.end());
}
Bytes Blob(uint32_t magic, const std::string& payload)
{
Bytes b;
Be32(b, magic);
Be32(b, uint32_t(8 + payload.size()));
Append(b, payload);
return b;
}
// A SuperBlob with these (slot, blob) entries, a CodeDirectory first as
// a real signature has.
Bytes SuperBlob(std::vector<std::pair<uint32_t, Bytes>> slots)
{
slots.insert(slots.begin(), { 0, Blob(0xFADE0C02, std::string(40, '\x11')) });
Bytes header, body;
uint32_t offset = uint32_t(12 + 8 * slots.size());
Bytes index;
for (auto& [type, blob] : slots)
{
Be32(index, type);
Be32(index, offset + uint32_t(body.size()));
body.insert(body.end(), blob.begin(), blob.end());
}
Be32(header, 0xFADE0CC0);
Be32(header, uint32_t(12 + index.size() + body.size()));
Be32(header, uint32_t(slots.size()));
header.insert(header.end(), index.begin(), index.end());
header.insert(header.end(), body.begin(), body.end());
return header;
}
// A thin arm64 executable: a header, a stand-in segment command,
// LC_CODE_SIGNATURE (unless `signature` is empty), some "code", then the
// signature, 16-byte aligned as ld puts it.
Bytes MachO(const Bytes& signature, uint32_t cpu = 0x0100000C)
{
bool sign = !signature.empty();
uint32_t ncmds = sign ? 2 : 1, sizeofcmds = 72 + (sign ? 16 : 0);
Bytes b;
Le32(b, 0xFEEDFACF);
Le32(b, cpu);
Le32(b, 0);
Le32(b, 2); // MH_EXECUTE
Le32(b, ncmds);
Le32(b, sizeofcmds);
Le32(b, 0);
Le32(b, 0);
Le32(b, 0x19); // LC_SEGMENT_64, contents unread
Le32(b, 72);
b.resize(b.size() + 64, 0);
uint32_t dataoff = 4096;
if (sign)
{
Le32(b, 0x1D);
Le32(b, 16);
Le32(b, dataoff);
Le32(b, uint32_t(signature.size()));
}
b.resize(dataoff, 0xCC);
b.insert(b.end(), signature.begin(), signature.end());
return b;
}
// A fat file of these slices (cpu type, contents), 32- or 64-bit headers.
Bytes Fat(const std::vector<std::pair<uint32_t, Bytes>>& slices, bool fat64 = false)
{
Bytes b;
Be32(b, fat64 ? 0xCAFEBABF : 0xCAFEBABE);
Be32(b, uint32_t(slices.size()));
uint64_t offset = 16384;
std::vector<uint64_t> offsets;
for (auto& [cpu, slice] : slices)
{
offsets.push_back(offset);
Be32(b, cpu);
Be32(b, 0);
if (fat64)
{
Be64(b, offset);
Be64(b, slice.size());
Be32(b, 14);
Be32(b, 0);
}
else
{
Be32(b, uint32_t(offset));
Be32(b, uint32_t(slice.size()));
Be32(b, 14);
}
offset += (slice.size() + 16383) / 16384 * 16384;
}
for (size_t i = 0; i < slices.size(); i++)
{
b.resize(offsets[i], 0);
b.insert(b.end(), slices[i].second.begin(), slices[i].second.end());
}
return b;
}
std::string Xml(const std::string& body)
{
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" "
"\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n" + body + "</dict>\n</plist>\n";
}
// DER as codesign writes it: [APPLICATION 16] { INTEGER 1, [CONTEXT 16]
// { SET of SEQUENCE { UTF8String key, value } } }, short lengths only.
std::string Der(const std::vector<std::pair<std::string, int>>& entries)
{
auto tlv = [](uint8_t tag, const std::string& v) { return std::string(1, char(tag)) + char(v.size()) + v; };
std::string set;
for (auto& [key, value] : entries)
{
std::string val = value < 0 ? tlv(0x0C, "text") : tlv(0x01, std::string(1, char(value ? 0xFF : 0x00)));
set += tlv(0x30, tlv(0x0C, key) + val);
}
return tlv(0x70, tlv(0x02, "\x01") + tlv(0xB0, tlv(0x31, set)));
}
const std::string kBoth = Xml(std::string("\t<key>application-identifier</key>\n\t<string>ABCDE12345.com.example.game</string>\n")
+ "\t<key>" + kVa + "</key>\n\t<true/>\n\t<key>" + kMem + "</key>\n\t<true/>\n");
}
int main(int argc, char** argv)
{
// Thin, XML: both entitlements.
{
Bytes file = MachO(SuperBlob({ { 5, Blob(0xFADE7171, kBoth) } }));
std::optional<Entitlements> e = FromFile(file);
CHECK(e && e->found && !e->der, "thin XML read");
CHECK(e && Lookup(*e, kVa) == Value::True, "extended VA true");
CHECK(e && Lookup(*e, kMem) == Value::True, "memory limit true");
CHECK(e && Lookup(*e, "com.apple.developer.kernel.extended-virtual") == Value::Absent, "a prefix isn't the key");
CHECK(e && Lookup(*e, "get-task-allow") == Value::Absent, "absent key");
CHECK(e && Lookup(*e, "application-identifier") == Value::Absent, "a string value isn't a boolean");
}
// XML: one false, one missing (what a tool that strips one would leave).
{
std::string xml = Xml(std::string("<key>") + kVa + "</key><false/>\n<key>get-task-allow</key>\n<true/>\n");
std::optional<Entitlements> e = FromFile(MachO(SuperBlob({ { 5, Blob(0xFADE7171, xml) } })));
CHECK(e && Lookup(*e, kVa) == Value::False, "false");
CHECK(e && Lookup(*e, kMem) == Value::Absent, "missing");
CHECK(e && Lookup(*e, "get-task-allow") == Value::True, "on its own line");
}
// Ad-hoc (the linker's): a CodeDirectory, no entitlements.
{
std::optional<Entitlements> e = FromFile(MachO(SuperBlob({})));
CHECK(e && !e->found, "signed without entitlements");
CHECK(e && Lookup(*e, kVa) == Value::Absent, "nothing to find");
}
// DER only.
{
std::string der = Der({ { "application-identifier", -1 }, { kVa, 1 }, { kMem, 0 } });
std::optional<Entitlements> e = FromFile(MachO(SuperBlob({ { 7, Blob(0xFADE7172, der) } })));
CHECK(e && e->found && e->der, "DER read");
CHECK(e && Lookup(*e, kVa) == Value::True, "DER true");
CHECK(e && Lookup(*e, kMem) == Value::False, "DER false");
CHECK(e && Lookup(*e, "application-identifier") == Value::Absent, "DER string isn't a boolean");
CHECK(e && Lookup(*e, "get-task-allow") == Value::Absent, "DER absent");
}
// Both: XML wins (whichever comes first in the index).
{
std::string der = Der({ { kVa, 0 } });
for (bool derFirst : { false, true })
{
std::vector<std::pair<uint32_t, Bytes>> slots = { { 5, Blob(0xFADE7171, kBoth) }, { 7, Blob(0xFADE7172, der) } };
if (derFirst)
std::swap(slots[0], slots[1]);
std::optional<Entitlements> e = FromFile(MachO(SuperBlob(slots)));
CHECK(e && !e->der && Lookup(*e, kVa) == Value::True, "XML preferred (DER first: %d)", int(derFirst));
}
}
// A blob of the wrong kind in the entitlements slot is skipped.
{
std::optional<Entitlements> e = FromFile(MachO(SuperBlob({ { 5, Blob(0xFADE0B01, kBoth) } })));
CHECK(e && !e->found, "wrong magic in slot 5");
}
// Unsigned: no LC_CODE_SIGNATURE.
CHECK(!FromFile(MachO({})), "unsigned");
// Not a Mach-O at all.
CHECK(!FromFile(Bytes(8192, 0x41)), "text");
CHECK(!FromFile(Bytes{}), "empty");
// 32-bit Mach-O magic isn't supported (iOS 11 dropped 32-bit).
{
Bytes file = MachO(SuperBlob({ { 5, Blob(0xFADE7171, kBoth) } }));
file[0] = 0xCE;
CHECK(!FromFile(file), "MH_MAGIC (32-bit)");
}
// Fat: the arm64 slice, wherever it is, with 32- and 64-bit headers.
{
Bytes arm = MachO(SuperBlob({ { 5, Blob(0xFADE7171, kBoth) } }));
Bytes other = MachO(SuperBlob({}), 0x01000007);
for (bool fat64 : { false, true })
{
std::optional<Entitlements> e = FromFile(Fat({ { 0x01000007, other }, { 0x0100000C, arm } }, fat64));
CHECK(e && Lookup(*e, kVa) == Value::True, "fat, arm64 second (64-bit header: %d)", int(fat64));
std::optional<Range> slice = MachOSlice(Fat({ { 0x0100000C, arm }, { 0x01000007, other } }, fat64), 1u << 20);
CHECK(slice && slice->offset == 16384 && slice->size == arm.size(), "fat, arm64 first (64-bit header: %d)", int(fat64));
}
std::optional<Entitlements> only = FromFile(Fat({ { 0x0100000C, arm } }));
CHECK(only && Lookup(*only, kMem) == Value::True, "fat with one slice");
CHECK(!FromFile(Fat({ { 0x01000007, other }, { 0x01000012, other } })), "fat without arm64");
Bytes bad = Fat({ { 0x0100000C, arm } });
bad[8 + 8] = 0x7F; // the slice's offset, far past the end
CHECK(!FromFile(bad), "fat slice out of the file");
}
// Every truncation, and random corruption, of a good file: no crash, and
// a truncation before the signature's end reads nothing.
{
Bytes good = MachO(SuperBlob({ { 5, Blob(0xFADE7171, kBoth) }, { 7, Blob(0xFADE7172, Der({ { kVa, 1 } })) } }));
int read = 0;
for (size_t n = 0; n < good.size(); n++)
{
Bytes cut(good.begin(), good.begin() + n);
std::optional<Entitlements> e = FromFile(cut);
read += e.has_value();
}
CHECK(read == 0, "truncated files read: %d", read);
std::mt19937 rng(1234);
int parsed = 0;
for (int round = 0; round < 20000; round++)
{
Bytes b = good;
int flips = 1 + int(rng() % 4);
for (int f = 0; f < flips; f++)
{
// Mostly the structure (header, commands, the SuperBlob's
// index and blob headers), sometimes anywhere.
size_t at = rng() % 2 ? rng() % 120 : rng() % 3 ? 4096 + rng() % 48 : rng() % b.size();
b[at % b.size()] = uint8_t(rng());
}
if (std::optional<Entitlements> e = FromFile(b))
{
parsed++;
Lookup(*e, kVa);
Lookup(*e, kMem);
}
}
printf(" corrupted files: %d of 20000 still parsed (no crash)\n", parsed);
}
// A real executable (manual: the iOS app's, signed by Xcode or a tool).
if (argc > 1)
{
std::ifstream in(argv[1], std::ios::binary);
Bytes file((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
std::optional<Entitlements> e = FromFile(file);
auto name = [](Value v) { return v == Value::True ? "true" : v == Value::False ? "false" : "absent"; };
if (!e)
printf("%s: no signature read\n", argv[1]);
else
printf("%s: entitlements %s (%s); extended-virtual-addressing %s, increased-memory-limit %s\n", argv[1],
e->found ? "found" : "none", e->der ? "DER" : "XML", name(Lookup(*e, kVa)), name(Lookup(*e, kMem)));
}
if (g_failures)
{
printf("code_signature_test: %d failure(s)\n", g_failures);
return 1;
}
printf("code_signature_test: all passed\n");
return 0;
}
+309 -1
View File
@@ -15,6 +15,8 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <iterator>
#include <utility>
// On Windows gpu/renderer.cpp includes the header after <windows.h>, whose
@@ -580,7 +582,8 @@ static Draw S2Draw(const Matrix& m, float sx, float sy, const float ps[4], float
static const char* Name(Fill f)
{
return f == Fill::RightBar ? "right bar" : f == Fill::LeftBar ? "left bar" : f == Fill::Blackout ? "black frame" : "none";
return f == Fill::RightBar ? "right bar" : f == Fill::LeftBar ? "left bar" : f == Fill::Blackout ? "black frame"
: f == Fill::Backdrop ? "backdrop" : "none";
}
// Runs PlanFill over the ePolys; the reader fails for vertices from
@@ -623,6 +626,309 @@ static void PlanCase(const char* what, const Draw& d, const EPolys& e, Fill want
CHECK(p.argb == e.w[0][3], "%s: colour %08X, want %08X", what, p.argb, e.w[0][3]);
}
// ---- The full-band backdrop (the results screen after a race) ----
// Its box in band units: x +-1.104, y +-0.981 (the title's key art has the
// same, logged as an inset candidate); opaque, textured (1024x512).
constexpr float kBackdropX = 1.104f, kBackdropY = 0.981f;
constexpr uint32_t kBackdropColour = 0xFF7F7F7F;
static void MatchBack(const char* what, const Quad& q, float sx, float sy, bool want, const BackdropRule& rule)
{
Box box{};
const bool got = MatchBackdrop(q.v, sx, sy, rule, &box);
CHECK(got == want, "%s at x %.4f y %.4f: %s, want %s", what, sx, sy, got ? "backdrop" : "none", want ? "backdrop" : "none");
}
// Every backdrop match case. Returns the failures it added (for planting).
static int BackdropCases(const BackdropRule& rule)
{
const int before = g_failures;
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); };
MatchBack("results backdrop", at(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, kBackdropColour), s.sx, s.sy, true, rule);
MatchBack("results backdrop, alpha F0", at(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 0xF07F7F7F), s.sx, s.sy, true, rule);
MatchBack("a picture over the whole band", at(-1.0f, 1.0f, -1.0f, 1.0f, 0xFF808080), s.sx, s.sy, true, rule);
MatchBack("a picture 0.96 of the band's height", at(-1.0f, 1.0f, -0.96f, 0.96f, 0xFF808080), s.sx, s.sy, true, rule);
// What must show the world (or isn't such a picture).
MatchBack("~57 s overlay (B8)", at(-1.104f, 1.104f, -0.978f, 0.985f, 0xB8000000), s.sx, s.sy, false, rule);
MatchBack("a translucent picture (alpha EF)", at(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 0xEF7F7F7F), s.sx, s.sy, false, rule);
MatchBack("fade", at(-1.29f, 1.29f, -1.30f, 1.30f, 0xFF000000), s.sx, s.sy, false, rule);
MatchBack("loading band", at(-1.29f, 1.29f, -0.30f, 0.30f, 0xFF000000), s.sx, s.sy, false, rule);
MatchBack("black frame", at(-kFrameX, kFrameX, -kFrameY, kFrameY, kFrameColour), s.sx, s.sy, false, rule);
MatchBack("black frame at ~57 s", at(-kFrameX, kFrameX, -kFrameY57, kFrameY57, 0xFE000000), s.sx, s.sy, false, rule);
MatchBack("noise overlay", at(-0.692f, 0.709f, -1.058f, 1.050f, 0x3C727272), s.sx, s.sy, false, rule);
MatchBack("noise overlay shape, opaque", at(-0.692f, 0.709f, -1.058f, 1.050f, 0xFF727272), s.sx, s.sy, false, rule);
MatchBack("pause dim centre", at(-0.53f, 0.57f, -1.91f, 1.91f, 0xC8000000), s.sx, s.sy, false, rule);
MatchBack("pause dim shape, opaque", at(-0.53f, 0.57f, -1.91f, 1.91f, 0xFF000000), s.sx, s.sy, false, rule);
MatchBack("cam dialog box", at(-0.578f, 0.586f, -0.167f, 0.569f, 0xFD000000), s.sx, s.sy, false, rule);
MatchBack("right bar", at(0.651f, 1.406f, -1.024f, 1.039f, 0xFD000000), s.sx, s.sy, false, rule);
MatchBack("minimap piece", at(-0.73f, -0.46f, -1.02f, -0.53f, 0xFE7F7F7F), s.sx, s.sy, false, rule);
MatchBack("half-height picture", at(-1.104f, 1.104f, -0.5f, 0.5f, kBackdropColour), s.sx, s.sy, false, rule);
MatchBack("a picture off-centre by 0.05", at(-kBackdropX + 0.05f, kBackdropX + 0.05f, -kBackdropY, kBackdropY, kBackdropColour), s.sx,
s.sy, false, rule);
MatchBack("a picture 0.94 of the band's height", at(-1.104f, 1.104f, -0.94f, 0.94f, kBackdropColour), s.sx, s.sy, false, rule);
MatchBack("a picture 0.98 of the band's width", at(-0.98f, 0.98f, -1.0f, 1.0f, kBackdropColour), s.sx, s.sy, false, rule);
MatchBack("a picture to 1.25", at(-1.25f, 1.25f, -1.0f, 1.0f, kBackdropColour), s.sx, s.sy, false, rule);
// Not one rectangle: a vertex off its corner.
Quad bent = at(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, kBackdropColour);
bent.v[2].x -= 0.05f;
MatchBack("a bent quad", bent, s.sx, s.sy, false, rule);
}
return g_failures - before;
}
static void PlantedBackdrop(const char* what, const BackdropRule& wrong)
{
const int saved = g_failures;
g_quiet = true;
const int failed = BackdropCases(wrong);
g_quiet = false;
g_failures = saved;
printf("planted %s: %d case(s) failed\n", what, failed);
CHECK(failed > 0, "planted %s passed every backdrop case", what);
}
// The backdrop's fill against pixel centres: every pixel of the scissor
// past the band and outside the box is filled, exactly once; nothing in the
// band or the box is (ties within 1/256 pixel skipped). `rects` makes the
// rectangles (BackdropRects, or a planted fault). Returns the wrong pixels.
using BackdropRectsFn = uint32_t (*)(const Box&, const float*, float, float, const Rect&, float, float, Rect*);
static int BackdropOracle(const char* what, float sx, float sy, const Box& b, const float ps[4], float hostW, float hostH, const Rect& scissor,
BackdropRectsFn rects, bool print = true)
{
Rect r[6]{};
const uint32_t n = rects(b, 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;
int wrong = 0, ties = 0;
for (int32_t y = scissor.y0; y < scissor.y1; y++)
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 float cy = ((float(y) + 0.5f) / hostH * 2.0f - 1.0f - ps[3]) / ps[1];
auto tie = [](float c, float e, float px) { return std::fabs(c - e) * px < 1.0f / 256.0f; };
if (tie(std::fabs(cx), sx, pxX) || tie(std::fabs(cy), sy, pxY) || tie(cx, b.x0, pxX) || tie(cx, b.x1, pxX) || tie(cy, b.y0, pxY) ||
tie(cy, b.y1, pxY))
{
ties++;
continue;
}
// The band: the columns inside +-sx (Hor+) or the rows inside
// +-sy (Vert+), all of the other axis (at 2x with half-pixel
// centres the first row's centre is past clip 1, still the band).
const bool inBand = (sx >= 1.0f || std::fabs(cx) < sx) && (sy >= 1.0f || std::fabs(cy) < sy);
const bool inBox = cx > b.x0 && cx < b.x1 && cy > b.y0 && cy < b.y1;
int filled = 0;
for (uint32_t i = 0; i < n; i++)
filled += x >= r[i].x0 && x < r[i].x1 && y >= r[i].y0 && y < r[i].y1;
if (filled != ((inBand || inBox) ? 0 : 1))
{
if (print && wrong < 3 && std::getenv("EDGE_BARS_DEBUG"))
printf(" wrong px %d,%d: clip %.5f %.5f, band %d box %d filled %d\n", x, y, cx, cy, inBand, inBox, filled);
wrong++;
}
}
if (print)
{
printf("backdrop fill %s: %u rect(s)", what, n);
for (uint32_t i = 0; i < n; i++)
printf(" [%d,%d)x[%d,%d)", r[i].x0, r[i].x1, r[i].y0, r[i].y1);
printf(", %d pixel(s) wrong (%d on an edge, skipped)\n", wrong, ties);
CHECK(wrong == 0, "%s: %d pixels filled wrongly (in the band or the box, twice, or left)", what, wrong);
}
return wrong;
}
// The backdrop's draw: S2Draw's registers over its 1024x512 picture.
static Draw BackdropDraw(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 })
{
Draw d = S2Draw(m, sx, sy, ps, hostW, hostH, scissor);
d.t0Width = 1024;
d.t0Height = 512;
return d;
}
static void BackdropPlanCase(const char* what, const Draw& d, const EPolys& e, Fill want, uint32_t wantN = 0, const Rect* wantRects = nullptr)
{
const Plan p = PlanBackdrop(d, [&](uint32_t k, uint32_t j, uint32_t& word) {
word = e.w[k & 3][j & 3];
return k < 4;
});
printf("backdrop 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);
}
static void BackdropTests()
{
printf("\nbackdrop:\n");
BackdropCases(BackdropRule{});
printf("backdrop match cases at %zu screen shapes checked\n", std::size(kShapes));
printf("\nplanted, backdrop (each must fail a case):\n");
auto plant = [](const char* what, auto set) { BackdropRule r; set(r); PlantedBackdrop(what, r); };
plant("centreMax 0.06", [](BackdropRule& r) { r.centreMax = 0.06f; });
plant("extentMinX 0.97", [](BackdropRule& r) { r.extentMinX = 0.97f; });
plant("extentMinX 1.11", [](BackdropRule& r) { r.extentMinX = 1.11f; });
plant("extentMinY 0.93", [](BackdropRule& r) { r.extentMinY = 0.93f; });
plant("extentMinY 0.99", [](BackdropRule& r) { r.extentMinY = 0.99f; });
plant("extentMax 1.10", [](BackdropRule& r) { r.extentMax = 1.10f; });
plant("extentMax 1.30", [](BackdropRule& r) { r.extentMax = 1.30f; });
plant("minAlpha B0", [](BackdropRule& r) { r.minAlpha = 0xB0; });
plant("minAlpha F1", [](BackdropRule& r) { r.minAlpha = 0xF1; });
plant("cornerTolerance 0.1", [](BackdropRule& r) { r.cornerTolerance = 0.1f; });
printf("\nbackdrop fill:\n");
float ps[4], half[4], flipped[4], tiled[4];
FrontEndPs(ps);
FrontEndPs(half, 640.5f, 360.5f);
FrontEndPs(flipped, 640.0f, 360.0f, 360.0f);
FrontEndPs(tiled, 640.0f, 360.0f - 256.0f);
const Rect frame{ 0, 0, 1280, 720 }, frame2{ 0, 0, 2560, 1440 };
struct Case
{
const char* name;
float sx, sy;
};
const Case cases[] = { { "2560x1080", 0.75f, 1.0f }, { "2868x1320 (iPhone 18 Pro Max)", 0.818182f, 1.0f }, { "3440x1440", 0.744186f, 1.0f },
{ "32:9", 0.5f, 1.0f }, { "1.95:1 (the picture past the screen edge)", 0.912f, 1.0f }, { "1280x800", 1.0f, 0.9f },
{ "1024x768", 1.0f, 0.75f }, { "1200x800", 1.0f, 0.84375f } };
int wrong = 0;
for (const Case& c : cases)
{
const Box results{ -kBackdropX * c.sx, kBackdropX * c.sx, -kBackdropY * c.sy, kBackdropY * c.sy };
const Box full{ -c.sx, c.sx, -c.sy, c.sy };
char what[160];
snprintf(what, sizeof(what), "%s, results, 1x", c.name);
wrong += BackdropOracle(what, c.sx, c.sy, results, ps, 1280, 2048, frame, BackdropRects);
snprintf(what, sizeof(what), "%s, results, 2x, half-pixel", c.name);
wrong += BackdropOracle(what, c.sx, c.sy, results, half, 2560, 4096, frame2, BackdropRects);
snprintf(what, sizeof(what), "%s, results, y flipped", c.name);
wrong += BackdropOracle(what, c.sx, c.sy, results, flipped, 1280, 2048, frame, BackdropRects);
snprintf(what, sizeof(what), "%s, results, tile at y -256", c.name);
wrong += BackdropOracle(what, c.sx, c.sy, results, tiled, 1280, 2048, Rect{ 0, 0, 1280, 464 }, BackdropRects);
snprintf(what, sizeof(what), "%s, a picture over the band", c.name);
wrong += BackdropOracle(what, c.sx, c.sy, full, ps, 1280, 2048, frame, BackdropRects);
snprintf(what, sizeof(what), "%s, results, game scissor", c.name);
wrong += BackdropOracle(what, c.sx, c.sy, results, ps, 1280, 2048, Rect{ 100, 10, 1180, 700 }, BackdropRects);
}
printf("backdrop fill: %d wrong pixel(s) over %zu shapes\n", wrong, std::size(cases));
{
// The rectangles themselves, 2868x1320 at 1x: columns past the
// picture's sides (x +-1.104 band = 0.9033 clip: from column 1218,
// to 62), and the corners past the band (0.8182 clip: column 1164)
// above and below it (y +-0.981: rows 0..6 and 713..719).
Rect r[6]{};
const Box results{ -kBackdropX * 0.818182f, kBackdropX * 0.818182f, -kBackdropY, kBackdropY };
const uint32_t n = BackdropRects(results, ps, 1280, 2048, frame, 0.818182f, 1.0f, r);
const Rect want[6] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
{ 62, 713, 116, 720 } };
CHECK(n == 6, "iPhone: %u rects, want 6", n);
for (uint32_t i = 0; i < n && i < 6; i++)
CHECK(Same(r[i], want[i]), "iPhone: rect %u [%d,%d)x[%d,%d), want [%d,%d)x[%d,%d)", i, r[i].x0, r[i].x1, r[i].y0, r[i].y1, want[i].x0,
want[i].x1, want[i].y0, want[i].y1);
// Vert+: the rows above and below the band, whole (the picture stops
// inside it in y and past the screen in x).
const Box r43{ -kBackdropX, kBackdropX, -kBackdropY * 0.75f, kBackdropY * 0.75f };
const uint32_t n43 = BackdropRects(r43, ps, 1280, 2048, frame, 1.0f, 0.75f, r);
CHECK(n43 == 2 && Same(r[0], Rect{ 0, 0, 1280, 90 }) && Same(r[1], Rect{ 0, 630, 1280, 720 }), "4:3: %u rects [%d,%d)x[%d,%d) ...", n43,
r[0].x0, r[0].x1, r[0].y0, r[0].y1);
// 16:9: nothing.
const Box r169{ -kBackdropX, kBackdropX, -kBackdropY, kBackdropY };
CHECK(BackdropRects(r169, ps, 1280, 2048, frame, 1.0f, 1.0f, r) == 0, "16:9: a backdrop fills nothing");
}
printf("\nplanted, backdrop fill (each must fail):\n");
{
auto run = [&](const char* what, BackdropRectsFn f) {
int bad = 0;
for (const Case& c : cases)
{
const Box results{ -kBackdropX * c.sx, kBackdropX * c.sx, -kBackdropY * c.sy, kBackdropY * c.sy };
bad += BackdropOracle(what, c.sx, c.sy, results, ps, 1280, 2048, frame, f, false);
bad += BackdropOracle(what, c.sx, c.sy, results, half, 2560, 4096, frame2, f, false);
}
printf("planted %s: %d wrong pixel(s)\n", what, bad);
CHECK(bad > 0, "planted %s: no wrong pixel", what);
};
run("the whole strips (the black frame's fill)", [](const Box&, const float* ps, float w, float h, const Rect& sc, float sx, float sy, Rect* out) {
return BlackoutRects(ps, w, h, sc, sx, sy, out);
});
run("past the picture only (no corners)", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy, Rect* out) {
Rect r[6]{};
const uint32_t n = BackdropRects(b, ps, w, h, sc, sx, sy, r);
uint32_t m = 0;
for (uint32_t i = 0; i < n; i++)
if (r[i].y1 - r[i].y0 > 100 || sy < 1.0f)
out[m++] = r[i];
return m;
});
run("from the band's edge, under the picture", [](const Box& b, const float* ps, float w, float h, const Rect& sc, float sx, float sy, Rect* out) {
Box wider = b;
wider.x0 = std::max(b.x0, -sx);
wider.x1 = std::min(b.x1, sx);
return BackdropRects(wider, ps, w, h, sc, sx, sy, out);
});
}
printf("\nbackdrop plan (the draw as the renderer hands it over):\n");
{
const Matrix iphone = FrontEndMatrix(0.818182f, 1.0f), deck = FrontEndMatrix(1.0f, 0.9f), m169 = FrontEndMatrix(1.0f, 1.0f);
const EPolys results = Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 0.818182f, 1.0f, kBackdropColour), iphone);
const Draw d = BackdropDraw(iphone, 0.818182f, 1.0f, ps);
const Rect want[6] = { { 1218, 0, 1280, 720 }, { 1164, 0, 1218, 7 }, { 1164, 713, 1218, 720 }, { 0, 0, 62, 720 }, { 62, 0, 116, 7 },
{ 62, 713, 116, 720 } };
BackdropPlanCase("iPhone results", d, results, Fill::Backdrop, 6, want);
const Rect deckWant[2] = { { 0, 0, 1280, 36 }, { 0, 684, 1280, 720 } };
BackdropPlanCase("Deck results", BackdropDraw(deck, 1.0f, 0.9f, ps),
Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 0.9f, kBackdropColour), deck), Fill::Backdrop, 2, deckWant);
// Never at 16:9 (nothing past the band to fill).
BackdropPlanCase("16:9 results", BackdropDraw(m169, 1.0f, 1.0f, ps),
Polys(Rectangle(-kBackdropX, kBackdropX, -kBackdropY, kBackdropY, 1.0f, 1.0f, kBackdropColour), m169), Fill::None);
// A flat full-band layer over the 32x32 white texture is not a
// picture (the black frame's and the fades' kind).
Draw flat = d;
flat.t0Width = flat.t0Height = 32;
BackdropPlanCase("over the white texture", flat, results, Fill::None);
// A translucent picture: vertex 0's alpha refuses before the rest.
EPolys faded = results;
for (auto& w : faded.w)
w[3] = 0xB87F7F7F;
BackdropPlanCase("alpha B8", d, faded, Fill::None);
auto refuse = [&](const char* what, auto change) {
Draw x = d;
Matrix m = iphone;
change(x, m);
x.c = m.c;
BackdropPlanCase(what, x, results, Fill::None);
};
refuse("taken by another rule", [](Draw& x, Matrix&) { x.leftAlone = false; });
refuse("not the front-end shader", [](Draw& x, Matrix&) { x.frontEndShader = false; });
refuse("inside an occlusion query", [](Draw& x, Matrix&) { x.occlusion = true; });
refuse("two quads", [](Draw& x, Matrix&) { x.count = 8; });
refuse("additive blend", [](Draw& x, Matrix&) { x.blend0 = 0x01060106; });
refuse("t0 not sampled", [](Draw& x, Matrix&) { x.t0Sampled = false; });
refuse("t0 64 high", [](Draw& x, Matrix&) { x.t0Height = 64; });
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 y offset off", [](Draw& x, Matrix&) { x.vte = 0x437; });
Draw opaque = d;
opaque.blend0 = 0x00010001;
BackdropPlanCase("blending off", opaque, results, Fill::Backdrop, 6, want);
// PlanFill (bars and black frames) never takes it: the renderer asks
// PlanBackdrop only when PlanFill found nothing.
CHECK(RunPlan(d, results).fill == Fill::None, "PlanFill took the backdrop");
}
}
int main()
{
printf("match:\n");
@@ -983,6 +1289,8 @@ int main()
CHECK(reads == 17, "a bar: %d reads, want 17", reads);
}
BackdropTests();
printf("\n%s (%d failure%s)\n", g_failures ? "FAILED" : "all passed", g_failures, g_failures == 1 ? "" : "s");
return g_failures ? 1 : 0;
}
+186
View File
@@ -0,0 +1,186 @@
// Tests for game::glare (runtime/game/glare.h): the sun's glow, a square of
// frame pixels the game builds around the sun, fitted to the view scales so
// that on a screen of any shape (video::FitPicture's picture) it is still a
// square, at the game's angle, in the same place, and as large against the
// 16:9 band as at 16:9. From the repo root:
// clang++ -std=c++20 -O2 -UNDEBUG -Iruntime tests/glare_fit_test.cpp runtime/video/picture_fit.cpp -o build/glare_fit_test
// build/glare_fit_test
// The shape checks run again with planted wrong fits (none, the inverse
// scale, the axes swapped, about the frame's corner instead of the centre):
// each must fail at least one case.
#include <game/glare.h>
#include <video/picture_fit.h>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <functional>
#include <iterator>
static int g_failures = 0;
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; if (g_print) { printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } } while (0)
static bool g_print = true;
using Fit = std::function<void(float x[4], float y[4], float scaleX, float scaleY)>;
// The glow's quad as sub_822251C8 builds it: centre (cx, cy), "radius" r
// (its size times the table's factor), angle t: a = r (cos + sin),
// b = r (cos - sin); corners (cx - a, cy - b), (cx + b, cy - a),
// (cx + a, cy + b), (cx - b, cy + a). A square, its first
// side at angle t, 2r long.
static void Build(float cx, float cy, float r, float t, float x[4], float y[4])
{
const float s = std::sin(t), c = std::cos(t), a = r * (c + s), b = r * (c - s);
const float px[4] = { cx - a, cx + b, cx + a, cx - b }, py[4] = { cy - b, cy - a, cy + b, cy + a };
for (int i = 0; i < 4; i++)
x[i] = px[i], y[i] = py[i];
}
struct Screen
{
uint32_t w, h;
const char* name;
};
constexpr Screen kScreens[] = {
{ 1280, 720, "16:9 1280x720" },
{ 1920, 1080, "16:9 1920x1080" },
{ 1366, 768, "1366x768 (within 1% of 16:9: letterboxed)" },
{ 1280, 800, "Steam Deck 16:10" },
{ 1200, 800, "3:2" },
{ 1024, 768, "4:3" },
{ 2732, 2048, "iPad Pro" },
{ 1280, 1024, "5:4 (a 4:3 picture, letterboxed)" },
{ 2560, 1080, "ultrawide 2560x1080" },
{ 3440, 1440, "ultrawide 3440x1440" },
{ 2796, 1290, "iPhone 15 Pro Max" },
{ 3840, 1080, "32:9" },
};
// One glow on one screen: fitted by `fit`, then stretched as the presenter
// stretches the frame (x * picture width / 1280, y * picture height / 720).
// Returns the failures it added.
static int Case(const Fit& fit, const Screen& s, float cx, float cy, float r, float t)
{
const int before = g_failures;
const video::PictureFit p = video::FitPicture(s.w, s.h, 1280.0f, 720.0f, true, true);
float x[4], y[4];
Build(cx, cy, r, t, x, y);
fit(x, y, p.scaleX, p.scaleY);
const float kx = float(p.width) / 1280.0f, ky = float(p.height) / 720.0f;
float X[4], Y[4];
for (int i = 0; i < 4; i++)
X[i] = x[i] * kx, Y[i] = y[i] * ky;
// A square on the screen: four equal sides, equal diagonals.
float side[4];
for (int i = 0; i < 4; i++)
side[i] = std::hypot(X[(i + 1) & 3] - X[i], Y[(i + 1) & 3] - Y[i]);
const float d0 = std::hypot(X[2] - X[0], Y[2] - Y[0]), d1 = std::hypot(X[3] - X[1], Y[3] - Y[1]);
const float tol = 1e-3f * side[0];
for (int i = 1; i < 4; i++)
CHECK(std::fabs(side[i] - side[0]) <= tol, "%s, angle %.2f: side %d is %.3f px, side 0 %.3f px (not a square)", s.name, t, i, side[i], side[0]);
CHECK(std::fabs(d0 - d1) <= tol, "%s, angle %.2f: diagonals %.3f and %.3f px (not a square)", s.name, t, d0, d1);
// Its height over its width (the v0.1.1 report's measure): 1.
const float w = std::fmax(std::fmax(X[0], X[1]), std::fmax(X[2], X[3])) - std::fmin(std::fmin(X[0], X[1]), std::fmin(X[2], X[3]));
const float h = std::fmax(std::fmax(Y[0], Y[1]), std::fmax(Y[2], Y[3])) - std::fmin(std::fmin(Y[0], Y[1]), std::fmin(Y[2], Y[3]));
CHECK(std::fabs(h / w - 1.0f) <= 1e-3f, "%s, angle %.2f: h/w %.4f", s.name, t, h / w);
// At the game's angle.
const float angle = std::atan2(Y[1] - Y[0], X[1] - X[0]), want = std::atan2(-(r * (std::cos(t) + std::sin(t))) + r * (std::cos(t) - std::sin(t)),
r * (std::cos(t) - std::sin(t)) + r * (std::cos(t) + std::sin(t)));
CHECK(std::fabs(std::remainder(angle - want, 6.28318531f)) <= 1e-3f, "%s, angle %.2f: first side at %.4f rad, want %.4f", s.name, t, angle, want);
// Where the game put it: the sun's projected point.
const float mx = (X[0] + X[1] + X[2] + X[3]) * 0.25f, my = (Y[0] + Y[1] + Y[2] + Y[3]) * 0.25f;
CHECK(std::fabs(mx - cx * kx) <= 1e-3f && std::fabs(my - cy * ky) <= 1e-3f, "%s: centre (%.3f, %.3f), want (%.3f, %.3f)", s.name, mx, my, cx * kx, cy * ky);
// As large against the 16:9 band as on a 16:9 screen: the band is the
// picture's width under Vert+ (or letterboxed), its height x 16/9 under
// Hor+; a side of 2r frame pixels (|v1 - v0| = sqrt(2 a^2 + 2 b^2)) of
// 1280.
const float bandW = std::fmin(float(p.width), float(p.height) * 16.0f / 9.0f);
const float wantSide = 2.0f * r * bandW / 1280.0f;
CHECK(std::fabs(side[0] - wantSide) <= 1e-3f * wantSide, "%s: side %.3f px, want %.3f (the 16:9 band's share)", s.name, side[0], wantSide);
return g_failures - before;
}
static int AllCases(const Fit& fit)
{
int failed = 0;
for (const Screen& s : kScreens)
for (float t : { 0.0f, 0.3f, 0.785398f, 1.2f, 2.5f })
failed += Case(fit, s, 431.0f, 118.0f, 37.5f, t) ? 1 : 0;
return failed;
}
int main()
{
printf("The glow, fitted (game::glare::FitSprite), on %zu screens x 5 angles:\n", std::size(kScreens));
for (const Screen& s : kScreens)
{
const video::PictureFit p = video::FitPicture(s.w, s.h, 1280.0f, 720.0f, true, true);
printf(" %-44s picture %dx%d, view scale x %.4f y %.4f\n", s.name, p.width, p.height, p.scaleX, p.scaleY);
}
const int fitted = AllCases(game::glare::FitSprite);
printf("fitted: %d case(s) failed\n", fitted);
// On 16:9 (and on a screen the presenter letterboxes) the fit is the
// identity: the corners come back bit for bit.
{
float x[4], y[4], x0[4], y0[4];
Build(640.25f, 77.5f, 41.0f, 0.7f, x, y);
for (int i = 0; i < 4; i++)
x0[i] = x[i], y0[i] = y[i];
game::glare::FitSprite(x, y, 1.0f, 1.0f);
bool same = true;
for (int i = 0; i < 4; i++)
same = same && x[i] == x0[i] && y[i] == y0[i];
CHECK(same, "the fit at scale 1, 1 moved a corner");
}
// The view set the projection hook widens, and the ones it doesn't.
for (uint32_t id : { 1u, 2u, 4u, 5u, 17u })
CHECK(game::glare::WidenedViewId(id), "view %u must be widened", id);
for (uint32_t id : { 0u, 3u, 6u, 13u, 14u, 15u, 16u, 18u, 19u, 20u, 21u, 22u, 23u })
CHECK(!game::glare::WidenedViewId(id), "view %u must not be widened", id);
CHECK(game::glare::kGlowCall != game::glare::kOcclusionQuadCall, "the two call sites");
// Planted: wrong fits must each fail.
printf("planted wrong fits (each must fail):\n");
struct Planted
{
const char* name;
Fit fit;
};
const Planted planted[] = {
{ "no fit (v0.1.1)", [](float*, float*, float, float) {} },
{ "1/scale (stretches it twice)", [](float* x, float* y, float sx, float sy) { game::glare::FitSprite(x, y, 1.0f / sx, 1.0f / sy); } },
{ "axes swapped", [](float* x, float* y, float sx, float sy) { game::glare::FitSprite(x, y, sy, sx); } },
{ "y only (no Hor+)", [](float* x, float* y, float, float sy) { game::glare::FitSprite(x, y, 1.0f, sy); } },
{ "x only (no Vert+)", [](float* x, float* y, float sx, float) { game::glare::FitSprite(x, y, sx, 1.0f); } },
{ "about the frame's corner", [](float* x, float* y, float sx, float sy) {
for (int i = 0; i < 4; i++)
x[i] *= sx, y[i] *= sy;
} },
{ "about the first corner", [](float* x, float* y, float sx, float sy) {
const float ox = x[0], oy = y[0];
for (int i = 0; i < 4; i++)
x[i] = ox + (x[i] - ox) * sx, y[i] = oy + (y[i] - oy) * sy;
} },
};
for (const Planted& p : planted)
{
const int saved = g_failures;
g_print = false;
const int failed = AllCases(p.fit);
g_print = true;
g_failures = saved;
printf(" %-32s %d case(s) failed\n", p.name, failed);
CHECK(failed > 0, "planted \"%s\" passed every case", p.name);
}
if (g_failures)
{
printf("%d check(s) FAILED\n", g_failures);
return 1;
}
printf("all checks passed\n");
return 0;
}
+298
View File
@@ -0,0 +1,298 @@
// video::ChooseGpu and GpuLogLine (runtime/video/gpu_choice.h): the Vulkan
// device the presenter draws with, and the log's line about it. Header only;
// built by tests/CMakeLists.txt (-DSB_TESTS=ON), or from the repo root:
// clang++ -std=c++20 -O2 -Iruntime tests/gpu_choice_test.cpp -o build/gpu_choice_test
// 1. The devices we ship on, as their logs name them (one GPU each): that GPU,
// the loader's first, as up to v0.1.1 ("the only one").
// 2. Laptops with two GPUs: Intel + NVIDIA (PR #6's i9-13900H + RTX 3050, in
// either order), AMD integrated + NVIDIA, AMD + AMD; an NVIDIA GPU alone; a
// lone integrated GPU.
// 3. A software renderer (llvmpipe) beside a real GPU, in either order; Mesa's
// Dozen after native drivers; two discrete GPUs: the one with more
// device-local memory, the loader's first when equal.
// 4. A device that can't present is never chosen; one without an extension
// the game needs comes after those with all.
// 5. NFSMW_GPU: an index, part of a name in any case, a number that is part
// of a name, a device that can't present, no match, empty.
// 6. The log line, word for word.
// 7. Against an independent ranking over 20,000 random lists, and the rule
// before v0.1.2 (the loader's first that can present) wherever exactly one
// device can present.
#include <video/gpu_choice.h>
#include <cstdio>
#include <random>
#include <tuple>
static int g_failures = 0;
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
using video::ChooseGpu;
using video::GpuCandidate;
using video::GpuChoice;
using video::kNoGpu;
namespace
{
constexpr int kOther = 0, kIntegrated = 1, kDiscrete = 2, kVirtual = 3, kCpu = 4;
constexpr uint64_t GB = 1ull << 30;
GpuCandidate Dev(size_t index, int type, const char* name, uint64_t bytes, bool presents = true, const char* lacks = "",
bool layered = false)
{
return { index, type, layered, name, bytes, presents, lacks };
}
// The rule up to v0.1.1: the loader's first device that can present.
size_t OldRule(const std::vector<GpuCandidate>& list)
{
for (size_t i = 0; i < list.size(); i++)
if (list[i].presents)
return i;
return kNoGpu;
}
std::string Picked(const std::vector<GpuCandidate>& list, const char* want = nullptr)
{
GpuChoice c = ChooseGpu(list, want);
printf(" %-8s -> %s\n", want ? want : "-", video::GpuLogLine(list, c).c_str());
return c.pick == kNoGpu ? std::string("(none)") : list[c.pick].name;
}
// An independent ranking (not GpuBefore): a key per device, smallest wins.
size_t Reference(const std::vector<GpuCandidate>& list)
{
auto key = [](const GpuCandidate& c) {
static const int order[] = { /*other*/ 3, /*integrated*/ 1, /*discrete*/ 0, /*virtual*/ 2, /*cpu*/ 4 };
int kind = (c.type >= 0 && c.type <= 4) ? order[c.type] : 3;
return std::make_tuple(c.lacks.empty() ? 0 : 1, c.layered ? 1 : 0, kind, ~c.localBytes, c.index);
};
size_t best = kNoGpu;
for (size_t i = 0; i < list.size(); i++)
if (list[i].presents && (best == kNoGpu || key(list[i]) < key(list[best])))
best = i;
return best;
}
}
int main()
{
printf("1. the devices we ship on (from their logs' [video] and GPU: lines)\n");
{
const std::vector<std::vector<GpuCandidate>> shipped = {
{ Dev(0, kIntegrated, "AMD Custom GPU 0932 (RADV VANGOGH)", 1 * GB) }, // Steam Deck OLED
{ Dev(0, kIntegrated, "AMD Custom GPU 0405 (RADV VANGOGH)", 1 * GB) }, // Steam Deck LCD
{ Dev(0, kDiscrete, "AMD Steam Machine (RADV NAVI33)", 8 * GB) }, // Steam Machine
{ Dev(0, kIntegrated, "Turnip Adreno (TM) 750", 15 * GB) }, // Steam Frame
{ Dev(0, kIntegrated, "Apple M1 Pro", 10 * GB) }, // MacBook Pro
{ Dev(0, kIntegrated, "Apple M4 Max", 27 * GB) }, // Mac Studio
{ Dev(0, kIntegrated, "Apple Paravirtual device", 8 * GB) }, // macOS 15 VM
{ Dev(0, kIntegrated, "Apple M2 GPU", 7 * GB) }, // iPad Pro M2
{ Dev(0, kIntegrated, "Apple A17 Pro GPU", 5 * GB) }, // iPhone 15 Pro Max
{ Dev(0, kDiscrete, "NVIDIA GeForce RTX 4070", 12 * GB) }, // issue #2's desktops
{ Dev(0, kDiscrete, "NVIDIA GeForce GTX 1080 Ti", 11 * GB) },
{ Dev(0, kIntegrated, "Intel(R) Graphics (ARL)", 15 * GB) }, // issue #2's Intel laptop
{ Dev(0, kIntegrated, "Intel(R) UHD Graphics", 3 * GB) }, // the Windows test ThinkPad
};
for (const auto& one : shipped)
{
GpuChoice c = ChooseGpu(one, nullptr);
CHECK(c.pick == 0 && c.pick == OldRule(one), "%s: picked %zu", one[0].name.c_str(), c.pick);
CHECK(c.why == "the only one that can present to the window", "%s: %s", one[0].name.c_str(), c.why.c_str());
std::string line = video::GpuLogLine(one, c);
CHECK(line.find("NFSMW_GPU") == std::string::npos && line.find("loader listed") == std::string::npos,
"one device: no hint, no order note: %s", line.c_str());
Picked(one);
}
// The Steam Machine's own log line, word for word (8.0 GB: RADV's VRAM heaps added up).
CHECK(video::GpuLogLine(shipped[2], ChooseGpu(shipped[2], nullptr)) ==
"Vulkan devices: 0 AMD Steam Machine (RADV NAVI33) (discrete, 8.0 GB); using 0: the only one that can present to the window",
"%s", video::GpuLogLine(shipped[2], ChooseGpu(shipped[2], nullptr)).c_str());
}
printf("2. laptops with two GPUs\n");
const std::vector<GpuCandidate> pr6 = { Dev(0, kIntegrated, "Intel(R) Iris(R) Xe Graphics (RPL-P)", 15 * GB),
Dev(1, kDiscrete, "NVIDIA GeForce RTX 3050 Laptop GPU", 4 * GB) };
{
// The integrated GPU has more device-local memory (shared system memory): the kind decides first.
GpuChoice c = ChooseGpu(pr6, nullptr);
CHECK(c.pick == 1, "Intel first, NVIDIA second: picked %zu", c.pick);
CHECK(c.why == "a discrete GPU before an integrated GPU", "why: %s", c.why.c_str());
Picked(pr6);
std::vector<GpuCandidate> nvFirst = { Dev(0, kDiscrete, "NVIDIA GeForce RTX 3050 Laptop GPU", 4 * GB),
Dev(1, kIntegrated, "Intel(R) Iris(R) Xe Graphics (RPL-P)", 15 * GB) };
CHECK(Picked(nvFirst) == "NVIDIA GeForce RTX 3050 Laptop GPU", "NVIDIA listed first (PRIME offload's order)");
std::vector<GpuCandidate> amdNv = { Dev(0, kIntegrated, "AMD Radeon 780M (RADV PHOENIX)", 8 * GB),
Dev(1, kDiscrete, "NVIDIA GeForce RTX 4060 Laptop GPU", 8 * GB) };
CHECK(Picked(amdNv) == "NVIDIA GeForce RTX 4060 Laptop GPU", "AMD integrated + NVIDIA");
std::vector<GpuCandidate> amdAmd = { Dev(0, kIntegrated, "AMD Radeon 680M (RADV REMBRANDT)", 2 * GB),
Dev(1, kDiscrete, "AMD Radeon RX 6650M (RADV NAVI23)", 8 * GB) };
CHECK(Picked(amdAmd) == "AMD Radeon RX 6650M (RADV NAVI23)", "AMD integrated + AMD discrete");
std::vector<GpuCandidate> nvOnly = { Dev(0, kDiscrete, "NVIDIA GeForce RTX 3050 Laptop GPU", 4 * GB) };
CHECK(Picked(nvOnly) == "NVIDIA GeForce RTX 3050 Laptop GPU", "NVIDIA only (PR #6's VK_DRIVER_FILES list)");
std::vector<GpuCandidate> igpu = { Dev(0, kIntegrated, "Intel(R) Iris(R) Xe Graphics (RPL-P)", 15 * GB) };
CHECK(Picked(igpu) == "Intel(R) Iris(R) Xe Graphics (RPL-P)", "a lone integrated GPU");
}
printf("3. software renderers, Dozen, two discrete GPUs\n");
{
std::vector<GpuCandidate> softFirst = { Dev(0, kCpu, "llvmpipe (LLVM 20.1.8, 256 bits)", 31 * GB),
Dev(1, kDiscrete, "AMD Radeon RX 7600 (RADV NAVI33)", 8 * GB) };
CHECK(Picked(softFirst) == "AMD Radeon RX 7600 (RADV NAVI33)", "llvmpipe listed first");
CHECK(ChooseGpu(softFirst, nullptr).why == "a discrete GPU before a software renderer", "%s", ChooseGpu(softFirst, nullptr).why.c_str());
std::vector<GpuCandidate> softLast = { Dev(0, kIntegrated, "AMD Custom GPU 0932 (RADV VANGOGH)", 1 * GB),
Dev(1, kCpu, "llvmpipe (LLVM 20.1.8, 256 bits)", 15 * GB) };
CHECK(Picked(softLast) == "AMD Custom GPU 0932 (RADV VANGOGH)", "an integrated GPU before llvmpipe, even with less memory");
CHECK(OldRule(softLast) == ChooseGpu(softLast, nullptr).pick, "the same as before when the GPU is listed first");
std::vector<GpuCandidate> otherVsSoft = { Dev(0, kCpu, "llvmpipe", 15 * GB), Dev(1, kOther, "Other", 1 * GB) };
CHECK(Picked(otherVsSoft) == "Other", "software last of all native kinds");
std::vector<GpuCandidate> virt = { Dev(0, kOther, "Other", 4 * GB), Dev(1, kVirtual, "Virtio-GPU Venus", 1 * GB) };
CHECK(Picked(virt) == "Virtio-GPU Venus", "virtual before other");
std::vector<GpuCandidate> dozen = { Dev(0, kDiscrete, "Microsoft Direct3D12 (NVIDIA GeForce RTX 4060)", 8 * GB, true, "", true),
Dev(1, kCpu, "llvmpipe", 15 * GB), Dev(2, kIntegrated, "Intel(R) UHD Graphics", 2 * GB) };
CHECK(Picked(dozen) == "Intel(R) UHD Graphics", "a native integrated GPU before Dozen's discrete one");
dozen.pop_back();
CHECK(Picked(dozen) == "llvmpipe", "Dozen after every native driver's (the windows branch's rule)");
std::vector<GpuCandidate> two = { Dev(0, kDiscrete, "AMD Radeon RX 7600 (RADV NAVI33)", 8 * GB),
Dev(1, kDiscrete, "NVIDIA GeForce RTX 4070 Ti SUPER", 16 * GB) };
GpuChoice c = ChooseGpu(two, nullptr);
CHECK(c.pick == 1, "two discrete: the one with more memory, picked %zu", c.pick);
CHECK(c.why == "a discrete GPU, with more device-local memory than 0: 16.0 GB against 8.0 GB", "why: %s", c.why.c_str());
Picked(two);
two[1].localBytes = 8 * GB;
c = ChooseGpu(two, nullptr);
CHECK(c.pick == 0, "two discrete, equal memory: the loader's first, picked %zu", c.pick);
CHECK(c.why == "a discrete GPU, with as much device-local memory as 1: the loader's order", "why: %s", c.why.c_str());
// Memory never outranks the kind: an integrated GPU's shared 30 GB against a 4 GB card.
std::vector<GpuCandidate> big = { Dev(0, kIntegrated, "AMD Radeon 8060S (RADV GFX1151)", 30 * GB),
Dev(1, kDiscrete, "NVIDIA GeForce RTX 3050 Laptop GPU", 4 * GB) };
CHECK(Picked(big) == "NVIDIA GeForce RTX 3050 Laptop GPU", "kind before memory");
}
printf("4. presenting, and the extensions the game needs\n");
{
std::vector<GpuCandidate> cant = { Dev(0, kDiscrete, "NVIDIA GeForce RTX 3050 Laptop GPU", 4 * GB, false),
Dev(1, kIntegrated, "Intel(R) Iris(R) Xe Graphics (RPL-P)", 15 * GB) };
CHECK(Picked(cant) == "Intel(R) Iris(R) Xe Graphics (RPL-P)", "a GPU that can't present is never chosen");
CHECK(ChooseGpu(cant, nullptr).why == "the only one that can present to the window", "%s", ChooseGpu(cant, nullptr).why.c_str());
std::vector<GpuCandidate> none = { Dev(0, kDiscrete, "A", 4 * GB, false), Dev(1, kIntegrated, "B", 1 * GB, false) };
CHECK(ChooseGpu(none, nullptr).pick == kNoGpu, "none can present");
CHECK(ChooseGpu(none, "1").pick == kNoGpu, "none can present, NFSMW_GPU or not");
CHECK(ChooseGpu({}, nullptr).pick == kNoGpu, "no devices");
CHECK(video::GpuLogLine({}, ChooseGpu({}, nullptr)) == "Vulkan devices: none; none can present to the window", "%s",
video::GpuLogLine({}, ChooseGpu({}, nullptr)).c_str());
std::vector<GpuCandidate> old = { Dev(0, kIntegrated, "Intel(R) HD Graphics 4600 (HSW GT2)", 2 * GB),
Dev(1, kDiscrete, "NVIDIA GeForce GTX 760M", 2 * GB, true, "VK_KHR_dynamic_rendering") };
GpuChoice c = ChooseGpu(old, nullptr);
CHECK(c.pick == 0, "a discrete GPU without an extension the game needs comes after: picked %zu", c.pick);
CHECK(c.why == "1 NVIDIA GeForce GTX 760M lacks VK_KHR_dynamic_rendering, which the game needs", "why: %s", c.why.c_str());
Picked(old);
old[0].lacks = "VK_KHR_push_descriptor";
CHECK(ChooseGpu(old, nullptr).pick == 1, "both lack one: the usual rules (it fails as it would have)");
std::vector<GpuCandidate> lone = { Dev(0, kIntegrated, "Old", 1 * GB, true, "VK_KHR_dynamic_rendering") };
CHECK(ChooseGpu(lone, nullptr).pick == 0, "a lone device without one: still it (vkCreateDevice says why, as before)");
}
printf("5. NFSMW_GPU\n");
{
CHECK(Picked(pr6, "0") == "Intel(R) Iris(R) Xe Graphics (RPL-P)", "by index 0");
CHECK(Picked(pr6, "1") == "NVIDIA GeForce RTX 3050 Laptop GPU", "by index 1");
CHECK(Picked(pr6, "iris") == "Intel(R) Iris(R) Xe Graphics (RPL-P)", "part of a name, any case");
CHECK(Picked(pr6, " Intel ") == "Intel(R) Iris(R) Xe Graphics (RPL-P)", "spaces around it");
CHECK(Picked(pr6, "3050") == "NVIDIA GeForce RTX 3050 Laptop GPU", "a number that is no index: part of a name");
CHECK(ChooseGpu(pr6, "intel").why == "NFSMW_GPU=intel", "why: %s", ChooseGpu(pr6, "intel").why.c_str());
GpuChoice c = ChooseGpu(pr6, "radeon");
CHECK(c.pick == 1 && c.wantNote == "NFSMW_GPU=radeon names none of them (an index from this list, or part of a name)",
"no match: %zu, %s", c.pick, c.wantNote.c_str());
c = ChooseGpu(pr6, "7");
CHECK(c.pick == 1 && !c.wantNote.empty(), "an index beyond the list: the usual choice");
c = ChooseGpu(pr6, "");
CHECK(c.pick == 1 && c.wantNote.empty() && c.want.empty(), "empty: as unset");
c = ChooseGpu(pr6, " ");
CHECK(c.pick == 1 && c.want.empty(), "blank: as unset");
// The index is the loader's, not a position among the presenting.
std::vector<GpuCandidate> gap = { Dev(0, kDiscrete, "A", 8 * GB, false), Dev(1, kIntegrated, "B", 1 * GB),
Dev(2, kDiscrete, "C", 4 * GB) };
CHECK(Picked(gap, "1") == "B", "index 1 is B");
c = ChooseGpu(gap, "0");
CHECK(c.pick == 2 && c.wantNote == "NFSMW_GPU=0 names 0 A, which can't present to the window", "can't present: %zu %s", c.pick,
c.wantNote.c_str());
Picked(gap, "0");
std::vector<GpuCandidate> nvs = { Dev(0, kDiscrete, "NVIDIA GeForce RTX 3060", 12 * GB, false),
Dev(1, kDiscrete, "NVIDIA GeForce GTX 1650", 4 * GB), Dev(2, kDiscrete, "NVIDIA GeForce RTX 4070", 12 * GB),
Dev(3, kIntegrated, "Intel(R) UHD Graphics 770", 15 * GB) };
CHECK(Picked(nvs, "nvidia") == "NVIDIA GeForce RTX 4070", "several match: the best of them that can present");
CHECK(Picked(nvs, "3060") == "NVIDIA GeForce RTX 4070", "matches only one that can't present: the usual choice");
CHECK(ChooseGpu(nvs, "3060").wantNote == "NFSMW_GPU=3060 names only devices that can't present to the window", "%s",
ChooseGpu(nvs, "3060").wantNote.c_str());
}
printf("6. the log line\n");
{
std::string line = video::GpuLogLine(pr6, ChooseGpu(pr6, nullptr));
CHECK(line == "Vulkan devices: 0 Intel(R) Iris(R) Xe Graphics (RPL-P) (integrated, 15.0 GB), 1 NVIDIA GeForce RTX 3050 Laptop GPU "
"(discrete, 4.0 GB); using 1: a discrete GPU before an integrated GPU (the loader listed 0 first); "
"NFSMW_GPU=<index or part of a name> picks another", "%s", line.c_str());
line = video::GpuLogLine(pr6, ChooseGpu(pr6, "0"));
CHECK(line == "Vulkan devices: 0 Intel(R) Iris(R) Xe Graphics (RPL-P) (integrated, 15.0 GB), 1 NVIDIA GeForce RTX 3050 Laptop GPU "
"(discrete, 4.0 GB); using 0: NFSMW_GPU=0 (the loader listed it first)", "%s", line.c_str());
line = video::GpuLogLine(pr6, ChooseGpu(pr6, "amd"));
CHECK(line.ends_with("; using 1: NFSMW_GPU=amd names none of them (an index from this list, or part of a name), so the usual "
"choice: a discrete GPU before an integrated GPU (the loader listed 0 first)"), "%s", line.c_str());
// The loader's first that can present, when that isn't its device 0.
std::vector<GpuCandidate> gap = { Dev(0, kDiscrete, "A", 8 * GB, false), Dev(1, kIntegrated, "B", 1 * GB),
Dev(2, kDiscrete, "C", 4 * GB) };
line = video::GpuLogLine(gap, ChooseGpu(gap, "1"));
CHECK(line == "Vulkan devices: 0 A (discrete, 8.0 GB, can't present to the window), 1 B (integrated, 1.0 GB), 2 C (discrete, 4.0 GB); "
"using 1: NFSMW_GPU=1 (the loader listed it first)", "%s", line.c_str());
line = video::GpuLogLine(gap, ChooseGpu(gap, nullptr));
CHECK(line.ends_with("using 2: a discrete GPU before an integrated GPU (the loader listed 1 first); NFSMW_GPU=<index or part of a name> picks another"),
"%s", line.c_str());
std::vector<GpuCandidate> marks = { Dev(0, kDiscrete, "X", 0, false), Dev(1, kIntegrated, "Y", 2 * GB, true, "VK_KHR_push_descriptor"),
Dev(2, kDiscrete, "Z", 6 * GB, true, "", true) };
line = video::GpuLogLine(marks, ChooseGpu(marks, nullptr));
CHECK(line == "Vulkan devices: 0 X (discrete, can't present to the window), 1 Y (integrated, 2.0 GB, lacks VK_KHR_push_descriptor), "
"2 Z (discrete on Direct3D 12, 6.0 GB); using 2: 1 Y lacks VK_KHR_push_descriptor, which the game needs "
"(the loader listed 1 first); NFSMW_GPU=<index or part of a name> picks another", "%s", line.c_str());
}
printf("7. against an independent ranking, and the old rule with one presenting device\n");
{
std::mt19937 rng(20261010);
int checked = 0, single = 0, changed = 0;
for (int round = 0; round < 20000; round++)
{
std::vector<GpuCandidate> list;
size_t n = 1 + rng() % 5;
for (size_t i = 0; i < n; i++)
{
// Few memory sizes, so ties happen; some lists skip indices.
GpuCandidate c = Dev(i + (rng() % 4 == 0 ? 1 : 0) * 10, int(rng() % 6), "dev", (rng() % 3) * 4 * GB, rng() % 5 != 0,
rng() % 6 == 0 ? "VK_KHR_dynamic_rendering" : "", rng() % 7 == 0);
c.name = std::format("dev{}", i);
list.push_back(c);
}
GpuChoice c = ChooseGpu(list, nullptr);
size_t ref = Reference(list);
CHECK(c.pick == ref, "round %d: picked %zu, the reference %zu", round, c.pick, ref);
size_t presenting = 0;
for (const GpuCandidate& d : list)
presenting += d.presents ? 1 : 0;
if (presenting == 1)
{
single++;
CHECK(c.pick == OldRule(list), "round %d: one can present: %zu, before v0.1.2 %zu", round, c.pick, OldRule(list));
}
changed += c.pick != OldRule(list) ? 1 : 0;
checked++;
if (g_failures > 20)
break;
}
printf(" %d lists, %d with one presenting device (all the same as before), %d chose another than before\n", checked, single, changed);
}
printf(g_failures ? "%d failure(s)\n" : "all passed\n", g_failures);
return g_failures ? 1 : 0;
}
+1 -1
View File
@@ -4,7 +4,7 @@
// (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
// clang++ -std=c++20 -O2 -Iruntime tests/install_folder_test.cpp runtime/install/{placement,installer,locate,manifest,result,disc_source,sha256}.cpp runtime/user/portable.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
+152
View File
@@ -0,0 +1,152 @@
#!/bin/bash
# The SteamOS launcher's hybrid-graphics part (scripts/steamos/bundle/
# speedbreaker.sh: nvidia_icd and prime_offload, run alone) on made-up /sys,
# /proc and Vulkan driver folders. Registered by tests/CMakeLists.txt, or:
# bash tests/launcher_prime_test.sh scripts/steamos/bundle/speedbreaker.sh
# Each case: the PCI display devices (vendor, device, class, driver), whether
# NVIDIA's kernel driver is loaded (/proc/driver/nvidia), where NVIDIA's Vulkan
# driver manifest is, and what the user set; then whether
# __NV_PRIME_RENDER_OFFLOAD reaches the game and what the log says. The
# launcher never sets VK_DRIVER_FILES or __GLX_VENDOR_LIBRARY_NAME, and leaves
# a value the user set as it is.
set -u
LAUNCHER=${1:?usage: launcher_prime_test.sh <speedbreaker.sh>}
[ -f "$LAUNCHER" ] || { echo "no $LAUNCHER"; exit 1; }
# The two functions, as the launcher has them (each from "name() {" to its "}").
FUNCS="$(awk '/^(nvidia_icd|prime_offload)\(\) \{$/ { on = 1 } on { print } on && /^\}$/ { on = 0 }' "$LAUNCHER")"
case "$FUNCS" in *"nvidia_icd() {"*"prime_offload() {"*) ;; *) echo "FAIL: nvidia_icd and prime_offload not found in $LAUNCHER"; exit 1 ;; esac
T="$(mktemp -d "${TMPDIR:-/tmp}/launcher_prime_test.XXXXXX")" || exit 1
trap 'rm -rf "$T"' EXIT
failures=0
cases=0
# gpu <root> <slot> <vendor> <device> <class> <driver or ->
gpu() {
local d="$1/sys/bus/pci/devices/$2"
mkdir -p "$d" "$1/sys/bus/pci/drivers"
echo "$5" > "$d/class"; echo "$3" > "$d/vendor"; echo "$4" > "$d/device"
if [ "$6" != - ]; then
mkdir -p "$1/sys/bus/pci/drivers/$6"
ln -s "../../../../bus/pci/drivers/$6" "$d/driver"
fi
}
nvidia_loaded() {
mkdir -p "$1/proc/driver/nvidia"
printf 'NVRM version: NVIDIA UNIX Open Kernel Module for x86_64 580.82.07 Release Build (dvs-builder@U16-I3-B17-1-1) Wed Aug 27 23:23:00 UTC 2025\nGCC version: gcc version 14.2.0\n' \
> "$1/proc/driver/nvidia/version"
}
icd() { # icd <root> <dir> <name>
mkdir -p "$1$2/vulkan/icd.d"
echo '{ "file_format_version": "1.0.1", "ICD": { "library_path": "libGLX_nvidia.so.0", "api_version": "1.4.312" } }' > "$1$2/vulkan/icd.d/$3"
}
# PR #6's laptop: Intel Raptor Lake-P iGPU + RTX 3050 Laptop GPU (a "3D controller"), NVIDIA's HDMI audio beside it.
laptop() {
gpu "$1" 0000:00:02.0 0x8086 0xa7a0 0x030000 i915
gpu "$1" 0000:01:00.0 0x10de 0x25a2 0x030200 nvidia
gpu "$1" 0000:01:00.1 0x10de 0x228e 0x040300 snd_hda_intel
gpu "$1" 0000:00:1f.3 0x8086 0x51ca 0x040380 snd_hda_intel
}
# run <name> <root> [VAR=value ...]: the functions in a clean environment (HOME=/home, inside the root; no XDG_*, no
# NVIDIA or Vulkan variables but those given; uname -m says x86_64, as on the x86-64 bundle's machines).
# Sets OUT (the log) and ENVS (the variables the game would get).
run() {
local name=$1 root=$2; shift 2
mkdir -p "$root/home"
OUT="$(env -i PATH="$PATH" HOME=/home "$@" "$BASH" -c '
say() { echo "[launcher] $*"; }
uname() { [ "${1:-}" = -m ] && echo x86_64 || command uname "$@"; }
eval "$1"
prime_offload "$2"
echo "ENV __NV_PRIME_RENDER_OFFLOAD=${__NV_PRIME_RENDER_OFFLOAD-<unset>} VK_DRIVER_FILES=${VK_DRIVER_FILES-<unset>}" \
"VK_ICD_FILENAMES=${VK_ICD_FILENAMES-<unset>} __GLX_VENDOR_LIBRARY_NAME=${__GLX_VENDOR_LIBRARY_NAME-<unset>}"
' bash "$FUNCS" "$root" 2>&1)"
ENVS="$(echo "$OUT" | sed -n 's/^ENV //p')"
OUT="$(echo "$OUT" | grep -v '^ENV ')"
cases=$((cases + 1))
printf '%s\n log: %s\n env: %s\n' "$name" "${OUT:-(none)}" "$ENVS"
}
check() { # check <description> <shell test...>
local what=$1; shift
if ! "$@"; then failures=$((failures + 1)); echo " FAIL: $what"; fi
}
has() { case "$1" in *"$2"*) return 0 ;; *) return 1 ;; esac; }
offload() { has "$ENVS" "__NV_PRIME_RENDER_OFFLOAD=$1 "; }
untouched() { has "$ENVS" "VK_DRIVER_FILES=${1:-<unset>} " && has "$ENVS" "__GLX_VENDOR_LIBRARY_NAME=<unset>" && has "$ENVS" "VK_ICD_FILENAMES=<unset>"; }
# 1. Our devices: one GPU, no NVIDIA. Nothing set; one line naming the GPU (none on the Frame).
R=$T/steam-machine; gpu $R 0000:03:00.0 0x1002 0x7481 0x030000 amdgpu
run "Steam Machine (RADV NAVI33)" $R
check "nothing set" offload "<unset>"; check "VK/GLX untouched" untouched
check "the line" [ "$OUT" = "[launcher] graphics: AMD 1002:7481 (amdgpu)" ]
R=$T/deck; gpu $R 0000:04:00.0 0x1002 0x1435 0x030000 amdgpu
run "Steam Deck OLED (VANGOGH)" $R
check "nothing set" offload "<unset>"; check "the line" [ "$OUT" = "[launcher] graphics: AMD 1002:1435 (amdgpu)" ]
R=$T/frame; mkdir -p $R/sys/bus/pci/devices
run "Steam Frame (Adreno: not on PCI)" $R
check "nothing set" offload "<unset>"; check "no line" [ -z "$OUT" ]
# 2. PR #6's laptop, NVIDIA's driver loaded, its manifest in each place the loader looks.
for place in /usr/share /etc /usr/local/share /etc/xdg; do
R=$T/laptop$(echo $place | tr / _); laptop $R; nvidia_loaded $R; icd $R $place nvidia_icd.json
run "Intel + NVIDIA, manifest in $place/vulkan/icd.d" $R
check "offload on" offload 1; check "VK/GLX untouched" untouched
check "the line" [ "$OUT" = "[launcher] graphics: Intel 8086:a7a0 (i915), NVIDIA 10de:25a2 (nvidia); NVIDIA driver 580.82.07 beside another GPU; NVIDIA's Vulkan driver $place/vulkan/icd.d/nvidia_icd.json; __NV_PRIME_RENDER_OFFLOAD=1 (PRIME render offload: NVIDIA's layer lists its GPU first)" ]
done
R=$T/fedora; laptop $R; nvidia_loaded $R; icd $R /usr/share nvidia_icd.x86_64.json
run "Intel + NVIDIA, Fedora's nvidia_icd.x86_64.json" $R
check "offload on" offload 1; check "the manifest named" has "$OUT" "/usr/share/vulkan/icd.d/nvidia_icd.x86_64.json"
R=$T/i686only; laptop $R; nvidia_loaded $R; icd $R /usr/share nvidia_icd.i686.json
run "Intel + NVIDIA, only the 32-bit manifest" $R
check "not the 32-bit one" offload "<unset>"; check "says why" has "$OUT" "no NVIDIA Vulkan driver (nvidia_icd.json) where the Vulkan loader looks"
R=$T/nixos; laptop $R; nvidia_loaded $R; icd $R /run/opengl-driver/share nvidia_icd.x86_64.json
run "Intel + NVIDIA, NixOS (XDG_DATA_DIRS)" $R XDG_DATA_DIRS=/run/opengl-driver/share:/usr/share
check "offload on" offload 1; check "from XDG_DATA_DIRS" has "$OUT" "/run/opengl-driver/share/vulkan/icd.d/nvidia_icd.x86_64.json"
R=$T/userdata; laptop $R; nvidia_loaded $R; icd $R /home/.local/share nvidia_icd.json
run "Intel + NVIDIA, manifest in ~/.local/share" $R
check "offload on" offload 1; check "from HOME" has "$OUT" "/home/.local/share/vulkan/icd.d/nvidia_icd.json"
R=$T/amd-nvidia; gpu $R 0000:65:00.0 0x1002 0x15bf 0x030000 amdgpu; gpu $R 0000:01:00.0 0x10de 0x28e0 0x030000 nvidia
nvidia_loaded $R; icd $R /usr/share nvidia_icd.json
run "AMD integrated + NVIDIA" $R
check "offload on" offload 1; check "both named" has "$OUT" "NVIDIA 10de:28e0 (nvidia), AMD 1002:15bf (amdgpu)"
# 3. Not hybrid, or no NVIDIA driver: nothing set.
R=$T/nomanifest; laptop $R; nvidia_loaded $R
run "Intel + NVIDIA, no manifest anywhere" $R
check "nothing set" offload "<unset>"; check "says why" has "$OUT" "no PRIME render offload"
R=$T/desktop; gpu $R 0000:01:00.0 0x10de 0x2786 0x030000 nvidia; nvidia_loaded $R; icd $R /usr/share nvidia_icd.json
run "NVIDIA desktop (one GPU)" $R
check "nothing set" offload "<unset>"; check "the line" [ "$OUT" = "[launcher] graphics: NVIDIA 10de:2786 (nvidia)" ]
R=$T/nouveau; gpu $R 0000:00:02.0 0x8086 0xa7a0 0x030000 i915; gpu $R 0000:01:00.0 0x10de 0x25a2 0x030200 nouveau
icd $R /usr/share nvidia_icd.json
run "Intel + NVIDIA on nouveau" $R
check "nothing set" offload "<unset>"
R=$T/notloaded; laptop $R; icd $R /usr/share nvidia_icd.json
run "Intel + NVIDIA, /proc/driver/nvidia missing" $R
check "nothing set" offload "<unset>"
R=$T/vfio; gpu $R 0000:00:02.0 0x8086 0xa7a0 0x030000 vfio-pci; gpu $R 0000:01:00.0 0x10de 0x25a2 0x030000 nvidia
nvidia_loaded $R; icd $R /usr/share nvidia_icd.json
run "NVIDIA + an Intel GPU passed to a VM (vfio-pci)" $R
check "nothing set" offload "<unset>"
R=$T/unbound; gpu $R 0000:00:02.0 0x8086 0xa7a0 0x030000 -; gpu $R 0000:01:00.0 0x10de 0x25a2 0x030000 nvidia
nvidia_loaded $R; icd $R /usr/share nvidia_icd.json
run "NVIDIA + an Intel GPU with no driver" $R
check "nothing set" offload "<unset>"; check "named without a driver" has "$OUT" "Intel 8086:a7a0 (no driver)"
R=$T/audio; gpu $R 0000:01:00.0 0x10de 0x2786 0x030000 nvidia; gpu $R 0000:01:00.1 0x10de 0x22bc 0x040300 snd_hda_intel
gpu $R 0000:00:1f.3 0x8086 0x7a50 0x040300 snd_hda_intel; nvidia_loaded $R; icd $R /usr/share nvidia_icd.json
run "NVIDIA desktop + Intel audio (not a GPU)" $R
check "nothing set" offload "<unset>"; check "audio not listed" [ "$OUT" = "[launcher] graphics: NVIDIA 10de:2786 (nvidia)" ]
# 4. The user's values win; the launcher adds no others.
R=$T/user0; laptop $R; nvidia_loaded $R; icd $R /usr/share nvidia_icd.json
run "Intel + NVIDIA, __NV_PRIME_RENDER_OFFLOAD=0 set" $R __NV_PRIME_RENDER_OFFLOAD=0
check "kept 0" offload 0; check "says so" has "$OUT" "__NV_PRIME_RENDER_OFFLOAD=0 was already set: left as it is"
run "Intel + NVIDIA, __NV_PRIME_RENDER_OFFLOAD= (empty) set" $R __NV_PRIME_RENDER_OFFLOAD=
check "kept empty" offload ""
run "Intel + NVIDIA, VK_DRIVER_FILES set" $R VK_DRIVER_FILES=/usr/share/vulkan/icd.d/intel_icd.x86_64.json
check "offload on" offload 1; check "VK_DRIVER_FILES kept" untouched /usr/share/vulkan/icd.d/intel_icd.x86_64.json
echo
if [ $failures = 0 ]; then echo "all passed ($cases cases)"; else echo "$failures failure(s) in $cases cases"; fi
[ $failures = 0 ]
+246
View File
@@ -0,0 +1,246 @@
// Tests for kernel/memory_status.h: the [memory] log line, the page shown when
// the guest's 4 GB can't be reserved (an iOS copy signed without Extended
// Virtual Addressing), and the low-memory notice. From the repo root:
// clang++ -std=c++20 -O2 -Iruntime tests/memory_status_test.cpp runtime/kernel/memory_status.cpp runtime/platform/code_signature.cpp -o build/memory_status_test
// build/memory_status_test
#include <kernel/memory_status.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <string>
using namespace memstatus;
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
{
bool Has(const std::string& text, const char* part)
{
return text.find(part) != std::string::npos;
}
std::string All(const Message& m)
{
std::string text = m.title + "\n";
for (const std::string& p : m.paragraphs)
text += p + "\n";
return text + m.details;
}
// An iOS copy as a sideload with a free Apple Account may leave it.
Status IosFailed(Flag va, Flag mem)
{
Status s;
s.ios = true;
s.failure = Failure::AddressSpace;
s.error = ENOMEM;
s.errorText = "Cannot allocate memory";
s.signatureRead = va != Flag::Unknown;
s.extendedVirtualAddressing = va;
s.increasedMemoryLimit = mem;
s.memoryLimitMB = 2900;
s.addressSpaceTop = 0x458000000;
return s;
}
}
int main()
{
// The log line.
{
Status mac;
mac.base = 0x100000000;
CHECK(LogLine(mac) == "[memory] guest 4 GB at 0x100000000", "%s", LogLine(mac).c_str());
CHECK(SummaryItem(mac) == "guest 4 GB at 0x100000000", "%s", SummaryItem(mac).c_str());
Status ios = mac;
ios.ios = true;
ios.base = 0x7000000000;
ios.signatureRead = true;
ios.extendedVirtualAddressing = Flag::Yes;
ios.increasedMemoryLimit = Flag::Yes;
ios.memoryLimitMB = 6136;
ios.addressSpaceTop = 0xFC0000000;
std::string line = LogLine(ios);
CHECK(line == "[memory] guest 4 GB at 0x7000000000; entitlements in the signature: Extended Virtual Addressing yes, "
"Increased Memory Limit yes; address space up to 0xfc0000000 (63 GB); memory limit 6136 MB", "%s", line.c_str());
Status unread = ios;
unread.signatureRead = false;
unread.extendedVirtualAddressing = unread.increasedMemoryLimit = Flag::Unknown;
unread.memoryLimitMB = -1;
unread.addressSpaceTop = 0;
CHECK(LogLine(unread) == "[memory] guest 4 GB at 0x7000000000; entitlements: not read; memory limit unknown", "%s",
LogLine(unread).c_str());
std::string failed = LogLine(IosFailed(Flag::No, Flag::No));
CHECK(Has(failed, "reserving 4 GB failed: Cannot allocate memory (errno 12)"), "%s", failed.c_str());
CHECK(Has(failed, "Extended Virtual Addressing no, Increased Memory Limit no"), "%s", failed.c_str());
CHECK(Has(failed, "address space up to 0x458000000 (17 GB)") && Has(failed, "memory limit 2900 MB"), "%s", failed.c_str());
}
// The page: nothing to say when the game started.
{
Status ok;
ok.base = 0x100000000;
Message m = StartupFailure(ok);
CHECK(m.title.empty() && m.paragraphs.empty() && m.details.empty(), "no failure, no page");
}
// iOS, signed with neither entitlement (SideStore 0.7 with a free Apple
// Account): the case the IPA is most likely to meet.
{
Message m = StartupFailure(IosFailed(Flag::No, Flag::No));
std::string text = All(m);
CHECK(m.title == "This copy of SpeedBreaker can't start", "%s", m.title.c_str());
CHECK(m.paragraphs.size() == 3, "%zu paragraphs", m.paragraphs.size());
CHECK(Has(m.paragraphs[0], "iOS refused the 4 GB of address space") && Has(m.paragraphs[0], "signed with neither"),
"%s", m.paragraphs[0].c_str());
CHECK(Has(m.paragraphs[1], "This build needs the Extended Virtual Addressing entitlement")
&& Has(m.paragraphs[1], "paid Apple Developer Program account") && Has(m.paragraphs[1], "Increased Memory Limit")
&& Has(m.paragraphs[1], "free Apple Account"), "%s", m.paragraphs[1].c_str());
CHECK(Has(text, "AltStore") && Has(text, "SideStore") && Has(text, "Xcode") && Has(text, "scripts/ios/README.md"),
"%s", text.c_str());
CHECK(Has(m.paragraphs[2], "app switcher"), "%s", m.paragraphs[2].c_str());
CHECK(Has(m.details, "Details: reserving 4 GB failed") && Has(m.details, "Extended Virtual Addressing no")
&& Has(m.details, "address space up to 0x458000000"), "%s", m.details.c_str());
}
// Increased Memory Limit only (AltStore with a free account), on a
// device without its larger address space.
{
Message m = StartupFailure(IosFailed(Flag::No, Flag::Yes));
CHECK(Has(m.paragraphs[0], "signed with Increased Memory Limit") && Has(m.paragraphs[0], "but not with Extended"),
"%s", m.paragraphs[0].c_str());
CHECK(Has(All(m), "paid Apple Developer Program"), "advice kept");
}
// The signature couldn't be read: the same advice, worded for it.
{
Message m = StartupFailure(IosFailed(Flag::Unknown, Flag::Unknown));
CHECK(Has(m.paragraphs[0], "iOS didn't give this copy the room"), "%s", m.paragraphs[0].c_str());
CHECK(Has(All(m), "paid Apple Developer Program"), "advice kept");
CHECK(Has(m.details, "entitlements: not read"), "%s", m.details.c_str());
}
// Signed with it and still refused: not an entitlement problem.
{
Message m = StartupFailure(IosFailed(Flag::Yes, Flag::Yes));
std::string text = All(m);
CHECK(Has(text, "even though this copy has the Extended Virtual Addressing entitlement"), "%s", text.c_str());
CHECK(Has(text, "Restart the device") && !Has(text, "paid Apple Developer"), "%s", text.c_str());
}
// The physical memory mirrors failed (iOS and elsewhere).
{
Status s = IosFailed(Flag::Yes, Flag::Yes);
s.failure = Failure::PhysicalMemory;
s.error = 3;
s.errorText = "(os/kern) no space available";
Message m = StartupFailure(s);
CHECK(Has(All(m), "512 MB") && Has(m.details, "mapping the 512 MB of physical memory failed: (os/kern) no space available (3)"),
"%s", All(m).c_str());
s.ios = false;
m = StartupFailure(s);
CHECK(m.title == "SpeedBreaker can't start" && Has(All(m), "512 MB"), "%s", All(m).c_str());
}
// Elsewhere (a Mac, Linux): no entitlement talk.
{
Status s;
s.failure = Failure::AddressSpace;
s.error = ENOMEM;
s.errorText = "Cannot allocate memory";
Message m = StartupFailure(s);
std::string text = All(m);
CHECK(m.title == "SpeedBreaker can't start", "%s", m.title.c_str());
CHECK(Has(text, "ulimit -v") && !Has(text, "entitlement") && !Has(text, "Apple"), "%s", text.c_str());
CHECK(m.details == "Details: reserving 4 GB failed: Cannot allocate memory (errno 12).", "%s", m.details.c_str());
}
// The low-memory notice.
{
Status s;
s.ios = true;
s.base = 0x7000000000;
s.signatureRead = true;
s.extendedVirtualAddressing = Flag::Yes;
s.increasedMemoryLimit = Flag::Yes;
s.memoryLimitMB = 6136;
CHECK(LowMemoryNotice(s).empty(), "a big limit: nothing");
s.memoryLimitMB = kPeakFootprintMB;
CHECK(LowMemoryNotice(s).empty(), "exactly the peak: nothing");
s.memoryLimitMB = 2900;
CHECK(LowMemoryNotice(s) == "iOS lets SpeedBreaker use only 2900 MB on this device: it may close the game in busy scenes",
"%s", LowMemoryNotice(s).c_str());
s.increasedMemoryLimit = Flag::No;
CHECK(LowMemoryNotice(s) == "iOS lets SpeedBreaker use only 2900 MB (no Increased Memory Limit entitlement): it may close "
"the game in busy scenes", "%s", LowMemoryNotice(s).c_str());
s.memoryLimitMB = 4500;
CHECK(LowMemoryNotice(s).empty(), "no entitlement but room enough: nothing");
s.memoryLimitMB = -1;
CHECK(LowMemoryNotice(s).empty(), "limit unknown: nothing");
s.memoryLimitMB = 2900;
s.ios = false;
CHECK(LowMemoryNotice(s).empty(), "not iOS: nothing");
s.ios = true;
s.failure = Failure::AddressSpace;
CHECK(LowMemoryNotice(s).empty(), "the game didn't start: the page says it instead");
// iOS's default address space: neither entitlement took effect.
s.failure = Failure::None;
s.increasedMemoryLimit = Flag::No;
s.extendedVirtualAddressing = Flag::No;
s.addressSpaceTop = 0x458000000;
s.memoryLimitMB = 3300;
CHECK(LowMemoryNotice(s) == "iOS gives SpeedBreaker only 17 GB of address space (no memory entitlements): it may "
"close the game in busy scenes", "%s", LowMemoryNotice(s).c_str());
s.memoryLimitMB = 2900;
CHECK(LowMemoryNotice(s) == "iOS gives SpeedBreaker little room (17 GB of address space, 2900 MB of memory: no "
"memory entitlements): it may close the game in busy scenes", "%s", LowMemoryNotice(s).c_str());
s.addressSpaceTop = kLargeAddressSpace; // Increased Memory Limit's 63 GB, the limit still low
CHECK(Has(LowMemoryNotice(s), "only 2900 MB (no Increased Memory Limit"), "%s", LowMemoryNotice(s).c_str());
s.addressSpaceTop = 0x7FC0000000;
s.memoryLimitMB = 6136;
CHECK(LowMemoryNotice(s).empty(), "both: nothing");
s.addressSpaceTop = 0;
s.memoryLimitMB = 3300;
CHECK(LowMemoryNotice(s).empty(), "address space unknown, limit fine: nothing");
}
CHECK(std::string(FlagName(Flag::Yes)) == "yes" && std::string(FlagName(Flag::No)) == "no"
&& std::string(FlagName(Flag::Unknown)) == "unknown", "names");
// Gather off iOS leaves the entitlements unknown and the line short; on
// the machine running this it fills in the error text.
// NFSMW_TEST_MEMORY_IOS=1 gives the iOS wording, with this executable's
// own entitlements (a test binary has none: "no" on a Mac).
{
unsetenv("NFSMW_TEST_MEMORY_IOS");
Status s;
s.failure = Failure::AddressSpace;
s.error = ENOMEM;
Gather(s);
CHECK(!s.errorText.empty(), "error text");
#if !(defined(__APPLE__) && TARGET_OS_IOS)
CHECK(!s.ios && !s.signatureRead && s.memoryLimitMB == -1, "not iOS");
setenv("NFSMW_TEST_MEMORY_IOS", "1", 1);
Status t;
t.failure = Failure::AddressSpace;
t.error = ENOMEM;
Gather(t);
unsetenv("NFSMW_TEST_MEMORY_IOS");
CHECK(t.ios && t.memoryLimitMB == -1, "the iOS wording asked for");
#ifdef __APPLE__
CHECK(t.signatureRead && t.extendedVirtualAddressing == Flag::No && t.increasedMemoryLimit == Flag::No,
"a test binary's signature has no entitlements (read %d)", int(t.signatureRead));
#endif
CHECK(Has(StartupFailure(t).title, "This copy of SpeedBreaker"), "%s", StartupFailure(t).title.c_str());
#endif
}
if (g_failures)
{
printf("memory_status_test: %d failure(s)\n", g_failures);
return 1;
}
printf("memory_status_test: all passed\n");
return 0;
}
+176
View File
@@ -0,0 +1,176 @@
// Tests for gpu::pipeline_cache (runtime/gpu/pipeline_cache_header.h): when
// a saved pipelines.bin was made by another GPU, driver or OS version (the
// driver would set it aside, and the first drive stutters while pipelines
// compile again), and the log line that says so. From the repo root:
// clang++ -std=c++20 -O2 -UNDEBUG -Iruntime tests/pipeline_cache_header_test.cpp -o build/pipeline_cache_header_test
// build/pipeline_cache_header_test
// The two real headers are the first 32 bytes of the pipelines.bin MoltenVK
// 1.4.2 wrote on an M1 Pro under macOS 26.5 and under macOS 27.0.1.
#include <gpu/pipeline_cache_header.h>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>
using namespace gpu::pipeline_cache;
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 std::vector<uint8_t> kMac265 = {
0x20, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x6b, 0x10, 0x00, 0x00, 0x07, 0x02, 0x05, 0x1a,
0x00, 0x00, 0x28, 0xa2, 0x1a, 0x05, 0x02, 0x07, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 };
static const std::vector<uint8_t> kMac270 = {
0x20, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x6b, 0x10, 0x00, 0x00, 0x07, 0x02, 0x00, 0x1b,
0x00, 0x00, 0x28, 0xa2, 0x1b, 0x00, 0x02, 0x07, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 };
// The device as MoltenVK reports it on each (vkGetPhysicalDeviceProperties).
static Maker Mac(uint32_t deviceID)
{
Maker m{ kAppleVendor, deviceID, {} };
m.uuid = { 0x00, 0x00, 0x28, 0xa2, uint8_t(deviceID >> 24), uint8_t(deviceID >> 16), uint8_t(deviceID >> 8),
uint8_t(deviceID), 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 };
return m;
}
// A version-one header written least significant byte first, plus `extra` bytes of cache.
static std::vector<uint8_t> Header(const Maker& m, uint32_t size = 32, uint32_t version = 1, size_t extra = 0)
{
std::vector<uint8_t> out;
for (uint32_t v : { size, version, m.vendorID, m.deviceID })
for (int i = 0; i < 4; i++)
out.push_back(uint8_t(v >> (8 * i)));
out.insert(out.end(), m.uuid.begin(), m.uuid.end());
out.resize(out.size() + extra, 0xAB);
return out;
}
static HeaderCheck Check(const std::vector<uint8_t>& data, const Maker& device)
{
return CheckHeader(data.data(), data.size(), device);
}
int main()
{
const Maker mac265 = Mac(0x1a050207), mac270 = Mac(0x1b000207);
// The real headers parse as the device that wrote them.
HeaderCheck c = Check(kMac270, mac270);
CHECK(c.verdict == Verdict::Usable, "27.0's own cache on 27.0");
CHECK(c.file.vendorID == 0x106B && c.file.deviceID == 0x1b000207 && c.file.uuid == mac270.uuid, "fields read LSB first");
CHECK(LogLine(c, mac270).empty(), "nothing to say about a usable cache");
CHECK(Check(kMac265, mac265).verdict == Verdict::Usable, "26.5's own cache on 26.5");
CHECK(Header(mac270) == kMac270 && Header(mac265) == kMac265, "the test's writer matches MoltenVK's bytes");
// The macOS update: 26.5's cache on 27.0, as the 27.0.1 upgrade saw it.
std::vector<uint8_t> upgraded = kMac265;
upgraded.resize(2563 * 1024 + 100, 0x5A);
c = Check(upgraded, mac270);
CHECK(c.verdict == Verdict::OtherMaker, "26.5's cache on 27.0");
CHECK(!c.vendorDiffers && c.deviceDiffers && c.uuidDiffers, "the device ID and UUID carry the OS");
std::string line = LogLine(c, mac270);
CHECK(line == "[renderer] pipeline cache from another GPU, driver or OS version: rebuilding shaders, expect a brief "
"stutter on first use (OS 26.5 -> 27.0, device ID 1a050207 -> 1b000207, cache UUID "
"000028a21a0502070000000100000000 -> 000028a21b0002070000000100000000; 2563 KB set aside)",
"%s", line.c_str());
// And back (a second Mac, or a downgrade).
line = LogLine(Check(kMac270, mac265), mac265);
CHECK(line.find("(OS 27.0 -> 26.5, device ID 1b000207 -> 1a050207, ") != std::string::npos, "%s", line.c_str());
CHECK(line.find("; 0 KB set aside)") != std::string::npos, "a header-only file: %s", line.c_str());
// A new MoltenVK on the same macOS (a release that bundles another): the UUID alone.
Maker newMvk = mac270;
newMvk.uuid[3] = 0xa3;
c = Check(kMac270, newMvk);
CHECK(c.verdict == Verdict::OtherMaker && !c.vendorDiffers && !c.deviceDiffers && c.uuidDiffers, "MoltenVK update");
line = LogLine(c, newMvk);
CHECK(line.find("(cache UUID 000028a21b0002070000000100000000 -> 000028a31b0002070000000100000000; 0 KB set aside)")
!= std::string::npos && line.find("(OS ") == std::string::npos && line.find("device ID") == std::string::npos,
"%s", line.c_str());
// Mesa on the Steam Machine: a SteamOS update changes the UUID (its build ID) only.
Maker radv{ 0x1002, 0x7480, {} };
for (int i = 0; i < 16; i++)
radv.uuid[i] = uint8_t(0x10 + i);
Maker radvUpdated = radv;
radvUpdated.uuid[15] ^= 0xFF;
c = Check(Header(radv, 32, 1, 4096), radvUpdated);
CHECK(c.verdict == Verdict::OtherMaker && c.uuidDiffers && !c.deviceDiffers, "Mesa update");
CHECK(LogLine(c, radvUpdated).find("(cache UUID 101112131415161718191a1b1c1d1e1f -> 101112131415161718191a1b1c1d1ee0; 4 KB set aside)")
!= std::string::npos, "%s", LogLine(c, radvUpdated).c_str());
CHECK(Check(Header(radv, 32, 1, 4096), radv).verdict == Verdict::Usable, "Mesa, same build");
// Another GPU: the vendor and device IDs, and no OS reading (it's not one Apple GPU's ID against another).
c = Check(Header(radv), mac270);
CHECK(c.verdict == Verdict::OtherMaker && c.vendorDiffers && c.deviceDiffers && c.uuidDiffers, "AMD cache on an Apple GPU");
line = LogLine(c, mac270);
CHECK(line.find("(vendor 1002 -> 106b, device ID 00007480 -> 1b000207, cache UUID ") != std::string::npos
&& line.find("(OS ") == std::string::npos, "%s", line.c_str());
// Another Apple GPU on the same macOS: the low half differs, the OS doesn't.
const Maker m3 = Mac(0x1b000209);
c = Check(kMac270, m3);
CHECK(c.verdict == Verdict::OtherMaker && c.deviceDiffers, "M3 against an M1 cache");
line = LogLine(c, m3);
CHECK(line.find("(device ID 1b000207 -> 1b000209, ") != std::string::npos && line.find("(OS ") == std::string::npos, "%s",
line.c_str());
// An OS update that also moves the GPU family word: still read as the OS.
const Maker m270b = Mac(0x1b000208);
line = LogLine(Check(kMac265, m270b), m270b);
CHECK(line.find("(OS 26.5 -> 27.0, device ID 1a050207 -> 1b000208, ") != std::string::npos, "%s", line.c_str());
// Intel and AMD Macs report PCI IDs under MoltenVK: no OS reading for them.
const Maker intel{ 0x8086, 0x1b000207, mac270.uuid }, intelOld{ 0x8086, 0x1a050207, mac265.uuid };
line = LogLine(Check(Header(intelOld), intel), intel);
CHECK(line.find("(device ID 1a050207 -> 1b000207, ") != std::string::npos && line.find("(OS ") == std::string::npos, "%s",
line.c_str());
// Nothing, and files that aren't a version-one header.
c = CheckHeader(nullptr, 0, mac270);
CHECK(c.verdict == Verdict::Empty && LogLine(c, mac270).empty(), "no file");
c = CheckHeader(kMac270.data(), 31, mac270);
CHECK(c.verdict == Verdict::Unreadable, "31 bytes");
line = LogLine(c, mac270);
CHECK(line == "[renderer] pipeline cache file not readable: rebuilding shaders, expect a brief stutter on first use "
"(31 bytes, shorter than its header)", "%s", line.c_str());
CHECK(CheckHeader(kMac270.data(), 1, mac270).verdict == Verdict::Unreadable, "1 byte");
c = Check(Header(mac270, 40, 1, 8), mac270);
CHECK(c.verdict == Verdict::Unreadable && c.headerSize == 40, "a longer header (Mesa compares headerSize too)");
line = LogLine(c, mac270);
CHECK(line.find("(header size 40, version 1; 0 KB set aside)") != std::string::npos, "%s", line.c_str());
c = Check(Header(mac270, 32, 2, 2048), mac270);
CHECK(c.verdict == Verdict::Unreadable && c.headerVersion == 2, "another header version");
CHECK(LogLine(c, mac270).find("(header size 32, version 2; 2 KB set aside)") != std::string::npos, "%s", LogLine(c, mac270).c_str());
std::vector<uint8_t> bigEndian = kMac270; // a writer that ignored the byte order: unreadable, not another device
std::swap(bigEndian[0], bigEndian[3]);
std::swap(bigEndian[4], bigEndian[7]);
CHECK(Check(bigEndian, mac270).verdict == Verdict::Unreadable, "big-endian header fields");
// Each field alone decides.
for (int field = 0; field < 3; field++)
{
Maker other = mac270;
if (field == 0)
other.vendorID ^= 1;
else if (field == 1)
other.deviceID ^= 1;
else
other.uuid[15] ^= 1;
c = Check(kMac270, other);
CHECK(c.verdict == Verdict::OtherMaker && c.vendorDiffers == (field == 0) && c.deviceDiffers == (field == 1)
&& c.uuidDiffers == (field == 2), "field %d", field);
}
for (size_t i = 0; i < 16; i++)
{
Maker other = mac270;
other.uuid[i] ^= 0x80;
CHECK(Check(kMac270, other).verdict == Verdict::OtherMaker, "UUID byte %zu", i);
}
if (g_failures)
{
printf("pipeline_cache_header_test: %d FAILED\n", g_failures);
return 1;
}
printf("pipeline_cache_header_test: all passed\n");
return 0;
}
+711
View File
@@ -0,0 +1,711 @@
// Tests for portable mode (user/portable.h) and switching into it and back
// (install/portable_switch.h): the layout a portable.txt turns on, the
// records that move with the folder, the data and cache paths, where the
// game may go inside SpeedBreaker's folder, the startup write check, then the
// --portable on/off copies in a scratch folder (a stand-in copies the game).
// From the repo root:
// clang++ -std=c++20 -O2 -Iruntime tests/portable_test.cpp runtime/user/portable.cpp runtime/install/{portable_switch,placement,installer,locate,manifest,result,disc_source,sha256}.cpp -o build/portable_test
// (on macOS add -framework CoreFoundation)
// build/portable_test [folder]
// The disk checks run in a new folder made inside [folder] (default: the
// current folder), and only that one is deleted afterwards. On macOS and iOS
// portable mode never turns on (portable::kSupported); the disk checks set
// the layout directly there, so the rest of it is checked on a Mac too.
#include <install/installer.h>
#include <install/locate.h>
#include <install/placement.h>
#include <install/portable_switch.h>
#include <user/paths.h>
#include <user/portable.h>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <sys/stat.h>
#include <unistd.h>
static int g_failures = 0, g_checks = 0;
#define CHECK(cond, ...) do { g_checks++; 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;
using portable::Layout;
using portable::Resolve;
static bool Has(const std::string& text, const char* part)
{
return text.find(part) != std::string::npos;
}
static bool HasLine(const std::vector<std::string>& lines, const char* part)
{
for (const std::string& line : lines)
if (Has(line, part))
return true;
return false;
}
static std::string Joined(const std::vector<std::string>& lines)
{
std::string text;
for (const std::string& line : lines)
text += "\n " + line;
return text;
}
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 std::string Read(const fs::path& file)
{
std::ifstream in(file, std::ios::binary);
std::ostringstream text;
text << in.rdbuf();
return text.str();
}
static bool There(const fs::path& path)
{
std::error_code ec;
return fs::exists(fs::symlink_status(path, ec));
}
// --- Pure ---------------------------------------------------------------------
static void PureLayout()
{
const fs::path root = "/home/p/Games/SpeedBreaker";
Layout on = Resolve(root, true, true);
CHECK(on.Active() && on.root == root && on.data == root / "data" && on.cache == root / "cache", "marked: %s %s %s",
on.root.c_str(), on.data.c_str(), on.cache.c_str());
CHECK(!Resolve(root, false, true).Active(), "no marker: off");
CHECK(!Resolve(root, true, false).Active(), "unsupported (Apple): off even with the marker");
CHECK(!Resolve("", true, true).Active(), "SpeedBreaker's folder unknown: off");
CHECK(!Resolve("Games/SpeedBreaker", true, true).Active(), "a relative folder: off");
Layout trailing = Resolve("/home/p/Games/SpeedBreaker/", true, true);
CHECK(trailing.root == root && trailing.data == root / "data", "a trailing separator: %s, %s", trailing.root.c_str(),
trailing.data.c_str());
Layout dotted = Resolve("/home/p/Games/./x/../SpeedBreaker", true, true);
CHECK(dotted.root == root, "normalized: %s", dotted.root.c_str());
#ifdef __APPLE__
CHECK(!portable::kSupported, "never on Apple platforms");
#else
CHECK(portable::kSupported, "Linux (and Windows later)");
#endif
CHECK(Has(portable::Describe(on), "portable mode") && Has(portable::Describe(on), "/home/p/Games/SpeedBreaker/data")
&& Has(portable::Describe(on), "/home/p/Games/SpeedBreaker/cache"), "%s", portable::Describe(on).c_str());
CHECK(portable::Describe(Layout{}).empty(), "nothing to say when off");
}
static void PureRecords()
{
const Layout on = Resolve("/home/p/SB", true, true), off;
using portable::ParseRecord;
using portable::RecordText;
CHECK(RecordText("/home/p/SB/data/game2", on) == "data/game2", "inside: relative: %s",
RecordText("/home/p/SB/data/game2", on).c_str());
CHECK(RecordText("/home/p/SB/SpeedBreaker Game", on) == "SpeedBreaker Game", "%s",
RecordText("/home/p/SB/SpeedBreaker Game", on).c_str());
CHECK(RecordText("/home/p/SB/x/y/", on) == "x/y", "a trailing separator: %s", RecordText("/home/p/SB/x/y/", on).c_str());
CHECK(RecordText("/home/p/SB2/game", on) == "/home/p/SB2/game", "a sibling sharing the prefix: absolute: %s",
RecordText("/home/p/SB2/game", on).c_str());
CHECK(RecordText("/home/p/SB", on) == "/home/p/SB", "the folder itself: absolute");
CHECK(RecordText("/run/media/p/SD/speedbreaker/game", on) == "/run/media/p/SD/speedbreaker/game", "outside: absolute");
CHECK(RecordText("/home/p/SB/data/game2", off) == "/home/p/SB/data/game2", "not portable: absolute");
auto parsed = [](std::string_view text, const Layout& layout) {
std::optional<fs::path> p = ParseRecord(text, layout);
return p ? p->string() : std::string("(none)");
};
CHECK(parsed("data/game2\n", on) == "/home/p/SB/data/game2", "relative, portable: %s", parsed("data/game2\n", on).c_str());
CHECK(parsed("SpeedBreaker Game\r\n", on) == "/home/p/SB/SpeedBreaker Game", "CRLF: %s",
parsed("SpeedBreaker Game\r\n", on).c_str());
CHECK(parsed("data/game2\n", off) == "(none)", "relative, not portable: ignored (as v0.1.1 did)");
CHECK(parsed("/abs/game/\n", off) == "/abs/game", "absolute, trailing separator: %s", parsed("/abs/game/\n", off).c_str());
CHECK(parsed("/abs/game", on) == "/abs/game", "absolute in portable mode: as it is");
CHECK(parsed("../escape", on) == "(none)", "out of the folder: ignored");
CHECK(parsed("data/../../escape", on) == "(none)", "out of the folder by a detour: ignored");
CHECK(parsed("data/../SpeedBreaker Game", on) == "/home/p/SB/SpeedBreaker Game", "a detour that stays inside: %s",
parsed("data/../SpeedBreaker Game", on).c_str());
for (const char* empty : { "", "\n", ".", "./" })
CHECK(parsed(empty, on) == "(none)", "'%s': nothing", empty);
// The point of it: the folder moves, the record still finds the game.
const Layout moved = Resolve("/run/media/p/USB/Old Games/SB", true, true);
CHECK(parsed(RecordText("/home/p/SB/SpeedBreaker Game", on), moved) == "/run/media/p/USB/Old Games/SB/SpeedBreaker Game",
"after a move: %s", parsed(RecordText("/home/p/SB/SpeedBreaker Game", on), moved).c_str());
}
static void Paths()
{
setenv("HOME", "/home/p", 1);
setenv("XDG_DATA_HOME", "/xdg/data", 1);
setenv("XDG_CACHE_HOME", "/xdg/cache", 1);
portable::CurrentLayout() = {};
const fs::path homeData = HomeUserPath(), homeCache = HomeCachePath();
#ifndef __APPLE__
CHECK(homeData == "/xdg/data/speedbreaker" && homeCache == "/xdg/cache/speedbreaker", "home: %s, %s", homeData.c_str(),
homeCache.c_str());
#endif
CHECK(GetUserPath() == homeData && GetCachePath() == homeCache, "not portable: the home folders");
portable::CurrentLayout() = Resolve("/home/p/SB", true, true);
CHECK(GetUserPath() == "/home/p/SB/data", "portable data: %s", GetUserPath().c_str());
CHECK(GetCachePath() == "/home/p/SB/cache", "portable caches: %s", GetCachePath().c_str());
CHECK(DefaultInstallPath() == "/home/p/SB/data/game", "the installer's default: %s", DefaultInstallPath().c_str());
CHECK(GetSavePath(true) == "/home/p/SB/data/save", "saves: %s", GetSavePath(true).c_str());
CHECK(HomeUserPath() == homeData && HomeCachePath() == homeCache, "the home folders still known (for --portable off)");
portable::CurrentLayout() = {};
CHECK(GetUserPath() == homeData, "off again");
unsetenv("XDG_DATA_HOME");
unsetenv("XDG_CACHE_HOME");
}
static FolderFacts Facts(const fs::path& path, FolderState state)
{
FolderFacts f;
f.path = path;
f.state = state;
f.fileSystem = "ext4";
return f;
}
static void PlacementRules()
{
const fs::path root = "/home/p/Games/SpeedBreaker";
PlacementContext c;
c.home = "/home/p";
c.bundle = root;
c.portable = root;
c.installs = { root / "data/game" };
c.largestFile = 653262848;
auto exact = [&](const fs::path& dir, FolderState state = FolderState::Missing) {
return PlaceExact(Facts(dir, state), true, c);
};
for (const char* ok : { "data/game", "SpeedBreaker Game", "data/other", "library", "data2", "caches", "lib2/game" })
{
FolderChoice choice = exact(root / ok);
CHECK(choice.result.Ok(), "%s: allowed in portable mode: %s", ok, choice.result.message.c_str());
CHECK(choice.notes.empty(), "%s: no note (it moves with the folder): %zu", ok, choice.notes.size());
}
FolderChoice choice = exact(root, FolderState::Other);
CHECK(choice.result.error == Error::DestinationNotWritable && Has(choice.result.message, "holds the program")
&& Has(choice.result.message, "/home/p/Games/SpeedBreaker/data/game"), "the folder's top: %s",
choice.result.message.c_str());
for (const char* own : { "lib", "lib/game", "licenses", "licenses/x/y" })
{
choice = exact(root / own);
CHECK(choice.result.error == Error::DestinationNotWritable && Has(choice.result.message, "program's own files"),
"%s: refused: %s", own, choice.result.message.c_str());
}
for (const char* cache : { "cache", "cache/game" })
{
choice = exact(root / cache);
CHECK(choice.result.error == Error::DestinationNotWritable && Has(choice.result.message, "cache folder"),
"%s: refused: %s", cache, choice.result.message.c_str());
}
choice = exact(root / "data", FolderState::Other);
CHECK(choice.result.error == Error::DestinationNotWritable && Has(choice.result.message, "settings and saves"),
"data/ itself: refused: %s", choice.result.message.c_str());
choice = exact(root / "data/game/inner");
CHECK(!choice.result.Ok() && Has(choice.result.message, "inside the game installed"), "inside the install: %s",
choice.result.message.c_str());
choice = exact("/home/p/Elsewhere");
CHECK(choice.result.Ok() && choice.notes.size() == 1 && Has(choice.notes[0], "outside SpeedBreaker's folder")
&& Has(choice.notes[0], "won't move"), "outside: allowed, with a note: %zu %s", choice.notes.size(),
choice.notes.empty() ? "" : choice.notes[0].c_str());
// Choose a folder... in portable mode.
choice = PlaceInPicked(Facts(root, FolderState::Other), c);
CHECK(choice.result.Ok() && choice.gameDir == root / kPickedSubfolder, "the folder picked: a subfolder in it: %s %s",
choice.gameDir.c_str(), choice.result.message.c_str());
choice = PlaceInPicked(Facts(root / "data", FolderState::Other), c);
CHECK(choice.result.Ok() && choice.gameDir == root / "data" / kPickedSubfolder, "data/ picked: %s %s", choice.gameDir.c_str(),
choice.result.message.c_str());
choice = PlaceInPicked(Facts(root / "lib", FolderState::Other), c);
CHECK(choice.result.error == Error::DestinationNotWritable && Has(choice.result.message, "/home/p/Games/SpeedBreaker/lib "),
"lib/ picked: refused, naming lib/: %s", choice.result.message.c_str());
// Not portable: all of SpeedBreaker's folder is refused, as in v0.1.1.
c.portable.clear();
c.installs = { "/home/p/.local/share/speedbreaker/game" };
for (const char* inside : { "data/game", "SpeedBreaker Game", "library" })
{
choice = exact(root / inside);
CHECK(choice.result.error == Error::DestinationNotWritable && Has(choice.result.message, "SpeedBreaker's own folder"),
"%s, not portable: refused: %s", inside, choice.result.message.c_str());
}
choice = exact("/home/p/Elsewhere");
CHECK(choice.result.Ok() && choice.notes.empty(), "not portable: no note: %zu", choice.notes.size());
}
// --- Through the disk ----------------------------------------------------------
// A SteamOS-style SpeedBreaker folder: speedbreaker.sh at the top, the program in lib/.
static fs::path MakeBundle(const fs::path& root)
{
Write(root / "speedbreaker.sh", "#!/bin/bash\n");
Write(root / "lib/SpeedBreaker", "program");
Write(root / "README.md", "readme");
return root;
}
static const ManifestFile s_files[] = { { "default.xex", 16, "00" } };
static const Manifest s_manifest{ "test-1", 0x454107D9, s_files, 16 };
static void FakeInstall(const fs::path& dir)
{
Write(dir / "default.xex", std::string(16, 'x'));
Write(dir / kInstallMarker, "manifest_version = \"test-1\"\n");
}
static void Detection(const fs::path& scratch)
{
const fs::path root = MakeBundle(scratch / "detect/SpeedBreaker");
SetProgramPath((root / "lib/SpeedBreaker").c_str());
const fs::path bundle = BundleFolder();
#ifndef __APPLE__
CHECK(bundle == root, "the bundle's folder: %s", bundle.c_str());
#endif
CHECK(!portable::MarkerIn(root) && !portable::Detect(bundle).Active(), "no portable.txt: off");
Write(root / portable::kMarker, "");
CHECK(portable::MarkerIn(root), "portable.txt (empty) seen");
CHECK(portable::Detect(bundle).Active() == portable::kSupported, "portable.txt (empty): on where supported");
if (portable::kSupported)
CHECK(portable::Current().data == root / "data" && GetUserPath() == root / "data", "current: %s",
portable::Current().data.c_str());
fs::remove(root / portable::kMarker);
fs::create_directory(root / portable::kMarker);
CHECK(!portable::MarkerIn(root) && !portable::Detect(bundle).Active(), "a folder named portable.txt: off");
fs::remove(root / portable::kMarker);
Write(root / "elsewhere.txt", "x");
fs::create_symlink("elsewhere.txt", root / portable::kMarker);
CHECK(portable::MarkerIn(root), "a link to a file: seen");
CHECK(portable::Detect(bundle).Active() == portable::kSupported, "a link to a file: on where supported");
fs::remove(root / portable::kMarker);
fs::create_symlink("nowhere.txt", root / portable::kMarker);
CHECK(!portable::MarkerIn(root), "a link to nothing: not seen");
fs::remove(root / portable::kMarker);
CHECK(!portable::MarkerIn(root) && !portable::Detect(bundle).Active(), "removed: off");
CHECK(!portable::MarkerIn(""), "no folder: no marker");
CHECK(GetUserPath() == HomeUserPath(), "off: the home folder");
CHECK(!portable::Detect("").Active(), "unknown folder: off");
// The startup write check.
Layout layout = Resolve(root, true, true);
CHECK(portable::CheckWritable(layout) == 0 && fs::is_directory(root / "data"), "makes data/ and writes there");
CHECK(!There(root / "data/.write-check"), "and leaves nothing behind");
CHECK(portable::CheckWritable(Layout{}) == 0, "off: nothing to check");
if (geteuid() != 0)
{
chmod((root / "data").c_str(), 0555);
int error = portable::CheckWritable(layout);
CHECK(error == EACCES, "a read-only data folder: %d (%s)", error, strerror(error));
chmod((root / "data").c_str(), 0755);
fs::remove_all(root / "data");
chmod(root.c_str(), 0555);
error = portable::CheckWritable(layout);
CHECK(error == EACCES && !There(root / "data"), "a read-only SpeedBreaker folder, no data/: %d (%s)", error,
strerror(error));
chmod(root.c_str(), 0755);
}
}
static void RecordsOnDisk(const fs::path& scratch)
{
unsetenv("NFSMW_GAME_DIR");
setenv("HOME", (scratch / "home").c_str(), 1);
fs::create_directories(scratch / "home");
chdir(scratch.c_str()); // no ./game/files here for FindGameInstall to find first
fs::path root = MakeBundle(scratch / "a/SpeedBreaker");
SetProgramPath((root / "lib/SpeedBreaker").c_str());
// The layout as Detect makes it where it's supported; set directly on a Mac.
Write(root / portable::kMarker, "");
auto activate = [&](const fs::path& folder) {
portable::Detect(BundleFolder());
if (!portable::kSupported)
portable::CurrentLayout() = Resolve(ResolvedFolder(folder), true, true);
return portable::Current().Active();
};
CHECK(activate(root), "on");
root = ResolvedFolder(root);
const fs::path chosen = root / kPickedSubfolder;
FakeInstall(chosen);
CHECK(RecordInstallPath(chosen), "recorded");
CHECK(Read(root / "data/game-location") == std::string(kPickedSubfolder) + "\n", "relative in the file: '%s'",
Read(root / "data/game-location").c_str());
CHECK(RecordedInstallPath() == chosen, "read back: %s", RecordedInstallPath() ? RecordedInstallPath()->c_str() : "(none)");
std::optional<GameInstall> found = FindGameInstall(s_manifest);
CHECK(found && found->path == chosen && found->origin == InstallOrigin::User, "found there: %s",
found ? found->path.c_str() : "(none)");
std::vector<InstallDestination> where = InstallDestinations();
CHECK(where.size() == 1 && where[0].label == "SpeedBreaker's folder" && where[0].path == root / "data/game",
"the To row: only SpeedBreaker's folder: %zu, %s", where.size(), where.empty() ? "" : where[0].path.c_str());
PlacementContext context = CurrentPlacementContext(s_manifest);
CHECK(context.portable == root, "the placement context: %s", context.portable.c_str());
FolderChoice lib = CheckExactFolder(root / "lib/game", true, s_manifest);
CHECK(lib.result.error == Error::DestinationNotWritable, "lib/ refused through the disk: %s", lib.result.message.c_str());
FolderChoice inside = CheckExactFolder(root / "data/game", true, s_manifest);
CHECK(inside.result.Ok() || inside.result.error == Error::NotEnoughSpace, "data/game allowed through the disk: %s",
inside.result.message.c_str());
// The whole folder moves (another path, a space in it): the record follows.
fs::create_directories(scratch / "b");
fs::rename(scratch / "a/SpeedBreaker", scratch / "b/Moved Folder");
fs::path moved = scratch / "b/Moved Folder";
SetProgramPath((moved / "lib/SpeedBreaker").c_str());
CHECK(activate(moved), "on, moved");
moved = ResolvedFolder(moved);
CHECK(portable::Current().root == moved, "the new root: %s", portable::Current().root.c_str());
CHECK(RecordedInstallPath() == moved / kPickedSubfolder, "the record, moved: %s",
RecordedInstallPath() ? RecordedInstallPath()->c_str() : "(none)");
found = FindGameInstall(s_manifest);
CHECK(found && found->path == moved / kPickedSubfolder, "found after the move: %s", found ? found->path.c_str() : "(none)");
// The default place: no record; found after a move too.
FakeInstall(moved / "data/game");
CHECK(RecordInstallPath(moved / "data/game") && !There(moved / "data/game-location"), "the default: the record goes");
found = FindGameInstall(s_manifest);
CHECK(found && found->path == moved / "data/game", "the default install: %s", found ? found->path.c_str() : "(none)");
// Outside the folder: absolute.
const fs::path outside = ResolvedFolder(scratch / "outside/game");
FakeInstall(outside);
CHECK(RecordInstallPath(outside) && Read(moved / "data/game-location") == outside.string() + "\n",
"outside: absolute: '%s'", Read(moved / "data/game-location").c_str());
CHECK(RecordedInstallPath() == outside, "read back: %s", RecordedInstallPath() ? RecordedInstallPath()->c_str() : "(none)");
// Off: the home folder's data, and a relative record means nothing there.
portable::CurrentLayout() = {};
CHECK(GetUserPath() == HomeUserPath() && !RecordedInstallPath(), "off: the home folder, no record");
Write(HomeUserPath() / "game-location", "SpeedBreaker Game\n");
CHECK(!RecordedInstallPath(), "a relative record outside portable mode: ignored");
fs::remove_all(scratch / "home");
fs::remove_all(scratch / "a");
fs::remove_all(scratch / "b");
fs::remove_all(scratch / "outside");
}
// --- --portable on|off ---------------------------------------------------------
struct Copier
{
std::vector<std::pair<fs::path, fs::path>> calls;
bool fail = false;
GameCopier Get()
{
return [this](const fs::path& from, const fs::path& to) -> Result {
calls.push_back({ from, to });
if (fail)
{
Result r;
r.error = Error::WriteError;
r.message = "the stand-in failed";
return r;
}
FakeInstall(to);
return {};
};
}
};
static fs::file_time_type Stamp(int daysAgo)
{
return fs::file_time_type::clock::now() - std::chrono::hours(24 * daysAgo);
}
// A home data folder with settings, a save, a log (dated), and the game at its default place.
static void MakeHome(const fs::path& home, bool game = true)
{
Write(home / "settings.toml", "[video]\nfullscreen = true\n");
Write(home / "profile_settings.bin", "profile");
Write(home / "save/content/slot1", "career");
Write(home / "logs/2026-10-01_10-00-00.log", "an old session");
fs::last_write_time(home / "logs/2026-10-01_10-00-00.log", Stamp(9));
if (game)
FakeInstall(home / "game");
}
static void SwitchOn(const fs::path& scratch)
{
const fs::path base = scratch / "on";
const fs::path home = base / "home/.local/share/speedbreaker";
const fs::path root = ResolvedFolder(MakeBundle(base / "Games/SpeedBreaker"));
const fs::path data = root / "data";
MakeHome(home);
const auto logTime = fs::last_write_time(home / "logs/2026-10-01_10-00-00.log");
Copier copier;
std::vector<std::string> said;
Result r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.Ok(), "on: %s%s", r.message.c_str(), Joined(said).c_str());
CHECK(Read(data / "settings.toml") == "[video]\nfullscreen = true\n" && Read(data / "save/content/slot1") == "career"
&& Read(data / "profile_settings.bin") == "profile", "settings and saves copied");
CHECK(fs::last_write_time(data / "logs/2026-10-01_10-00-00.log") == logTime, "the log keeps its date");
CHECK(copier.calls.size() == 1 && copier.calls[0].first == ResolvedFolder(home / "game")
&& copier.calls[0].second == root / "data.partial/game", "the game copied once, verified, into the copy: %zu %s -> %s",
copier.calls.size(), copier.calls.empty() ? "" : copier.calls[0].first.c_str(),
copier.calls.empty() ? "" : copier.calls[0].second.c_str());
CHECK(IsUsableInstall(data / "game", true, s_manifest), "the game in data/game");
CHECK(!There(data / "game-location") && !There(data / kPortablePartialTag) && !There(root / "data.partial"),
"no record, no tag, no partial copy left");
CHECK(fs::is_regular_file(root / portable::kMarker) && Has(Read(root / portable::kMarker), "portable mode"),
"portable.txt written");
CHECK(Read(home / "settings.toml") == "[video]\nfullscreen = true\n" && IsUsableInstall(home / "game", true, s_manifest),
"the home folder left as it was");
CHECK(HasLine(said, "Portable mode is on") && HasLine(said, "Nothing was removed") && HasLine(said, "4 files"),
"said:%s", Joined(said).c_str());
// Again: already on.
said.clear();
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.error == Error::DestinationNotEmpty && Has(r.message, "already on") && copier.calls.size() == 1, "again: %s",
r.message.c_str());
// portable.txt deleted by hand, data/ still there: refused, nothing changed.
fs::remove(root / portable::kMarker);
Write(data / "settings.toml", "changed in portable mode");
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.error == Error::DestinationNotEmpty && Has(r.message, "already holds files")
&& Read(data / "settings.toml") == "changed in portable mode" && !There(root / portable::kMarker),
"data/ there: refused: %s", r.message.c_str());
fs::remove_all(data);
// An unfinished copy from an earlier attempt goes; anything else of that name is refused.
Write(root / "data.partial" / kPortablePartialTag, "");
Write(root / "data.partial/settings.toml", "half");
said.clear();
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.Ok() && HasLine(said, "unfinished copy") && Read(data / "settings.toml") == "[video]\nfullscreen = true\n",
"an unfinished copy replaced: %s%s", r.message.c_str(), Joined(said).c_str());
fs::remove_all(data);
fs::remove(root / portable::kMarker);
Write(root / "data.partial/mine.txt", "the player's");
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.error == Error::DestinationNotEmpty && Read(root / "data.partial/mine.txt") == "the player's"
&& !There(root / portable::kMarker), "a data.partial of the player's: refused, kept: %s", r.message.c_str());
fs::remove_all(root / "data.partial");
// The game copy fails: nothing changes.
copier.fail = true;
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.error == Error::WriteError && !There(data) && !There(root / "data.partial") && !There(root / portable::kMarker),
"a failed game copy: nothing left: %s", r.message.c_str());
copier.fail = false;
// An empty data/ (made by a start without portable.txt? by hand) is used.
fs::create_directory(data);
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.Ok() && IsUsableInstall(data / "game", true, s_manifest), "an empty data/: used: %s", r.message.c_str());
fs::remove_all(data);
fs::remove(root / portable::kMarker);
// The game recorded elsewhere: that one is copied (as FindGameInstall
// picks it: before one at the default place), and its record isn't.
const fs::path elsewhere = ResolvedFolder(base / "card/speedbreaker/game");
FakeInstall(elsewhere);
Write(home / "game-location", elsewhere.string() + "\n");
copier.calls.clear();
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.Ok() && copier.calls.size() == 1 && copier.calls[0].first == elsewhere && !There(data / "game-location"),
"the recorded game: %s %s", r.message.c_str(), copier.calls.empty() ? "" : copier.calls[0].first.c_str());
CHECK(!There(data / "game") || IsUsableInstall(data / "game", true, s_manifest), "data/game is the copied game only");
fs::remove_all(data);
fs::remove(root / portable::kMarker);
fs::remove_all(home / "game");
copier.calls.clear();
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.Ok() && copier.calls.size() == 1 && copier.calls[0].first == elsewhere, "the recorded game, none at the "
"default place: %s %s", r.message.c_str(), copier.calls.empty() ? "" : copier.calls[0].first.c_str());
fs::remove_all(data);
fs::remove(root / portable::kMarker);
// Recorded, but not there (a card out), and none at the default place: refused, nothing copied.
fs::remove_all(elsewhere);
copier.calls.clear();
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.error == Error::NotFound && Has(r.message, "isn't there now") && Has(r.message, "game-location")
&& copier.calls.empty() && !There(data) && !There(root / portable::kMarker), "a lost game: refused: %s",
r.message.c_str());
// Recorded and not there, but one at the default place: that one (as FindGameInstall would).
FakeInstall(home / "game");
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.Ok() && copier.calls.size() == 1 && copier.calls[0].first == ResolvedFolder(home / "game"),
"the default one when the record's is gone: %s", r.message.c_str());
fs::remove_all(data);
fs::remove(root / portable::kMarker);
fs::remove(home / "game-location");
// A game folder recorded INSIDE the home data folder isn't copied as data.
fs::remove_all(home / "game");
const fs::path inHome = ResolvedFolder(home / kPickedSubfolder);
FakeInstall(inHome);
Write(home / "game-location", inHome.string() + "\n");
copier.calls.clear();
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.Ok() && copier.calls.size() == 1 && copier.calls[0].first == inHome && !There(data / kPickedSubfolder),
"a game inside the data folder: copied once, as the game: %s", r.message.c_str());
fs::remove_all(data);
fs::remove(root / portable::kMarker);
fs::remove_all(inHome);
fs::remove(home / "game-location");
// No game: the data only. A link in the data folder: not followed, said.
fs::create_symlink("/", home / "a-link");
copier.calls.clear();
said.clear();
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.Ok() && copier.calls.empty() && HasLine(said, "No installed game") && HasLine(said, "a-link")
&& !There(data / "a-link") && fs::is_regular_file(root / portable::kMarker), "no game: %s%s", r.message.c_str(),
Joined(said).c_str());
fs::remove_all(data);
fs::remove(root / portable::kMarker);
// No home data folder at all: a fresh portable folder.
said.clear();
r = MakePortable(root, base / "nobody/.local/share/speedbreaker", copier.Get(), said, s_manifest);
CHECK(r.Ok() && fs::is_directory(data) && fs::is_regular_file(root / portable::kMarker) && HasLine(said, "nothing of them"),
"nothing at home: %s%s", r.message.c_str(), Joined(said).c_str());
fs::remove_all(data);
fs::remove(root / portable::kMarker);
r = MakePortable("", home, copier.Get(), said, s_manifest);
CHECK(r.error == Error::NotFound, "SpeedBreaker's folder unknown: %s", r.message.c_str());
if (geteuid() != 0)
{
chmod(root.c_str(), 0555);
r = MakePortable(root, home, copier.Get(), said, s_manifest);
CHECK(r.error == Error::DestinationNotWritable && !There(root / portable::kMarker), "a read-only folder: %s",
r.message.c_str());
chmod(root.c_str(), 0755);
}
fs::remove_all(base);
}
static void SwitchOff(const fs::path& scratch)
{
const fs::path base = scratch / "off";
const fs::path home = base / "home/.local/share/speedbreaker";
const fs::path root = ResolvedFolder(MakeBundle(base / "Games/SpeedBreaker"));
const fs::path data = root / "data";
// Before: a home folder (the old copies), a portable folder with newer settings and the game.
MakeHome(home);
Write(home / "settings.toml", "old");
MakeHome(data);
Write(data / "settings.toml", "newer, portable");
Write(root / portable::kMarker, "");
Layout layout = Resolve(root, true, true);
Copier copier;
std::vector<std::string> said;
Result r = LeavePortable(layout, home, copier.Get(), said, s_manifest);
CHECK(r.Ok(), "off: %s%s", r.message.c_str(), Joined(said).c_str());
CHECK(Read(home / "settings.toml") == "newer, portable" && Read(home / "save/content/slot1") == "career",
"the portable data in the home folder");
CHECK(copier.calls.size() == 1 && copier.calls[0].first == data / "game"
&& copier.calls[0].second == fs::path(ResolvedFolder(home).string() + ".partial") / "game",
"the game copied back, verified: %zu %s", copier.calls.size(), copier.calls.empty() ? "" : copier.calls[0].second.c_str());
CHECK(IsUsableInstall(home / "game", true, s_manifest), "the game at home");
CHECK(!There(root / portable::kMarker), "portable.txt removed");
CHECK(Read(data / "settings.toml") == "newer, portable" && IsUsableInstall(data / "game", true, s_manifest),
"data/ left as it was");
fs::path aside;
for (const auto& entry : fs::directory_iterator(home.parent_path()))
if (entry.path().filename().string().starts_with("speedbreaker.before-portable-off-"))
aside = entry.path();
CHECK(!aside.empty() && Read(aside / "settings.toml") == "old" && IsUsableInstall(aside / "game", true, s_manifest),
"the old home folder kept, renamed aside: %s", aside.c_str());
CHECK(HasLine(said, "Renamed the data folder") && HasLine(said, "Portable mode is off"), "said:%s", Joined(said).c_str());
CHECK(!There(fs::path(home.string() + ".partial")), "no partial copy left");
// Not portable: nothing to do.
r = LeavePortable(Layout{}, home, copier.Get(), said, s_manifest);
CHECK(r.error == Error::NotFound && Has(r.message, "isn't on"), "not portable: %s", r.message.c_str());
// A game recorded outside the folder stays there, recorded at home; the home folder there is empty: replaced.
fs::remove_all(home.parent_path());
fs::create_directories(home);
fs::remove_all(data / "game");
const fs::path outside = ResolvedFolder(base / "card/speedbreaker/game");
FakeInstall(outside);
Write(data / "game-location", outside.string() + "\n");
Write(root / portable::kMarker, "");
copier.calls.clear();
said.clear();
r = LeavePortable(layout, home, copier.Get(), said, s_manifest);
CHECK(r.Ok() && copier.calls.empty() && Read(home / "game-location") == outside.string() + "\n"
&& HasLine(said, "stays where it is") && !HasLine(said, "Renamed"), "outside: recorded as it is: %s%s",
r.message.c_str(), Joined(said).c_str());
// A game recorded relative (inside the folder, not data/game): copied to the home folder's game/.
fs::remove_all(home.parent_path());
FakeInstall(root / kPickedSubfolder);
Write(data / "game-location", std::string(kPickedSubfolder) + "\n");
Write(root / portable::kMarker, "");
copier.calls.clear();
r = LeavePortable(layout, home, copier.Get(), said, s_manifest);
CHECK(r.Ok() && copier.calls.size() == 1 && copier.calls[0].first == root / kPickedSubfolder
&& !There(home / "game-location") && IsUsableInstall(home / "game", true, s_manifest),
"a relative record: copied home: %s", r.message.c_str());
// The game copy fails: the home folder and portable.txt as they were.
MakeHome(home);
Write(home / "settings.toml", "untouched");
Write(root / portable::kMarker, "");
copier.fail = true;
r = LeavePortable(layout, home, copier.Get(), said, s_manifest);
CHECK(r.error == Error::WriteError && Read(home / "settings.toml") == "untouched" && There(root / portable::kMarker)
&& !There(fs::path(ResolvedFolder(home).string() + ".partial")), "a failed copy: nothing changed: %s",
r.message.c_str());
copier.fail = false;
// This computer's data folder inside SpeedBreaker's folder (XDG_DATA_HOME pointed there): refused.
r = LeavePortable(layout, root / "xdg/speedbreaker", copier.Get(), said, s_manifest);
CHECK(r.error == Error::DestinationNotWritable && Has(r.message, "inside SpeedBreaker's folder"), "home inside: %s",
r.message.c_str());
fs::remove_all(base);
}
int main(int argc, char** argv)
{
PureLayout();
PureRecords();
Paths();
PlacementRules();
fs::path parent = argc > 1 ? fs::path(argv[1]) : fs::current_path();
fs::create_directories(parent);
std::string pattern = (parent / "portable-test-XXXXXX").string();
if (!mkdtemp(pattern.data()))
{
printf("can't make a scratch folder in %s: %s\n", parent.c_str(), strerror(errno));
return 2;
}
const fs::path scratch = fs::weakly_canonical(pattern);
Detection(scratch);
RecordsOnDisk(scratch);
SwitchOn(scratch);
SwitchOff(scratch);
portable::CurrentLayout() = {};
std::error_code ec;
fs::remove_all(scratch, ec);
if (g_failures)
{
printf("%d of %d checks failed\n", g_failures, g_checks);
return 1;
}
printf("portable_test: all %d checks passed\n", g_checks);
return 0;
}
+451
View File
@@ -0,0 +1,451 @@
// Tests for gpu/render_scale.h: the internal resolution as a ratio, 1.5x
// (3/2) beside the whole scales. From the repo root:
// clang++ -std=c++20 -O2 -Iruntime tests/render_scale_test.cpp -o build/render_scale_test
// build/render_scale_test runtime/gpu/renderer.cpp
// SB_SCALE_FUNCTIONS is the code the shaders compile (GLSL_SCALE_CODE), run
// here as C++. Checked:
// - whole scales give exactly the arithmetic from before 1.5x (edges g x s,
// a texel's samples at word x s + j x sx + i), so 2x and 3x don't move;
// - at 3/2: edges, ownership (every host pixel one guest owner), the box
// filter's weights, the depth sample, the inverse of an edge;
// - the scaled memory's layout: every host sample of a page one word of
// its slot, and a surface resolved in strips and side-by-side halves
// (as the game resolves its scene) read back by the texture and
// front-buffer untiles sample for sample, also from a texture that
// starts at a page inside it, through both resolve paths (per thread
// and coalesced, modelled as the shader does them);
// - NFSMW_RENDER_SCALE's parse and the settings' half steps;
// - with renderer.cpp's path (ctest passes it): the shaders that read or
// write the scaled memory splice the shared code in.
// Planted bugs this catches (each tried): sbBlockIndex keeping bit 2,
// sbHostEdge rounding to nearest, sbOwner off by one, sbOverlap's upper
// bound one pixel short, a coalesced group's block index taken from the
// pixel (not the word), the untile's block corner not multiplied by den.
#include <gpu/render_scale.h>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <random>
#include <set>
#include <sstream>
#include <string>
#include <vector>
using gpu::AxisScale;
using gpu::RenderScale;
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 gpu::ScaleGlsl G; // the shaders' functions
static const AxisScale kHalf{ 3, 2 };
static const AxisScale kAxes[] = { { 1, 1 }, { 3, 2 }, { 2, 1 }, { 3, 1 }, { 4, 1 } };
// renderer.cpp's Tiled2D (Xenos 2D tiling), as its shaders' tiled2D.
static uint32_t Tiled2D(uint32_t x, uint32_t y, uint32_t pitch, uint32_t bppLog2)
{
uint32_t outer = (((y >> 5) * (pitch >> 5)) + (x >> 5)) << 6;
uint32_t inner = (((y >> 1) & 7) << 3) | (x & 7);
uint32_t oi = (outer | inner) << bppLog2;
uint32_t bank = (y >> 4) & 1;
uint32_t pipe = ((x >> 3) & 3) ^ (((y >> 3) & 1) << 1);
return ((y & 1) << 4) | (pipe << 6) | (bank << 11) | (oi & 0xF) | (((oi >> 4) & 1) << 5) | (((oi >> 5) & 7) << 8) | ((oi >> 8) << 12);
}
static void TestWholeScalesUnchanged()
{
printf("whole scales: the arithmetic from before\n");
for (uint32_t s : { 1u, 2u, 3u, 4u })
{
for (uint32_t g = 0; g < 4096; g++)
{
CHECK(G.sbHostEdge(g, s, 1) == g * s, "%ux: edge %u -> %u", s, g, G.sbHostEdge(g, s, 1));
CHECK(G.sbHostSize(g, s, 1) == g * s, "%ux: size %u", s, g);
CHECK(G.sbCenterHost(g, s, 1) == g * s, "%ux: depth sample of %u is %u, not its first", s, g, G.sbCenterHost(g, s, 1));
}
for (uint32_t h = 0; h < 4096; h++)
CHECK(G.sbOwner(h, s, 1) == h / s, "%ux: owner of %u", s, h);
}
for (uint32_t sx : { 1u, 2u, 3u, 4u })
for (uint32_t sy : { 1u, 2u, 3u, 4u })
{
RenderScale r = RenderScale::Whole(sx, sy);
CHECK(r.SlotWords() == 1024 * sx * sy, "%ux%u: slot %u words", sx, sy, r.SlotWords());
for (uint32_t w = 0; w < 1024; w++)
for (uint32_t j = 0; j < sy; j++)
for (uint32_t i = 0; i < sx; i++)
CHECK(G.sbSampleWord(w, i, j, sx, sy, 1, 1) == w * sx * sy + j * sx + i, "%ux%u: word %u sample %u,%u", sx, sy, w, i, j);
}
}
static void TestHalfAxis()
{
printf("3/2: edges, ownership, weights, depth sample\n");
const uint32_t n = 3, d = 2;
// Even guest edges land exactly; odd ones on the floor of x.5.
for (uint32_t g = 0; g < 4096; g++)
{
uint32_t e = G.sbHostEdge(g, n, d);
CHECK(g % 2 == 1 || e * 2 == g * 3, "edge %u -> %u", g, e);
CHECK(g % 2 == 0 || e * 2 + 1 == g * 3, "odd edge %u -> %u", g, e);
CHECK(G.sbGuestEdge(e, n, d) == g, "edge %u -> %u -> %u", g, e, G.sbGuestEdge(e, n, d));
CHECK(G.sbHostSize(g, n, d) == (g * 3 + 1) / 2, "size %u", g);
}
CHECK(G.sbHostEdge(1280, n, d) == 1920 && G.sbHostEdge(720, n, d) == 1080, "1280x720 -> %ux%u", G.sbHostEdge(1280, n, d), G.sbHostEdge(720, n, d));
// Every host pixel has one owner, and the owners' spans tile the line.
for (uint32_t h = 0; h < 6144; h++)
{
uint32_t g = G.sbOwner(h, n, d);
CHECK(G.sbHostEdge(g, n, d) <= h && h < G.sbHostEdge(g + 1, n, d), "host %u: owner %u spans %u..%u", h, g, G.sbHostEdge(g, n, d),
G.sbHostEdge(g + 1, n, d));
}
// Spans: even pixels one host pixel, odd ones two.
for (uint32_t g = 0; g < 64; g++)
{
uint32_t span = G.sbHostEdge(g + 1, n, d) - G.sbHostEdge(g, n, d);
CHECK(span == (g % 2 ? 2u : 1u), "guest %u spans %u", g, span);
}
// Box weights: num a guest pixel, den a host pixel; 2:1 and 1:2.
for (uint32_t g = 0; g < 512; g++)
{
uint32_t sum = 0;
for (uint32_t h = 0; h < 1024; h++)
sum += G.sbOverlap(g, h, n, d);
CHECK(sum == n, "guest %u weighs %u", g, sum);
uint32_t lo = G.sbHostEdge(g, n, d), hi = G.sbHostSize(g + 1, n, d);
for (uint32_t h = 0; h < 1024; h++)
CHECK((G.sbOverlap(g, h, n, d) != 0) == (h >= lo && h < hi), "guest %u host %u: weight %u outside %u..%u", g, h,
G.sbOverlap(g, h, n, d), lo, hi);
}
for (uint32_t h = 0; h < 768; h++)
{
uint32_t sum = 0;
for (uint32_t g = 0; g < 512; g++)
sum += G.sbOverlap(g, h, n, d);
CHECK(sum == d, "host %u spread %u", h, sum);
}
CHECK(G.sbOverlap(0, 0, n, d) == 2 && G.sbOverlap(0, 1, n, d) == 1 && G.sbOverlap(1, 1, n, d) == 1 && G.sbOverlap(1, 2, n, d) == 2,
"weights %u %u | %u %u", G.sbOverlap(0, 0, n, d), G.sbOverlap(0, 1, n, d), G.sbOverlap(1, 1, n, d), G.sbOverlap(1, 2, n, d));
// The depth sample: the host pixel holding the guest pixel's centre.
for (uint32_t g = 0; g < 4096; g++)
{
uint32_t h = G.sbCenterHost(g, n, d);
double centre = (g + 0.5) * 1.5;
CHECK(double(h) <= centre && centre < double(h + 1), "depth sample of %u is %u (centre %.2f)", g, h, centre);
}
// Rectangles sharing a guest edge share the host edge (no gap, no overlap):
// the split anywhere of [0, 1280) gives host spans that add up.
for (uint32_t cut = 0; cut <= 1280; cut++)
CHECK(G.sbHostEdge(cut, n, d) - G.sbHostEdge(0, n, d) + G.sbHostEdge(1280, n, d) - G.sbHostEdge(cut, n, d) == 1920, "cut %u", cut);
}
// The box filter on a picture: a step at an even guest column stays exact,
// a flat picture stays flat, and the mean over whole blocks is the host mean.
static void TestBoxFilter()
{
printf("3/2: the 1x average\n");
const uint32_t n = 3, d = 2, W = 64, H = 4;
const uint32_t HW = G.sbHostEdge(W, n, d), HH = G.sbHostEdge(H, n, d);
auto average = [&](const std::vector<double>& host, uint32_t gx, uint32_t gy) {
double v = 0;
for (uint32_t j = G.sbHostEdge(gy, n, d); j < G.sbHostSize(gy + 1, n, d); j++)
for (uint32_t i = G.sbHostEdge(gx, n, d); i < G.sbHostSize(gx + 1, n, d); i++)
v += double(G.sbOverlap(gx, i, n, d) * G.sbOverlap(gy, j, n, d)) * host[j * HW + i];
return v / double(n * n);
};
std::vector<double> host(HW * HH);
for (uint32_t edge : { 10u, 32u, 50u }) // even guest columns
{
for (uint32_t j = 0; j < HH; j++)
for (uint32_t i = 0; i < HW; i++)
host[j * HW + i] = i < G.sbHostEdge(edge, n, d) ? 0.25 : 0.75;
for (uint32_t g = 0; g < W; g++)
CHECK(average(host, g, 1) == (g < edge ? 0.25 : 0.75), "step at %u: guest %u averages %.4f", edge, g, average(host, g, 1));
}
std::mt19937 rng(7);
for (double& v : host)
v = double(rng() % 1024) / 1023.0;
double hostSum = 0, guestSum = 0;
for (double v : host)
hostSum += v;
for (uint32_t gy = 0; gy < H; gy++)
for (uint32_t gx = 0; gx < W; gx++)
guestSum += average(host, gx, gy);
CHECK(std::fabs(hostSum / (HW * HH) - guestSum / (W * H)) < 1e-12, "mean %.9f host, %.9f guest", hostSum / (HW * HH), guestSum / (W * H));
}
// Every (word, owned host sample) of a page is one word of its slot, and
// every slot word is one of them; the block word inverts the block index.
static void TestSlotLayout()
{
printf("scaled memory: every sample one slot word\n");
for (const AxisScale& ax : kAxes)
for (const AxisScale& ay : kAxes)
{
RenderScale r{ ax, ay };
const uint32_t words = r.SlotWords();
CHECK(words * r.PixelsDen() == 1024 * r.PixelsNum(), "%s: %u words", gpu::ScaleName(r).c_str(), words);
std::vector<uint32_t> hits(words, 0);
for (uint32_t w = 0; w < 1024; w++)
{
uint32_t subX = G.sbSubX(w, ax.den), subY = G.sbSubY(w, ay.den);
uint32_t b = G.sbBlockIndex(w, ax.den, ay.den);
CHECK(G.sbBlockWord(b, ax.den, ay.den) == (w & ~(ax.den == 2 ? 1u : 0u) & ~(ay.den == 2 ? 4u : 0u)), "%s: word %u block %u",
gpu::ScaleName(r).c_str(), w, b);
for (uint32_t j = G.sbHostEdge(subY, ay.num, ay.den); j < G.sbHostEdge(subY + 1, ay.num, ay.den); j++)
for (uint32_t i = G.sbHostEdge(subX, ax.num, ax.den); i < G.sbHostEdge(subX + 1, ax.num, ax.den); i++)
{
uint32_t k = G.sbSampleWord(w, i, j, ax.num, ay.num, ax.den, ay.den);
if (k < words)
hits[k]++;
else
CHECK(false, "%s: word %u sample %u,%u at %u of %u", gpu::ScaleName(r).c_str(), w, i, j, k, words);
}
}
uint32_t bad = 0;
for (uint32_t k = 0; k < words; k++)
bad += hits[k] != 1;
CHECK(bad == 0, "%s: %u slot words not written exactly once", gpu::ScaleName(r).c_str(), bad);
}
// The block's texels are the words its sub bits name: at 3/2 a 2x2 block
// at even x, y of a tiled surface is one block, whatever the pitch.
for (uint32_t pitch : { 640u, 1280u, 1600u, 288u })
for (uint32_t y = 0; y < 64; y += 2)
for (uint32_t x = 0; x < pitch && x < 256; x += 2)
{
uint32_t a = Tiled2D(x, y, pitch, 2);
uint32_t w = (a & 4095) >> 2, page = a >> 12;
uint32_t b = G.sbBlockIndex(w, 2, 2);
for (uint32_t dy = 0; dy < 2; dy++)
for (uint32_t dx = 0; dx < 2; dx++)
{
uint32_t t = Tiled2D(x + dx, y + dy, pitch, 2), tw = (t & 4095) >> 2;
CHECK((t >> 12) == page && G.sbBlockIndex(tw, 2, 2) == b && G.sbSubX(tw, 2) == dx && G.sbSubY(tw, 2) == dy,
"pitch %u texel %u,%u: page %u block %u sub %u,%u (block %u at %u,%u)", pitch, x + dx, y + dy, t >> 12,
G.sbBlockIndex(tw, 2, 2), G.sbSubX(tw, 2), G.sbSubY(tw, 2), b, x, y);
}
}
}
// The scaled memory, modelled: a page table and a pool of slots.
struct Pool
{
RenderScale scale;
std::vector<uint32_t> words;
std::vector<uint32_t> table; // page -> slot + 1
uint32_t slots = 0;
explicit Pool(RenderScale s, uint32_t pages) : scale(s), words(size_t(s.SlotWords()) * pages, 0xDEADBEEF), table(1 << 17, 0)
{
}
uint32_t Slot(uint32_t page)
{
if (!table[page])
table[page] = ++slots;
return table[page];
}
};
// A resolve of guest rectangle [x0, x1) x [y0, y1) (of a host picture
// `src`, srcW wide, at the pool's scale) into the tiled surface at `base`,
// `pitch`: the shader's scaled writes, per thread (coalesced false) or a
// 32x2 group at a time through shared memory (coalesced true).
static void Resolve(Pool& pool, const std::vector<uint32_t>& src, uint32_t srcW, uint32_t base, uint32_t pitch, uint32_t x0, uint32_t y0,
uint32_t x1, uint32_t y1, bool coalesced)
{
const RenderScale& r = pool.scale;
const uint32_t nx = r.x.num, ny = r.y.num, dx = r.x.den, dy = r.y.den, s = nx * ny;
const uint32_t slotWords = r.SlotWords();
const int32_t originX = int32_t(r.x.Edge(x0)), originY = int32_t(r.y.Edge(y0));
auto thread = [&](uint32_t gx, uint32_t gy, uint32_t l, std::vector<uint32_t>* samples, std::vector<uint32_t>* offsets) {
bool inside = gx < x1 - x0 && gy < y1 - y0;
uint32_t x = x0 + gx, y = y0 + gy;
uint32_t a = base + Tiled2D(x, y, pitch, 2);
uint32_t slot = inside ? pool.Slot(a >> 12) : 0;
uint32_t k = (a & 4095) >> 2;
uint32_t o = (slot - 1) * slotWords + G.sbBlockIndex(k, dx, dy) * s;
uint32_t subX = G.sbSubX(k, dx), subY = G.sbSubY(k, dy);
uint32_t i0 = G.sbHostEdge(subX, nx, dx), i1 = G.sbHostEdge(subX + 1, nx, dx);
uint32_t j0 = G.sbHostEdge(subY, ny, dy), j1 = G.sbHostEdge(subY + 1, ny, dy);
uint32_t lb = G.sbBlockIndex(l, dx, dy);
if (slot != 0)
{
int32_t cx = originX + int32_t(G.sbHostEdge(gx, nx, dx)) - int32_t(i0), cy = originY + int32_t(G.sbHostEdge(gy, ny, dy)) - int32_t(j0);
for (uint32_t j = j0; j < j1; j++)
for (uint32_t i = i0; i < i1; i++)
{
uint32_t v = src[size_t(cy + int32_t(j)) * srcW + uint32_t(cx + int32_t(i))];
if (samples)
(*samples)[lb * s + j * nx + i] = v;
else
pool.words[o + j * nx + i] = v;
}
}
if (offsets && subX == 0 && subY == 0)
(*offsets)[lb] = slot != 0 ? o : ~0u;
};
if (!coalesced)
{
for (uint32_t gy = 0; gy < y1 - y0; gy++)
for (uint32_t gx = 0; gx < x1 - x0; gx++)
thread(gx, gy, 0, nullptr, nullptr);
return;
}
for (uint32_t wy = 0; wy < (y1 - y0 + 1) / 2; wy++)
for (uint32_t wx = 0; wx < (x1 - x0 + 31) / 32; wx++)
{
std::vector<uint32_t> samples(64 * 16, 0), offsets(64, ~0u);
for (uint32_t l = 0; l < 64; l++)
thread(wx * 32 + ((l & 3) | ((l >> 3) << 2)), wy * 2 + ((l >> 2) & 1), l, &samples, &offsets);
for (uint32_t q = 0; q < 64 / (dx * dy) * s; q++)
if (uint32_t po = offsets[q / s]; po != ~0u)
pool.words[po + q % s] = samples[q];
}
}
// The untile (a scaled texture, the front buffer): host texel (hx, hy) of a
// texture at `base`, `pitch`.
static uint32_t Read(const Pool& pool, uint32_t base, uint32_t pitch, uint32_t hx, uint32_t hy)
{
const RenderScale& r = pool.scale;
uint32_t bx = hx / r.x.num, by = hy / r.y.num;
uint32_t a = base + Tiled2D(bx * r.x.den, by * r.y.den, pitch, 2);
uint32_t slot = pool.table[a >> 12];
if (!slot)
return 0;
return pool.words[size_t(slot - 1) * r.SlotWords() + G.sbSampleWord((a & 4095) >> 2, hx - bx * r.x.num, hy - by * r.y.num, r.x.num,
r.y.num, r.x.den, r.y.den)];
}
static void TestRoundTrip()
{
printf("scaled memory: resolved in strips and halves, read back sample for sample\n");
const uint32_t W = 1280, H = 720, pitch = 1280;
for (const RenderScale& r : { RenderScale{ kHalf, kHalf }, RenderScale{ kHalf, { 1, 1 } }, RenderScale{ { 2, 1 }, kHalf },
RenderScale::Whole(2, 2), RenderScale::Whole(3, 2) })
for (bool coalesced : { false, true })
{
const uint32_t HW = r.x.Size(W), HH = r.y.Size(H);
std::vector<uint32_t> src(size_t(HW) * HH);
for (uint32_t j = 0; j < HH; j++)
for (uint32_t i = 0; i < HW; i++)
src[size_t(j) * HW + i] = (j << 16) | i; // each host pixel its own coordinates
Pool pool(r, 1024);
const uint32_t base = 0x00400000;
// The scene's three tile strips (rows 0, 256, 512), the right one
// in side-by-side 640-wide halves.
Resolve(pool, src, HW, base, pitch, 0, 0, W, 256, coalesced);
Resolve(pool, src, HW, base, pitch, 0, 256, 640, 512, coalesced);
Resolve(pool, src, HW, base, pitch, 640, 256, W, 512, coalesced);
Resolve(pool, src, HW, base, pitch, 0, 512, W, H, coalesced);
uint32_t wrong = 0, first = ~0u;
for (uint32_t j = 0; j < HH; j++)
for (uint32_t i = 0; i < HW; i++)
if (Read(pool, base, pitch, i, j) != src[size_t(j) * HW + i] && wrong++ == 0)
first = (j << 16) | i;
CHECK(wrong == 0, "%s %s: %u of %ux%u samples wrong, the first at %u,%u (read %08X)", gpu::ScaleName(r).c_str(),
coalesced ? "coalesced" : "per thread", wrong, HW, HH, first & 0xFFFF, first >> 16,
first == ~0u ? 0 : Read(pool, base, pitch, first & 0xFFFF, first >> 16));
// A texture of the right half, starting 20 tiles in (the world's
// entry resolve): its host texel (i, j) is the picture's (i + the
// half's host edge, j).
const uint32_t halfBase = base + 20 * 4096, x0 = r.x.Edge(640u);
wrong = 0;
for (uint32_t j = 0; j < HH; j++)
for (uint32_t i = 0; i < r.x.Size(640); i++)
wrong += Read(pool, halfBase, pitch, i, j) != src[size_t(j) * HW + x0 + i];
CHECK(wrong == 0, "%s %s: the right half's texture has %u wrong samples", gpu::ScaleName(r).c_str(),
coalesced ? "coalesced" : "per thread", wrong);
}
}
static void TestParse()
{
printf("NFSMW_RENDER_SCALE and the setting's half steps\n");
struct Case { const char* text; bool ok; RenderScale want; };
const Case cases[] = {
{ "1", true, RenderScale::Whole(1, 1) },
{ "1.5", true, { kHalf, kHalf } },
{ "1.5x1", true, { kHalf, { 1, 1 } } },
{ "3x1.5", true, { { 3, 1 }, kHalf } },
{ "2", true, RenderScale::Whole(2, 2) },
{ "3x2", true, RenderScale::Whole(3, 2) },
{ "4", true, RenderScale::Whole(4, 4) },
{ "9", true, RenderScale::Whole(4, 4) }, // clamped, as before
{ "0", true, RenderScale::Whole(1, 1) }, // as before
{ "2.5", false, {} },
{ "1.25", false, {} },
{ "abc", false, {} },
{ "2x", false, {} },
{ "1.5y2", false, {} },
{ "", false, {} },
};
for (const Case& c : cases)
{
RenderScale got = RenderScale::Whole(7, 7);
bool ok = gpu::ParseRenderScale(c.text, got);
CHECK(ok == c.ok, "\"%s\": %s", c.text, ok ? "accepted" : "refused");
if (ok && c.ok)
CHECK(got == c.want, "\"%s\": %s, want %s", c.text, gpu::ScaleName(got).c_str(), gpu::ScaleName(c.want).c_str());
}
CHECK(gpu::ScaleName({ kHalf, kHalf }) == "1.5x1.5", "%s", gpu::ScaleName({ kHalf, kHalf }).c_str());
CHECK(gpu::ScaleName(RenderScale::Whole(3, 2)) == "3x2", "%s", gpu::ScaleName(RenderScale::Whole(3, 2)).c_str());
CHECK((gpu::AxisFromHalves(2) == AxisScale{ 1, 1 }) && (gpu::AxisFromHalves(3) == kHalf) && (gpu::AxisFromHalves(4) == AxisScale{ 2, 1 }) &&
(gpu::AxisFromHalves(6) == AxisScale{ 3, 1 }), "half steps");
RenderScale half{ kHalf, kHalf };
CHECK(half.x.Size(1280) == 1920 && half.y.Size(720) == 1080 && half.SlotWords() == 2304 && half.PixelsNum() == 9 && half.PixelsDen() == 4,
"1.5x: %ux%u, %u words a page", half.x.Size(1280), half.y.Size(720), half.SlotWords());
CHECK(half.Exact(1280, 720) && !half.Exact(1279, 720) && !half.Exact(640, 359) && RenderScale::Whole(2, 2).Exact(1279, 359), "exact");
}
// The shaders that touch the scaled memory take the shared code.
static void TestShaders(const char* rendererPath)
{
printf("renderer.cpp's shaders splice GLSL_SCALE_CODE\n");
std::ifstream f(rendererPath);
CHECK(f.good(), "can't read %s", rendererPath);
if (!f.good())
return;
std::stringstream ss;
ss << f.rdbuf();
const std::string text = ss.str();
for (const char* shader : { "kResolveGlsl", "kScaledFillGlsl", "kScaledUntileGlsl", "kFrontGlsl" })
{
size_t at = text.find(std::string("const char* ") + shader + " = R\"(");
CHECK(at != std::string::npos, "%s not found", shader);
if (at == std::string::npos)
continue;
size_t end = text.find("\n)\";", at);
std::string body = text.substr(at, end - at);
CHECK(body.find(")\" GLSL_SCALE_CODE R\"(") != std::string::npos, "%s doesn't splice GLSL_SCALE_CODE", shader);
CHECK(body.find("sbSampleWord") != std::string::npos || body.find("sbBlockIndex") != std::string::npos,
"%s doesn't use the block layout", shader);
}
const std::string glsl = GLSL_SCALE_CODE;
for (const char* fn : { "uint sbHostEdge(", "uint sbOverlap(", "uint sbCenterHost(", "uint sbBlockIndex(", "uint sbSampleWord(" })
CHECK(glsl.find(fn) != std::string::npos, "GLSL_SCALE_CODE lacks %s", fn);
size_t comments = 0;
for (size_t at = glsl.find("//"); at != std::string::npos; at = glsl.find("//", at + 2))
comments++;
CHECK(comments == 1 && glsl.find("/*") == std::string::npos, "%zu comments in the GLSL text (its header line only)", comments);
}
int main(int argc, char** argv)
{
TestWholeScalesUnchanged();
TestHalfAxis();
TestBoxFilter();
TestSlotLayout();
TestRoundTrip();
TestParse();
if (argc > 1)
TestShaders(argv[1]);
if (g_failures)
{
printf("%d FAILED\n", g_failures);
return 1;
}
printf("all passed\n");
return 0;
}
+85
View File
@@ -0,0 +1,85 @@
// Tests for video::MobileScaleBudget (runtime/video/scale_budget.cpp): Auto
// internal resolution's budget on iPhones and iPads, chip by chip, and what
// it makes of the phones' screens with video::AutoRenderScale. From the repo
// root:
// clang++ -std=c++20 -O2 -Iruntime tests/scale_budget_test.cpp runtime/video/scale_budget.cpp runtime/video/picture_fit.cpp -o build/scale_budget_test
// build/scale_budget_test
#include <video/picture_fit.h>
#include <video/scale_budget.h>
#include <cstdio>
#include <cstring>
using video::AutoRenderScale;
using video::MobileScaleBudget;
using video::ScaleBudget;
static int g_failures = 0;
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
static void Budget(const char* gpu, unsigned want)
{
ScaleBudget b = MobileScaleBudget(gpu);
printf("\"%s\": %u (%s)\n", gpu, b.pixels, b.reason);
CHECK(b.pixels == want, "\"%s\": budget %u, want %u", gpu, b.pixels, want);
CHECK(b.reason && std::strlen(b.reason) > 0, "\"%s\": no reason for the log", gpu);
}
// The scale Auto picks for a phone filling its screen (Hor+: the picture is
// the whole swapchain) with the budget its GPU gets.
static void Phone(const char* gpu, unsigned w, unsigned h, unsigned wantX, unsigned wantY)
{
unsigned sx = 0, sy = 0;
AutoRenderScale(w, h, MobileScaleBudget(gpu).pixels, sx, sy);
printf("\"%s\" on %ux%u: %ux%u\n", gpu, w, h, sx, sy);
CHECK(sx == wantX && sy == wantY, "\"%s\" on %ux%u: %ux%u, want %ux%u", gpu, w, h, sx, sy, wantX, wantY);
}
int main()
{
printf("Measured chips:\n");
Budget("Apple A20 Pro GPU", 1); // iPhone 18 Pro Max: serious thermal state at 1x1 within 6 minutes
Budget("Apple A20 Pro", 1); // the same chip without Metal's " GPU"
Budget("Apple A17 Pro GPU", 1); // iPhone 15 Pro Max: 2x2 is 36-40 ms of GPU a frame
Budget("Apple A16 GPU", 1);
Budget("Apple A15 GPU", 1);
Budget("Apple M2 GPU", 1); // iPad Pro M2: 43-50 fps at 2x2
printf("Chips not measured (1x1 until they are):\n");
Budget("Apple A20 GPU", 1); // the iPhone 18's A20, not the Pro
Budget("Apple A18 Pro GPU", 1);
Budget("Apple A19 Pro GPU", 1);
Budget("Apple A21 Pro GPU", 1); // a newer chip: not "A20 or later"
Budget("Apple A22 Pro GPU", 1);
Budget("Apple M4 GPU", 1);
Budget("Apple M5 Pro GPU", 1);
printf("Names that only look alike:\n");
Budget("Apple A2 Pro GPU", 1); // "A2", not "A20"
Budget("Apple A200 Pro GPU", 1);
Budget("Apple A20 Pro Max GPU", 1); // another tier
Budget("Apple A20 Promax GPU", 1);
Budget("Apple A20X Pro GPU", 1);
Budget("Apple A Pro GPU", 1);
Budget("Apple GPU", 1);
Budget("Apple ", 1);
Budget("", 1);
Budget("AMD Radeon RX 7600 (RADV NAVI33)", 1);
Budget("A20 Pro GPU", 1); // not Metal's name
Budget("Apple A20 Pro GPU (simulated)", 1);
printf("The phones' screens:\n");
Phone("Apple A20 Pro GPU", 2868, 1320, 1, 1); // iPhone 18 Pro Max: 1280x720 (2x2 would be 2560x1440)
Phone("Apple A17 Pro GPU", 2796, 1290, 1, 1); // iPhone 15 Pro Max: 1280x720
Phone("Apple A20 GPU", 2622, 1206, 1, 1);
// Present Scale 75% (a smaller drawable).
Phone("Apple A20 Pro GPU", 2151, 990, 1, 1);
if (g_failures)
{
printf("%d FAILED\n", g_failures);
return 1;
}
printf("all passed\n");
return 0;
}
+509
View File
@@ -0,0 +1,509 @@
// Tests for video::SwapchainPolicy, ChooseExtent and FitUiDisplay
// (runtime/video/swapchain_policy.cpp): when the presenter builds its
// swapchain again, at what size, and the size the UI lays out at. Each
// scenario drives the policy as presenter.cpp does (the surface read when it
// asks, a rebuild at the size it decides, a view fitted back when it says,
// MoltenVK's SUBOPTIMAL after a present while the layer and the swapchain
// disagree); then every planted wrong rule must fail at least one scenario.
// From the repo root:
// clang++ -std=c++20 -O2 -Iruntime tests/swapchain_policy_test.cpp runtime/video/swapchain_policy.cpp -o build/swapchain_policy_test
// build/swapchain_policy_test
#include <video/swapchain_policy.h>
#include <cmath>
#include <cstdio>
#include <functional>
#include <vector>
using namespace video;
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)
static const Extent kLandscape{ 2868, 1320 }; // an iPhone 18 Pro Max's screen, in pixels
static const Extent kPortrait{ 1320, 2868 }; // the same, as SDL's turned view measured it
// The presenter, the layer and the view, around one policy.
struct Screen
{
SwapchainPolicy policy;
Extent swapchain = kLandscape;
Extent surface = kLandscape; // the layer's size (MoltenVK's currentExtent)
Extent windowPixels = kLandscape; // what a fitted-back view measures
bool active = true;
Orientation interface = Orientation::Landscape;
bool landscapeOnly = false;
bool viewFits = true;
bool repairWorks = true; // fitting the view back takes
int frames = 0;
int recreates = 0, repairs = 0;
int recreatesWhileInactive = 0;
std::vector<Extent> built;
explicit Screen(SwapchainRules rules) : policy(rules) {}
// One RunFrame's check.
SwapDecision Frame()
{
frames++;
SurfaceState s;
s.active = active;
s.swapchain = swapchain;
// Read whenever asked, active or not: holding off is the policy's.
if (policy.WantsSurface())
{
s.surfaceRead = true;
s.surface = surface;
s.interface = interface;
s.landscapeOnly = landscapeOnly;
s.viewFitsWindow = viewFits;
}
SwapDecision d = policy.Decide(s);
if (d.action == SwapAction::Recreate)
{
recreates++;
if (!active)
recreatesWhileInactive++;
swapchain = d.extent;
built.push_back(d.extent);
if (!swapchain.Empty())
policy.Created(swapchain);
}
else if (d.action == SwapAction::RepairView)
{
repairs++;
if (repairWorks)
{
// SDL lays the view out again: its drawables and resize events.
viewFits = true;
surface = windowPixels;
policy.SizeChanged();
}
}
return d;
}
// The frame's present (none while inactive): MoltenVK says SUBOPTIMAL
// while the layer and the swapchain disagree, for the next frame's check.
void Present()
{
if (active && !swapchain.Empty() && surface != swapchain)
policy.Reported(SurfaceResult::Suboptimal);
}
void Run(int n)
{
for (int i = 0; i < n; i++)
{
Frame();
Present();
}
}
bool BuiltPortrait() const
{
for (const Extent& e : built)
if (e.Portrait())
return true;
return false;
}
void Suspend()
{
policy.Suspended();
active = false;
}
void Resume()
{
active = true;
policy.Resumed();
}
};
// The v0.1.1 bug on the iPhone: after a screenshot, SDL turns its view
// portrait (440x956 points in a 956x440 window) as a keyboard hides.
static void IphoneViewTurned(const SwapchainRules& rules)
{
Screen s(rules);
s.landscapeOnly = true;
s.Run(10);
s.Suspend();
s.Run(30);
s.Resume();
s.Run(5);
// SDL's -updateKeyboard: the view's frame swapped.
s.surface = kPortrait;
s.viewFits = false;
s.policy.SizeChanged();
s.Run(200);
CHECK(!s.BuiltPortrait(), "iPhone, the view turned: a portrait swapchain was built");
CHECK(s.repairs >= 1, "iPhone, the view turned: never fitted back");
CHECK(s.swapchain == kLandscape && s.surface == kLandscape, "iPhone, the view turned: ends at swapchain %ux%u, layer %ux%u",
s.swapchain.width, s.swapchain.height, s.surface.width, s.surface.height);
CHECK(s.recreates == 0, "iPhone, the view turned: %d rebuilds (none needed: the size never really changed)", s.recreates);
}
// The layer is portrait though the view fills its window (the window itself
// turned under a landscape interface), and fitting back doesn't take: the
// swapchain stays landscape for as long as it lasts, repairs spaced out.
static void IphoneLayerPortraitStays(const SwapchainRules& rules)
{
Screen s(rules);
s.landscapeOnly = true;
s.repairWorks = false;
s.Run(10);
s.surface = kPortrait;
s.policy.SizeChanged();
s.Run(300);
CHECK(!s.BuiltPortrait(), "iPhone, a portrait layer: a portrait swapchain was built under a landscape interface");
CHECK(s.swapchain == kLandscape, "iPhone, a portrait layer: swapchain %ux%u", s.swapchain.width, s.swapchain.height);
CHECK(s.repairs >= 1 && s.repairs <= 20, "iPhone, a portrait layer: %d repairs in 300 frames (want some, spaced out)", s.repairs);
CHECK(s.recreates <= 1, "iPhone, a portrait layer: %d rebuilds", s.recreates);
}
// The interface really turned portrait (not SpeedBreaker on iPhone, but
// any landscape-only app iOS forces): followed.
static void IphoneInterfacePortrait(const SwapchainRules& rules)
{
Screen s(rules);
s.landscapeOnly = true;
s.Run(10);
s.interface = Orientation::Portrait;
s.surface = s.windowPixels = kPortrait;
s.policy.SizeChanged();
s.Run(3);
CHECK(s.swapchain == kPortrait, "a portrait interface: swapchain %ux%u, want the portrait surface", s.swapchain.width,
s.swapchain.height);
}
// iPad (not landscape-only): SDL's turned view is fitted back before the
// swapchain follows the layer anywhere.
static void IpadViewTurned(const SwapchainRules& rules)
{
Screen s(rules);
s.swapchain = s.surface = s.windowPixels = { 2732, 2048 };
s.Run(10);
s.surface = { 2048, 2732 };
s.viewFits = false;
s.policy.SizeChanged();
s.Run(60);
CHECK(!s.BuiltPortrait(), "iPad, the view turned: the swapchain followed SDL's portrait guess");
CHECK(s.swapchain == Extent(2732, 2048) && s.repairs >= 1, "iPad, the view turned: swapchain %ux%u, %d repairs", s.swapchain.width,
s.swapchain.height, s.repairs);
}
// A view that won't fit: after the patience the swapchain follows its layer
// (better a picture that fills the layer than one stretched into it).
static void ViewNeverFits(const SwapchainRules& tested)
{
const SwapchainRules rules; // the expected patience, not the one under test
Screen s(tested);
s.swapchain = s.surface = s.windowPixels = { 2732, 2048 };
s.repairWorks = false;
s.Run(5);
s.surface = { 2048, 2732 };
s.viewFits = false;
s.policy.SizeChanged();
s.Run(rules.repairPatience + 20);
CHECK(s.swapchain == Extent(2048, 2732), "a view that never fits: swapchain %ux%u after %u frames", s.swapchain.width,
s.swapchain.height, rules.repairPatience + 20);
CHECK(s.repairs >= 3 && s.repairs <= 3 + int(rules.repairPatience / std::max(rules.repairEvery, 1u)) + 1,
"a view that never fits: %d repairs", s.repairs);
}
// iOS snapshots the app turned while it is inactive (the app switcher, the
// screenshot editor), and turns it back before it is active: nothing built.
static void SnapshotWhileInactive(const SwapchainRules& rules)
{
Screen s(rules);
s.Run(10);
s.Suspend();
s.Run(5);
s.surface = kPortrait;
s.policy.SizeChanged();
s.Run(30);
s.surface = kLandscape;
s.policy.SizeChanged();
s.Run(5);
s.Resume();
s.Run(10);
CHECK(s.recreatesWhileInactive == 0, "a snapshot while inactive: %d rebuilds while inactive", s.recreatesWhileInactive);
CHECK(s.recreates == 0, "a snapshot while inactive: %d rebuilds (the size is the same again)", s.recreates);
}
// A real change while inactive: built on the first active frame, not before.
static void ResizeWhileInactive(const SwapchainRules& rules)
{
Screen s(rules);
s.swapchain = s.surface = { 1280, 720 };
s.Run(10);
s.Suspend();
s.surface = { 1600, 900 };
s.policy.SizeChanged();
s.Run(20);
CHECK(s.recreatesWhileInactive == 0, "a resize while inactive: %d rebuilds while inactive", s.recreatesWhileInactive);
s.Resume();
s.Frame();
CHECK(s.swapchain == Extent(1600, 900), "a resize while inactive: first active frame's swapchain %ux%u", s.swapchain.width,
s.swapchain.height);
}
// After a resume the layer settles late, with no event: caught that frame.
static void LateSettleAfterResume(const SwapchainRules& rules)
{
Screen s(rules);
s.Run(500); // off the once-a-second beat below
s.Suspend();
s.Run(10);
s.Resume();
for (int i = 0; i < 40; i++)
s.Frame();
s.surface = { 2868, 1200 };
SwapDecision d = s.Frame();
CHECK(d.action == SwapAction::Recreate && s.swapchain == Extent(2868, 1200),
"after a resume, the layer moved without an event: %s, swapchain %ux%u", SwapActionName(d.action), s.swapchain.width,
s.swapchain.height);
}
// Long after anything happened, the layer moves with no event and no
// SUBOPTIMAL (a driver that doesn't say): caught within a second.
static void PeriodicCheck(const SwapchainRules& rules)
{
Screen s(rules);
for (int i = 0; i < 1000; i++)
s.Frame();
s.surface = { 2560, 1440 };
int caughtAfter = -1;
for (int i = 0; i < 200 && caughtAfter < 0; i++)
if (s.Frame().action == SwapAction::Recreate)
caughtAfter = i;
CHECK(caughtAfter >= 0 && caughtAfter < 61, "an unannounced size: caught after %d frames (want within 60)", caughtAfter);
}
// MoltenVK's SUBOPTIMAL at the size the swapchain already has (drawables
// rounded another way): rebuilt, but not every frame.
static void SuboptimalSameSize(const SwapchainRules& rules)
{
Screen s(rules);
s.Run(200);
for (int i = 0; i < 90; i++)
{
s.policy.Reported(SurfaceResult::Suboptimal);
s.Frame();
}
CHECK(s.recreates >= 2 && s.recreates <= 4, "SUBOPTIMAL at the same size for 90 frames: %d rebuilds (want 2-4)", s.recreates);
}
// SUBOPTIMAL from a new size, no event, between checks: rebuilt at once.
static void SuboptimalNewSize(const SwapchainRules& rules)
{
Screen s(rules);
s.Run(500);
s.surface = { 2400, 1320 };
s.policy.Reported(SurfaceResult::Suboptimal);
SwapDecision d = s.Frame();
CHECK(d.action == SwapAction::Recreate && s.swapchain == Extent(2400, 1320), "SUBOPTIMAL at a new size: %s, swapchain %ux%u",
SwapActionName(d.action), s.swapchain.width, s.swapchain.height);
}
// OUT_OF_DATE: rebuilt whatever the size; on a landscape-only iPhone still
// not portrait.
static void OutOfDate(const SwapchainRules& rules)
{
Screen s(rules);
s.Run(500);
s.policy.Reported(SurfaceResult::OutOfDate);
SwapDecision d = s.Frame();
CHECK(d.action == SwapAction::Recreate && s.swapchain == kLandscape, "OUT_OF_DATE, same size: %s", SwapActionName(d.action));
Screen p(rules);
p.landscapeOnly = true;
p.repairWorks = false;
p.Run(10);
p.surface = kPortrait;
p.policy.Reported(SurfaceResult::OutOfDate);
d = p.Frame();
CHECK(d.action == SwapAction::Recreate && p.swapchain == kLandscape, "OUT_OF_DATE on a portrait layer (iPhone): %s, swapchain %ux%u",
SwapActionName(d.action), p.swapchain.width, p.swapchain.height);
}
// The Mac and Linux: window resizes, a fullscreen toggle, a Space change
// (each a pixel-size event, built that frame); an event with no change
// builds nothing; minimised tears down and comes back.
static void Desktop(const SwapchainRules& rules)
{
Screen s(rules);
s.swapchain = s.surface = { 2560, 1440 };
s.Run(100);
const Extent sizes[] = { { 2000, 1200 }, { 1800, 1100 }, { 3456, 2234 }, { 2560, 1440 }, { 3024, 1964 } };
for (const Extent& e : sizes)
{
s.surface = e;
s.policy.SizeChanged();
SwapDecision d = s.Frame();
CHECK(d.action == SwapAction::Recreate && s.swapchain == e, "desktop resize to %ux%u: %s, swapchain %ux%u", e.width, e.height,
SwapActionName(d.action), s.swapchain.width, s.swapchain.height);
s.Run(3);
}
const int before = s.recreates;
s.policy.SizeChanged(); // focus, a move: the same size
s.Run(10);
CHECK(s.recreates == before, "a size event at the same size: %d rebuilds", s.recreates - before);
// Minimised: the presenter's wait for a size, as before.
s.surface = {};
s.policy.SizeChanged();
SwapDecision d = s.Frame();
CHECK(d.action == SwapAction::Recreate && s.swapchain.Empty(), "minimised: %s, swapchain %ux%u", SwapActionName(d.action),
s.swapchain.width, s.swapchain.height);
s.surface = { 3024, 1964 };
s.policy.SizeChanged();
d = s.Frame();
CHECK(d.action == SwapAction::Recreate && s.swapchain == Extent(3024, 1964), "restored: %s, swapchain %ux%u",
SwapActionName(d.action), s.swapchain.width, s.swapchain.height);
CHECK(!s.BuiltPortrait(), "desktop: a portrait swapchain");
}
// A tall desktop window is portrait, and that's fine there.
static void DesktopPortraitWindow(const SwapchainRules& rules)
{
Screen s(rules);
s.swapchain = s.surface = { 1280, 720 };
s.Run(10);
s.surface = { 800, 1200 };
s.policy.SizeChanged();
s.Frame();
CHECK(s.swapchain == Extent(800, 1200), "a tall desktop window: swapchain %ux%u", s.swapchain.width, s.swapchain.height);
}
// The settled report: once, at the end of the watch after a resume.
static void SettledReport(const SwapchainRules& tested)
{
const SwapchainRules rules; // when it is expected
Screen s(tested);
s.Run(10);
s.Suspend();
s.Run(3);
s.Resume();
int settled = 0, at = -1;
for (int i = 0; i < 400; i++)
if (s.Frame().settled)
{
settled++;
at = i;
}
CHECK(settled == 1 && at == int(rules.settleFrames) - 1, "after a resume: %d settled reports (frame %d)", settled, at);
}
static const std::vector<std::pair<const char*, void (*)(const SwapchainRules&)>> kScenarios = {
{ "iPhone, the view turned", IphoneViewTurned },
{ "iPhone, a portrait layer stays", IphoneLayerPortraitStays },
{ "iPhone, a portrait interface", IphoneInterfacePortrait },
{ "iPad, the view turned", IpadViewTurned },
{ "a view that never fits", ViewNeverFits },
{ "a snapshot while inactive", SnapshotWhileInactive },
{ "a resize while inactive", ResizeWhileInactive },
{ "late settle after a resume", LateSettleAfterResume },
{ "the periodic check", PeriodicCheck },
{ "SUBOPTIMAL, same size", SuboptimalSameSize },
{ "SUBOPTIMAL, new size", SuboptimalNewSize },
{ "OUT_OF_DATE", OutOfDate },
{ "desktop", Desktop },
{ "a tall desktop window", DesktopPortraitWindow },
{ "the settled report", SettledReport },
};
static int RunScenarios(const SwapchainRules& rules)
{
int failed = 0;
for (const auto& [name, run] : kScenarios)
{
const int before = g_failures;
run(rules);
if (g_failures != before)
{
failed++;
if (!g_quiet)
printf(" scenario \"%s\" failed\n", name);
}
}
return failed;
}
// The same scenarios under a wrong rule must fail at least one.
static void Planted(const char* what, const std::function<void(SwapchainRules&)>& plant)
{
SwapchainRules wrong;
plant(wrong);
const int saved = g_failures;
g_quiet = true;
const int failed = RunScenarios(wrong);
g_quiet = false;
g_failures = saved;
printf("planted %-36s %d scenario(s) fail\n", what, failed);
CHECK(failed > 0, "planted %s: every scenario still passes", what);
}
static void Choose(Extent surface, Orientation interface, bool landscapeOnly, Extent want, bool refused)
{
ExtentChoice c = ChooseExtent(surface, interface, landscapeOnly);
CHECK(c.extent == want && c.refused == refused, "ChooseExtent(%ux%u, %s, %s): %ux%u%s, want %ux%u%s", surface.width, surface.height,
OrientationName(interface), landscapeOnly ? "landscape-only" : "any", c.extent.width, c.extent.height, c.refused ? " refused" : "",
want.width, want.height, refused ? " refused" : "");
}
static void Ui(float ww, float wh, Extent fb, float wantW, float wantH, float wantScale, bool fromSwapchain)
{
UiDisplay u = FitUiDisplay(ww, wh, fb);
printf("UI for a %.0fx%.0f window, swapchain %ux%u: %.1fx%.1f points, scale %.3f x %.3f%s\n", ww, wh, fb.width, fb.height, u.width,
u.height, u.scaleX, u.scaleY, u.fromSwapchain ? " (the swapchain's)" : "");
CHECK(std::fabs(u.width - wantW) < 0.6f && std::fabs(u.height - wantH) < 0.6f && u.fromSwapchain == fromSwapchain,
"%.0fx%.0f window, %ux%u: %.1fx%.1f%s", ww, wh, fb.width, fb.height, u.width, u.height, u.fromSwapchain ? " from the swapchain" : "");
CHECK(std::fabs(u.scaleX - wantScale) < 0.01f && std::fabs(u.scaleY - wantScale) < 0.01f, "scale %.3f x %.3f, want %.3f", u.scaleX,
u.scaleY, wantScale);
// The UI's own scale follows the display's height (ui.cpp UpdateScale).
}
int main()
{
printf("ChooseExtent:\n");
Choose(kLandscape, Orientation::Landscape, true, kLandscape, false);
Choose(kPortrait, Orientation::Landscape, true, kLandscape, true);
Choose(kPortrait, Orientation::Unknown, true, kLandscape, true);
Choose(kPortrait, Orientation::Portrait, true, kPortrait, false);
Choose(kPortrait, Orientation::Landscape, false, kPortrait, false);
Choose({ 1320, 1320 }, Orientation::Landscape, true, { 1320, 1320 }, false);
Choose({}, Orientation::Landscape, true, {}, false);
printf("\nscenarios:\n");
const int failed = RunScenarios(SwapchainRules{});
printf("%zu scenarios, %d failed\n", kScenarios.size(), failed);
printf("\nplanted (each must fail a scenario):\n");
Planted("holdWhileInactive off", [](SwapchainRules& r) { r.holdWhileInactive = false; });
Planted("refusePortrait off", [](SwapchainRules& r) { r.refusePortrait = false; });
Planted("repairView off", [](SwapchainRules& r) { r.repairView = false; });
Planted("followSuboptimal off", [](SwapchainRules& r) { r.followSuboptimal = false; });
Planted("settleFrames 0", [](SwapchainRules& r) { r.settleFrames = 0; });
Planted("settleFrames 20", [](SwapchainRules& r) { r.settleFrames = 20; });
Planted("periodFrames 0 (never)", [](SwapchainRules& r) { r.periodFrames = 0; });
Planted("periodFrames 120", [](SwapchainRules& r) { r.periodFrames = 120; });
Planted("suboptimalBackoff 0", [](SwapchainRules& r) { r.suboptimalBackoff = 0; });
Planted("suboptimalBackoff 100", [](SwapchainRules& r) { r.suboptimalBackoff = 100; });
Planted("repairEvery 1", [](SwapchainRules& r) { r.repairEvery = 1; });
Planted("repairPatience 100000", [](SwapchainRules& r) { r.repairPatience = 100000; });
printf("\nUI display:\n");
Ui(956, 440, kLandscape, 956, 440, 3.0f, false); // iPhone 18 Pro Max, as it should be
Ui(440, 956, kLandscape, 956, 440, 3.0f, true); // the window turned, the swapchain kept landscape
Ui(440, 956, kPortrait, 440, 956, 3.0f, false); // both portrait (v0.1.1's state: consistent, if wrong)
Ui(956, 440, { 2150, 989 }, 956, 440, 2.249f, false); // Screen Resolution 75%: drawables a hair under 2.25x
Ui(1512, 982, { 3024, 1964 }, 1512, 982, 2.0f, false); // a MacBook Pro's Retina window
Ui(1280, 800, { 1280, 800 }, 1280, 800, 1.0f, false); // the Steam Deck
Ui(1512, 982, { 3020, 1964 }, 1512, 982, 1.997f, false); // mid-resize, a few pixels behind: the window's size stands
Ui(1512, 982, {}, 1512, 982, 1.0f, false); // no swapchain yet
printf(g_failures ? "\n%d FAILED\n" : "\nall passed\n", g_failures);
return g_failures ? 1 : 0;
}