mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 15:01:23 +02:00
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:
1 parent
a4a72ce103
commit
e128baca5e
71 files changed
+9012
-423
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See glare.h.
|
||||
//
|
||||
// Two function overrides (the generated functions are weak; no ppc/
|
||||
// regeneration):
|
||||
// - sub_82225730, the sun code (projects the sun, counts its occlusion,
|
||||
// draws its glow): keeps its view argument for the glow. r4 is the view's
|
||||
// entry in the per-view table (0x82C3F590 + id * 384; logged: view 1 at
|
||||
// 0x82C3F710, view 4 at 0x82C3FB90), its id at +0, which the code itself
|
||||
// tests for the reflection views 4 and 5.
|
||||
// - sub_822251C8, the quad builder: after the game builds the glow's quad
|
||||
// (called from the sun code, ctx.lr = glare::kGlowCall) in a view the
|
||||
// projection hook widens, its corners are fitted to the view scales.
|
||||
// NFSMW_GLARE_FIT=0 leaves the glow as the game builds it (an A/B switch).
|
||||
#include <stdafx.h>
|
||||
#include "glare.h"
|
||||
#include "ultrawide.h"
|
||||
|
||||
#include <kernel/function.h>
|
||||
|
||||
extern "C" PPC_FUNC(__imp__sub_82225730);
|
||||
extern "C" PPC_FUNC(__imp__sub_822251C8);
|
||||
|
||||
namespace
|
||||
{
|
||||
const bool s_fit = [] {
|
||||
const char* v = std::getenv("NFSMW_GLARE_FIT");
|
||||
const bool on = !(v && v[0] == '0');
|
||||
if (!on)
|
||||
fprintf(stderr, "[glare] NFSMW_GLARE_FIT=0: the sun's glow is drawn as the game builds it (stretched off 16:9)\n");
|
||||
return on;
|
||||
}();
|
||||
|
||||
// The view the sun code is drawing for (its r4), on the thread running
|
||||
// it; 0 outside it.
|
||||
thread_local uint32_t t_sunView = 0;
|
||||
|
||||
float LoadFloat(uint8_t* base, uint32_t address)
|
||||
{
|
||||
const uint32_t bits = PPC_LOAD_U32(address);
|
||||
float f;
|
||||
memcpy(&f, &bits, sizeof(f));
|
||||
return f;
|
||||
}
|
||||
|
||||
void StoreFloat(uint8_t* base, uint32_t address, float f)
|
||||
{
|
||||
uint32_t bits;
|
||||
memcpy(&bits, &f, sizeof(bits));
|
||||
PPC_STORE_U32(address, bits);
|
||||
}
|
||||
}
|
||||
|
||||
PPC_FUNC(sub_82225730)
|
||||
{
|
||||
const uint32_t outer = t_sunView;
|
||||
t_sunView = ctx.r4.u32;
|
||||
__imp__sub_82225730(ctx, base);
|
||||
t_sunView = outer;
|
||||
}
|
||||
|
||||
PPC_FUNC(sub_822251C8)
|
||||
{
|
||||
const uint32_t lr = static_cast<uint32_t>(ctx.lr), verts = ctx.r5.u32;
|
||||
__imp__sub_822251C8(ctx, base);
|
||||
if (lr != game::glare::kGlowCall || !t_sunView)
|
||||
return;
|
||||
const float sx = game::AspectScaleX(), sy = game::AspectScaleY();
|
||||
const uint32_t id = PPC_LOAD_U32(t_sunView);
|
||||
const bool fit = s_fit && (sx != 1.0f || sy != 1.0f) && game::glare::WidenedViewId(id);
|
||||
// Each view's first glow (and again when the scales change), in every
|
||||
// log: the view, its corners as built, and what was done.
|
||||
{
|
||||
static std::mutex mutex;
|
||||
static std::unordered_map<uint32_t, uint64_t> logged;
|
||||
uint32_t sxBits, syBits;
|
||||
memcpy(&sxBits, &sx, 4);
|
||||
memcpy(&syBits, &sy, 4);
|
||||
const uint64_t key = (uint64_t(sxBits) << 32) ^ syBits ^ (fit ? 1ull << 63 : 0);
|
||||
std::lock_guard lock(mutex);
|
||||
if (logged.size() < 64)
|
||||
{
|
||||
auto [it, added] = logged.try_emplace(id, ~key);
|
||||
if (it->second != key)
|
||||
{
|
||||
it->second = key;
|
||||
fprintf(stderr, "[glare] sun glow in view %u: corners (%.1f,%.1f) (%.1f,%.1f) (%.1f,%.1f) (%.1f,%.1f); view scale x %.4f y %.4f: %s\n",
|
||||
id, LoadFloat(base, verts), LoadFloat(base, verts + 4), LoadFloat(base, verts + 16),
|
||||
LoadFloat(base, verts + 20), LoadFloat(base, verts + 32), LoadFloat(base, verts + 36), LoadFloat(base, verts + 48),
|
||||
LoadFloat(base, verts + 52), sx, sy,
|
||||
fit ? "fitted to the view scale" : !s_fit ? "left as built (NFSMW_GLARE_FIT=0)"
|
||||
: (sx == 1.0f && sy == 1.0f) ? "left as built (16:9)" : "left as built (a view the projection hook doesn't widen)");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fit)
|
||||
return;
|
||||
// The corners: x, y at +0 and +4 of four 16-byte vertices.
|
||||
float x[4], y[4];
|
||||
for (uint32_t i = 0; i < 4; i++)
|
||||
{
|
||||
x[i] = LoadFloat(base, verts + i * 16);
|
||||
y[i] = LoadFloat(base, verts + i * 16 + 4);
|
||||
}
|
||||
game::glare::FitSprite(x, y, sx, sy);
|
||||
for (uint32_t i = 0; i < 4; i++)
|
||||
{
|
||||
StoreFloat(base, verts + i * 16, x[i]);
|
||||
StoreFloat(base, verts + i * 16 + 4, y[i]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The sun's glare on a screen of another shape than 16:9 (game/glare.cpp
|
||||
// hooks it; tests/glare_fit_test.cpp checks this header alone).
|
||||
//
|
||||
// The glare is a screen-space sprite, not a view's geometry: the sun code
|
||||
// (sub_82225730) projects the sun through the view, turns the result into
|
||||
// pixels of the 1280x720 frame (view 0's target), and its quad builder
|
||||
// (sub_822251C8) makes a rotated square of a fixed size in those pixels
|
||||
// around that point: v0 = (cx - a, cy - b), v1 = (cx + b, cy - a),
|
||||
// v2 = (cx + a, cy + b), v3 = (cx - b, cy + a), a = R (cos + sin),
|
||||
// b = R (cos - sin). A pixel-to-clip matrix of its own (not the front
|
||||
// end's) draws it. Under Hor+ and Vert+ the presenter stretches the frame by
|
||||
// 1/scaleX in x or 1/scaleY in y (video::PlacePicture), so a square of frame
|
||||
// pixels came out 1/scaleX wide or 1/scaleY tall: 1.33x taller at 4:3,
|
||||
// 1.11x on a 16:10 Steam Deck, 1.34x wider at 3440x1440. The view's own
|
||||
// geometry doesn't, because its projection was narrowed by the same scale
|
||||
// (game/ultrawide.cpp).
|
||||
//
|
||||
// The fix scales the glow's corners about their centre by the view scales,
|
||||
// x by scaleX and y by scaleY (one of them is 1), after the game built them:
|
||||
// the stretch then gives back the square the game meant, with its angle, at
|
||||
// the size it has against the 16:9 band (the world's, the HUD's). The centre,
|
||||
// the sun's projected position, is already right. Only the glow is changed,
|
||||
// and only in the views the projection hook widens: the sun's occlusion
|
||||
// quad (the same builder, called from sub_82225438) keeps its 32x32 pixels,
|
||||
// so the share of it that is visible is still counted against the game's
|
||||
// expected 2048 samples; and in the environment cube's faces (views 18-23,
|
||||
// square targets sampled by direction, never stretched) the glow stays as
|
||||
// the game draws it.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace game::glare
|
||||
{
|
||||
// Return addresses of sub_822251C8's two calls (ctx.lr at entry): the
|
||||
// glow, from the sun code sub_82225730, and the occlusion quad, from
|
||||
// sub_82225438.
|
||||
constexpr uint32_t kGlowCall = 0x82225A40;
|
||||
constexpr uint32_t kOcclusionQuadCall = 0x822254B0;
|
||||
|
||||
// The views game/ultrawide.cpp widens (the projection hook's set): the
|
||||
// player's camera (1), 2, the road reflection (4, 5) and 17. The rest
|
||||
// (the shadow maps 13-16, the environment cube's faces 18-23) keep the
|
||||
// 16:9 projection.
|
||||
inline bool WidenedViewId(uint32_t id)
|
||||
{
|
||||
return id == 1 || id == 2 || id == 4 || id == 5 || id == 17;
|
||||
}
|
||||
|
||||
// The sprite's four corners (frame pixels), scaled about their centre:
|
||||
// x by scaleX, y by scaleY. The centre is the mean of the corners (the
|
||||
// builder's quad is symmetric about it).
|
||||
inline void FitSprite(float x[4], float y[4], float scaleX, float scaleY)
|
||||
{
|
||||
const float cx = (x[0] + x[1] + x[2] + x[3]) * 0.25f, cy = (y[0] + y[1] + y[2] + y[3]) * 0.25f;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
x[i] = cx + (x[i] - cx) * scaleX;
|
||||
y[i] = cy + (y[i] - cy) * scaleY;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The pipeline cache file's header (GetCachePath()/pipelines.bin), checked
|
||||
// against this device before the file goes to the driver. Vulkan-free:
|
||||
// tests/pipeline_cache_header_test.cpp checks it alone; gpu/renderer.cpp
|
||||
// hands it the file's bytes and the device's VkPhysicalDeviceProperties.
|
||||
//
|
||||
// vkGetPipelineCacheData's data starts with VkPipelineCacheHeaderVersionOne,
|
||||
// 32 bytes, every field least significant byte first on any host:
|
||||
// 0 headerSize (32) 4 headerVersion (1) 8 vendorID 12 deviceID
|
||||
// 16 pipelineCacheUUID[16]
|
||||
// The spec puts it there so an application can tell when its saved data no
|
||||
// longer fits the device. A driver sets such data aside without a word
|
||||
// (MoltenVK and Mesa compare the whole header), so the cache starts empty
|
||||
// and every pipeline compiles again on first use: a brief stutter, once,
|
||||
// and the next save writes this device's header. The header has no
|
||||
// driverVersion field: drivers fold their version into pipelineCacheUUID
|
||||
// (MoltenVK its revision, Mesa its build ID, NVIDIA its driver version), so
|
||||
// a driver update shows up as a UUID change.
|
||||
//
|
||||
// MoltenVK's device ID on an Apple GPU encodes the OS version: OS major (8
|
||||
// bits), OS minor (8), Mac GPU family (8), Apple GPU family (8)
|
||||
// (MVKPhysicalDevice::getHighestGPUCapability; its UUID carries the same
|
||||
// word). An M1 Pro reads 0x1a050207 on macOS 26.5 and 0x1b000207 on 27.0,
|
||||
// so every macOS update discards the cache: on the 27.0.1 upgrade, one
|
||||
// 103 ms frame at the first world entry. On SteamOS each Mesa update does
|
||||
// the same through the UUID.
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace gpu::pipeline_cache
|
||||
{
|
||||
constexpr size_t kHeaderBytes = 32;
|
||||
constexpr uint32_t kHeaderVersionOne = 1; // VK_PIPELINE_CACHE_HEADER_VERSION_ONE
|
||||
constexpr uint32_t kAppleVendor = 0x106B; // MoltenVK on Apple GPUs (Intel and AMD Macs report PCI IDs)
|
||||
|
||||
// Who made a cache: a file's header, or this device's properties
|
||||
// (vendorID, deviceID, pipelineCacheUUID).
|
||||
struct Maker
|
||||
{
|
||||
uint32_t vendorID = 0, deviceID = 0;
|
||||
std::array<uint8_t, 16> uuid{};
|
||||
};
|
||||
|
||||
enum class Verdict : uint8_t
|
||||
{
|
||||
Empty, // no data: nothing to load
|
||||
Usable, // this GPU, driver and OS made it: the driver takes it
|
||||
OtherMaker, // another GPU, driver or OS version made it: the driver would set it aside
|
||||
Unreadable, // shorter than a header, or not a version-one header
|
||||
};
|
||||
|
||||
struct HeaderCheck
|
||||
{
|
||||
Verdict verdict = Verdict::Empty;
|
||||
size_t bytes = 0;
|
||||
uint32_t headerSize = 0, headerVersion = 0; // as the file says (0 when shorter than a header)
|
||||
Maker file; // Usable and OtherMaker: the file's header
|
||||
bool vendorDiffers = false, deviceDiffers = false, uuidDiffers = false;
|
||||
};
|
||||
|
||||
inline uint32_t ReadLE32(const uint8_t* p)
|
||||
{
|
||||
return uint32_t(p[0]) | uint32_t(p[1]) << 8 | uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24;
|
||||
}
|
||||
|
||||
inline HeaderCheck CheckHeader(const uint8_t* data, size_t bytes, const Maker& device)
|
||||
{
|
||||
HeaderCheck c;
|
||||
c.bytes = bytes;
|
||||
if (bytes == 0)
|
||||
return c;
|
||||
c.verdict = Verdict::Unreadable;
|
||||
if (bytes < kHeaderBytes)
|
||||
return c;
|
||||
c.headerSize = ReadLE32(data);
|
||||
c.headerVersion = ReadLE32(data + 4);
|
||||
// MoltenVK and Mesa take exactly this header (Mesa compares all 32
|
||||
// bytes, headerSize included): a longer or newer one is unreadable.
|
||||
if (c.headerSize != kHeaderBytes || c.headerVersion != kHeaderVersionOne)
|
||||
return c;
|
||||
c.file.vendorID = ReadLE32(data + 8);
|
||||
c.file.deviceID = ReadLE32(data + 12);
|
||||
std::memcpy(c.file.uuid.data(), data + 16, c.file.uuid.size());
|
||||
c.vendorDiffers = c.file.vendorID != device.vendorID;
|
||||
c.deviceDiffers = c.file.deviceID != device.deviceID;
|
||||
c.uuidDiffers = c.file.uuid != device.uuid;
|
||||
c.verdict = c.vendorDiffers || c.deviceDiffers || c.uuidDiffers ? Verdict::OtherMaker : Verdict::Usable;
|
||||
return c;
|
||||
}
|
||||
|
||||
// The UUID as 32 hex digits in byte order, as `xxd -p` shows the file
|
||||
// (MoltenVK's: its revision, the device ID's word, then flags). Not
|
||||
// 8-4-4-4-12, so a log line isn't taken for a device's identifier.
|
||||
inline std::string UuidText(const std::array<uint8_t, 16>& uuid)
|
||||
{
|
||||
std::string out;
|
||||
char hex[3];
|
||||
for (uint8_t byte : uuid)
|
||||
{
|
||||
std::snprintf(hex, sizeof(hex), "%02x", byte);
|
||||
out += hex;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// What differs, file -> device: "OS 26.5 -> 27.0, device ID 1a050207 ->
|
||||
// 1b000207, cache UUID ... -> ...". The OS part only for MoltenVK on an
|
||||
// Apple GPU, whose device ID carries it.
|
||||
inline std::string DescribeChange(const HeaderCheck& c, const Maker& device)
|
||||
{
|
||||
std::string out;
|
||||
char buf[96];
|
||||
auto add = [&](const std::string& part) { out += (out.empty() ? "" : ", ") + part; };
|
||||
if (c.vendorDiffers)
|
||||
{
|
||||
std::snprintf(buf, sizeof(buf), "vendor %04x -> %04x", c.file.vendorID, device.vendorID);
|
||||
add(buf);
|
||||
}
|
||||
if (c.deviceDiffers)
|
||||
{
|
||||
if (!c.vendorDiffers && device.vendorID == kAppleVendor && (c.file.deviceID >> 16) != (device.deviceID >> 16))
|
||||
{
|
||||
std::snprintf(buf, sizeof(buf), "OS %u.%u -> %u.%u", c.file.deviceID >> 24, (c.file.deviceID >> 16) & 0xFF,
|
||||
device.deviceID >> 24, (device.deviceID >> 16) & 0xFF);
|
||||
add(buf);
|
||||
}
|
||||
std::snprintf(buf, sizeof(buf), "device ID %08x -> %08x", c.file.deviceID, device.deviceID);
|
||||
add(buf);
|
||||
}
|
||||
if (c.uuidDiffers)
|
||||
add("cache UUID " + UuidText(c.file.uuid) + " -> " + UuidText(device.uuid));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The log's line for a file the driver would set aside, "" for Empty
|
||||
// and Usable.
|
||||
inline std::string LogLine(const HeaderCheck& c, const Maker& device)
|
||||
{
|
||||
constexpr const char* kRebuild = "rebuilding shaders, expect a brief stutter on first use";
|
||||
const size_t kb = c.bytes / 1024;
|
||||
char buf[160];
|
||||
switch (c.verdict)
|
||||
{
|
||||
case Verdict::OtherMaker:
|
||||
return "[renderer] pipeline cache from another GPU, driver or OS version: " + std::string(kRebuild) + " (" +
|
||||
DescribeChange(c, device) + "; " + std::to_string(kb) + " KB set aside)";
|
||||
case Verdict::Unreadable:
|
||||
if (c.bytes < kHeaderBytes)
|
||||
std::snprintf(buf, sizeof(buf), "%zu bytes, shorter than its header", c.bytes);
|
||||
else
|
||||
std::snprintf(buf, sizeof(buf), "header size %u, version %u; %zu KB set aside", c.headerSize, c.headerVersion, kb);
|
||||
return "[renderer] pipeline cache file not readable: " + std::string(kRebuild) + " (" + buf + ")";
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The internal resolution as a ratio: on each axis `num` host pixels for
|
||||
// every `den` guest pixels. 1x is 1/1, 2x 2/1, 3x 3/1, and 1.5x is 3/2:
|
||||
// 1280x720 becomes 1920x1080. Vulkan-free; gpu/renderer.cpp uses it for
|
||||
// every guest <-> host pixel mapping, and tests/render_scale_test.cpp checks
|
||||
// it alone.
|
||||
//
|
||||
// Whole scales map guest pixel g to host pixels [g s, g s + s). At 3/2 a
|
||||
// guest pixel is a pixel and a half, so the mapping works on blocks: every
|
||||
// two guest pixels (an even one and the odd one after it) are exactly three
|
||||
// host pixels. On each axis:
|
||||
// - Edges: guest edge g is host edge floor(g num / den) (HostEdge). A
|
||||
// guest rectangle's host one is its edges', so rectangles that share a
|
||||
// guest edge share the host edge, with no gap and no overlap: draw
|
||||
// scissors, clears, pass areas and what a resolve reads all use it. It is
|
||||
// also the rasteriser's own rule for a pixel-aligned quad (a host pixel
|
||||
// whose centre lies exactly on an odd guest edge belongs to the pixel
|
||||
// after it, the top-left rule), so a scissor and the quad it clips agree.
|
||||
// Even guest coordinates land exactly (g 3/2 is whole); the game's
|
||||
// resolves are 8-pixel aligned and its tile offsets even, so every
|
||||
// resolve, clear of a resolve and tile band maps exactly.
|
||||
// - Ownership: guest pixel g owns host pixels [HostEdge(g), HostEdge(g+1)):
|
||||
// at 3/2 an even pixel owns one, the odd one after it two. Each host
|
||||
// pixel has exactly one owner (sbOwner).
|
||||
// - The 1x average a resolve writes into guest memory weighs every host
|
||||
// pixel by how much of it lies inside the guest pixel (sbOverlap: a box
|
||||
// filter, 2/3 and 1/3 at 3/2), so the mean over any whole number of
|
||||
// blocks is the host mean, and an edge between two colours lands where
|
||||
// it was. Whole scales give every host pixel weight 1, as before.
|
||||
// - A depth resolve keeps one sample per guest pixel: the first at a whole
|
||||
// scale (as before), at 3/2 the host pixel holding the guest pixel's
|
||||
// centre (sbCenterHost).
|
||||
//
|
||||
// The scaled resolve memory (renderer.cpp's ScaledMemory) keeps each host
|
||||
// sample of a 32-bit tiled surface, found from the guest address of the texel
|
||||
// that owns it, so a resolve (the writer) and a texture or the front buffer
|
||||
// (the readers) meet whatever the pitch and base of each. In Xenos's 32-bit
|
||||
// tiling a texel's word in its 4 KB page has x & 1 in bit 0 and y & 1 in bit 2
|
||||
// (the other bits depend only on x >> 1 and y >> 1), so a 2x2 block of texels
|
||||
// at even (x, y) is the words w, w + 1, w + 4, w + 5 of one page, whatever the
|
||||
// pitch. A page's slot holds its blocks in order of their word with the
|
||||
// sub-texel bits taken out (sbBlockIndex), each block's num.x x num.y host
|
||||
// samples row by row. At 3/2: 256 blocks of 9 samples, 9 KB a 4 KB page; at
|
||||
// a whole scale a block is one texel and this is the layout from before
|
||||
// (word x s + j x sx + i).
|
||||
//
|
||||
// So 1.5x only covers surfaces whose texels come in whole blocks: a scaled
|
||||
// texture or front buffer needs an even width and height at 3/2 (Exact),
|
||||
// else it is used at 1x; a resolve needs an even origin, which the 8-pixel
|
||||
// alignment gives (else it isn't kept at host resolution, logged once).
|
||||
//
|
||||
// The block arithmetic is written once, in tokens that are both GLSL and
|
||||
// C++ (SB_SCALE_FUNCTIONS, as gpu/depth24_glsl.h does for its pack): the shaders
|
||||
// get it as text (GLSL_SCALE_CODE, spliced into renderer.cpp's resolve,
|
||||
// fill, untile and front-buffer shaders) and the test runs the same code.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
// uint arithmetic only (wrapping 32-bit in both languages); `1u` literals,
|
||||
// min/max of two uints, if, ?:. Every function's name starts "sb".
|
||||
#define SB_SCALE_FUNCTIONS \
|
||||
/* Host edge of guest edge g: floor(g num / den). */ \
|
||||
uint sbHostEdge(uint g, uint num, uint den) { return g * num / den; } \
|
||||
/* The host pixels covering guest pixels [0, g): ceil(g num / den), a target's or image's size. */ \
|
||||
uint sbHostSize(uint g, uint num, uint den) { return (g * num + den - 1u) / den; } \
|
||||
/* The guest edge whose host edge is h (h from sbHostEdge): ceil(h den / num). */ \
|
||||
uint sbGuestEdge(uint h, uint num, uint den) { return (h * den + num - 1u) / num; } \
|
||||
/* The guest pixel owning host pixel h: the g with HostEdge(g) <= h < HostEdge(g + 1). */ \
|
||||
uint sbOwner(uint h, uint num, uint den) { return ((h + 1u) * den + num - 1u) / num - 1u; } \
|
||||
/* How much of host pixel h lies in guest pixel g, in 1/den of a pixel (num for the whole guest pixel). */ \
|
||||
uint sbOverlap(uint g, uint h, uint num, uint den) \
|
||||
{ \
|
||||
uint lo = max(g * num, h * den); \
|
||||
uint hi = min((g + 1u) * num, (h + 1u) * den); \
|
||||
return hi > lo ? hi - lo : 0u; \
|
||||
} \
|
||||
/* The one host pixel a depth resolve reads for guest pixel g. */ \
|
||||
uint sbCenterHost(uint g, uint num, uint den) { return den == 1u ? g * num : ((2u * g + 1u) * num) / (2u * den); } \
|
||||
/* A word of a 4 KB page of 32-bit tiled texels (0..1023): its block (the sub-texel bits out). */ \
|
||||
uint sbBlockIndex(uint w, uint denX, uint denY) \
|
||||
{ \
|
||||
if (denY == 2u) \
|
||||
w = (w & 3u) | ((w >> 3u) << 2u); \
|
||||
if (denX == 2u) \
|
||||
w = w >> 1u; \
|
||||
return w; \
|
||||
} \
|
||||
/* A block's first word (its texel at even x and y). */ \
|
||||
uint sbBlockWord(uint b, uint denX, uint denY) \
|
||||
{ \
|
||||
if (denX == 2u) \
|
||||
b = b << 1u; \
|
||||
if (denY == 2u) \
|
||||
b = (b & 3u) | ((b >> 2u) << 3u); \
|
||||
return b; \
|
||||
} \
|
||||
/* A word's texel within its block: x & 1 (bit 0) and y & 1 (bit 2), at 3/2 only. */ \
|
||||
uint sbSubX(uint w, uint denX) { return denX == 2u ? (w & 1u) : 0u; } \
|
||||
uint sbSubY(uint w, uint denY) { return denY == 2u ? ((w >> 2u) & 1u) : 0u; } \
|
||||
/* A 4 KB page's samples: its blocks' x theirs. */ \
|
||||
uint sbSlotWords(uint numX, uint numY, uint denX, uint denY) { return 1024u / (denX * denY) * (numX * numY); } \
|
||||
/* Host sample (i, j) of word w's block, i < numX and j < numY from the block's corner: its word in the slot. */ \
|
||||
uint sbSampleWord(uint w, uint i, uint j, uint numX, uint numY, uint denX, uint denY) \
|
||||
{ \
|
||||
return sbBlockIndex(w, denX, denY) * (numX * numY) + j * numX + i; \
|
||||
}
|
||||
|
||||
#define SB_SCALE_STRINGIFY_(...) #__VA_ARGS__
|
||||
#define SB_SCALE_STRINGIFY(...) SB_SCALE_STRINGIFY_(__VA_ARGS__)
|
||||
// The functions as the shaders have them (comments are gone by now).
|
||||
#define GLSL_SCALE_CODE "\n// Guest <-> host pixels at the internal resolution (gpu/render_scale.h).\n" \
|
||||
SB_SCALE_STRINGIFY(SB_SCALE_FUNCTIONS) "\n"
|
||||
|
||||
namespace gpu
|
||||
{
|
||||
// SB_SCALE_FUNCTIONS as C++, the shaders' own tokens: member functions
|
||||
// (defined in the class, so inline), called on a temporary, ScaleGlsl{}.
|
||||
struct ScaleGlsl
|
||||
{
|
||||
using uint = uint32_t;
|
||||
static uint min(uint a, uint b) { return a < b ? a : b; }
|
||||
static uint max(uint a, uint b) { return a > b ? a : b; }
|
||||
SB_SCALE_FUNCTIONS
|
||||
};
|
||||
|
||||
// One axis: num host pixels for every den guest pixels (den 1 or 2).
|
||||
struct AxisScale
|
||||
{
|
||||
uint32_t num = 1, den = 1;
|
||||
|
||||
bool operator==(const AxisScale&) const = default;
|
||||
bool IsOne() const { return num == den; }
|
||||
float Value() const { return float(num) / float(den); }
|
||||
uint32_t Edge(uint32_t g) const { return ScaleGlsl{}.sbHostEdge(g, num, den); }
|
||||
uint32_t Size(uint32_t g) const { return ScaleGlsl{}.sbHostSize(g, num, den); }
|
||||
uint32_t GuestEdge(uint32_t h) const { return ScaleGlsl{}.sbGuestEdge(h, num, den); }
|
||||
// Guest length g covers whole host pixels (every block whole).
|
||||
bool Exact(uint32_t g) const { return g % den == 0; }
|
||||
// A guest coordinate clamped to 0 first (rectangles' signed edges).
|
||||
int32_t EdgeAt(int32_t g) const { return g <= 0 ? 0 : int32_t(Edge(uint32_t(g))); }
|
||||
};
|
||||
|
||||
struct RenderScale
|
||||
{
|
||||
AxisScale x, y;
|
||||
|
||||
bool operator==(const RenderScale&) const = default;
|
||||
bool IsOne() const { return x.IsOne() && y.IsOne(); }
|
||||
// Host samples a block of den.x x den.y guest texels has.
|
||||
uint32_t SamplesPerBlock() const { return x.num * y.num; }
|
||||
// A 4 KB guest page's host samples (words) in the scaled memory.
|
||||
uint32_t SlotWords() const { return ScaleGlsl{}.sbSlotWords(x.num, y.num, x.den, y.den); }
|
||||
// A width x height surface maps to whole host pixels and blocks.
|
||||
bool Exact(uint32_t width, uint32_t height) const { return x.Exact(width) && y.Exact(height); }
|
||||
// Host pixels per guest pixel as a ratio (the occlusion weight):
|
||||
// pixelsNum / pixelsDen (9/4 at 1.5x).
|
||||
uint32_t PixelsNum() const { return x.num * y.num; }
|
||||
uint32_t PixelsDen() const { return x.den * y.den; }
|
||||
// Host pixels a frame of guest w x h has (sizes the scratch buffer).
|
||||
uint64_t HostPixels(uint32_t w, uint32_t h) const { return uint64_t(x.Size(w)) * y.Size(h); }
|
||||
|
||||
static RenderScale Whole(uint32_t sx, uint32_t sy) { return { { sx, 1 }, { sy, 1 } }; }
|
||||
};
|
||||
|
||||
// An axis from a number of half steps (2: 1x, 3: 1.5x, 4: 2x, 6: 3x),
|
||||
// reduced (4/2 is 2/1).
|
||||
inline AxisScale AxisFromHalves(uint32_t halves)
|
||||
{
|
||||
halves = std::max(halves, 2u);
|
||||
return halves % 2 == 0 ? AxisScale{ halves / 2, 1 } : AxisScale{ halves, 2 };
|
||||
}
|
||||
|
||||
// "1", "1.5", "2" (the log's and settings' spelling).
|
||||
inline std::string AxisName(const AxisScale& a)
|
||||
{
|
||||
if (a.den == 1)
|
||||
return std::to_string(a.num);
|
||||
char text[16];
|
||||
snprintf(text, sizeof(text), "%g", double(a.num) / double(a.den));
|
||||
return text;
|
||||
}
|
||||
|
||||
// "1x1", "1.5x1.5", "3x2": as the log has always named the scale.
|
||||
inline std::string ScaleName(const RenderScale& s)
|
||||
{
|
||||
return AxisName(s.x) + "x" + AxisName(s.y);
|
||||
}
|
||||
|
||||
// The scales the renderer takes on an axis: 1, 1.5, 2, 3, 4 (the
|
||||
// whole ones of NFSMW_RENDER_SCALE before 1.5x, and 1.5x). Other
|
||||
// fractions are refused: false.
|
||||
inline bool AxisFromNumber(double v, AxisScale& out)
|
||||
{
|
||||
if (!(v >= 0.0)) // (NaN too)
|
||||
return false;
|
||||
v = std::max(v, 1.0); // (0 was 1x, as the old whole-number parse clamped it)
|
||||
const double halves = std::round(v * 2.0);
|
||||
if (std::fabs(v * 2.0 - halves) > 1e-6)
|
||||
return false;
|
||||
const uint32_t h = uint32_t(std::clamp(halves, 2.0, 8.0));
|
||||
if (h % 2 == 1 && h != 3)
|
||||
return false; // 2.5x, 3.5x: not offered
|
||||
out = AxisFromHalves(h);
|
||||
return true;
|
||||
}
|
||||
|
||||
// NFSMW_RENDER_SCALE: "<s>" or "<sx>x<sy>", each 1, 1.5, 2, 3 or 4
|
||||
// ("2", "1.5", "3x2", "1.5x1"); whole numbers above 4 are clamped to 4,
|
||||
// as before. False for anything else (the caller logs it).
|
||||
inline bool ParseRenderScale(const char* text, RenderScale& out)
|
||||
{
|
||||
if (!text || !*text)
|
||||
return false;
|
||||
char* end = nullptr;
|
||||
const double a = std::strtod(text, &end);
|
||||
if (end == text)
|
||||
return false;
|
||||
double b = a;
|
||||
if (*end == 'x' || *end == 'X')
|
||||
{
|
||||
const char* second = end + 1;
|
||||
b = std::strtod(second, &end);
|
||||
if (end == second)
|
||||
return false;
|
||||
}
|
||||
if (*end != '\0')
|
||||
return false;
|
||||
RenderScale s;
|
||||
if (!AxisFromNumber(a, s.x) || !AxisFromNumber(b, s.y))
|
||||
return false;
|
||||
out = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+434
-206
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,9 @@
|
||||
#include "installer.h"
|
||||
#include "locate.h"
|
||||
#include "placement.h"
|
||||
#include "portable_switch.h"
|
||||
|
||||
#include <user/paths.h>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
@@ -120,7 +123,8 @@ namespace install
|
||||
"usage: SpeedBreaker --install <image|folder> [--dest <dir>]\n"
|
||||
" SpeedBreaker --verify <image|folder>\n"
|
||||
" SpeedBreaker --find-images [folder...]\n"
|
||||
" SpeedBreaker --where\n", message);
|
||||
" SpeedBreaker --where\n"
|
||||
" SpeedBreaker --portable on|off\n", message);
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -211,6 +215,17 @@ namespace install
|
||||
return images.empty() ? 1 : 0;
|
||||
}
|
||||
|
||||
// Where settings, saves and caches are, and whether that's portable mode.
|
||||
void PrintDataFolders()
|
||||
{
|
||||
const portable::Layout& layout = portable::Current();
|
||||
if (layout.Active())
|
||||
printf("Portable mode (%s in %s): data in %s, caches in %s\n", portable::kMarker, layout.root.c_str(),
|
||||
GetUserPath().c_str(), GetCachePath().c_str());
|
||||
else
|
||||
printf("Data in %s, caches in %s\n", GetUserPath().c_str(), GetCachePath().c_str());
|
||||
}
|
||||
|
||||
int RunWhere()
|
||||
{
|
||||
std::optional<GameInstall> game = FindGameInstall();
|
||||
@@ -226,11 +241,46 @@ namespace install
|
||||
if (!notice.empty())
|
||||
printf("%s\n", notice.c_str());
|
||||
printf("No game install found. Install one with --install <image|folder> [--dest <dir>].\n");
|
||||
PrintDataFolders();
|
||||
return 1;
|
||||
}
|
||||
const char* origin = game->origin == InstallOrigin::Environment ? "NFSMW_GAME_DIR"
|
||||
: game->origin == InstallOrigin::Development ? "development checkout" : "installed";
|
||||
printf("%s (%s%s)\n", game->path.c_str(), origin, game->marker ? ", verified by the installer" : "");
|
||||
PrintDataFolders();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --portable on|off (install/portable_switch.h): the copy, with the
|
||||
// game's copy shown as --install shows it.
|
||||
int RunPortable(bool on)
|
||||
{
|
||||
if (!portable::kSupported)
|
||||
{
|
||||
fprintf(stderr, "Portable mode isn't available on macOS or iOS: SpeedBreaker keeps its data in the system's "
|
||||
"folder for apps there.\n");
|
||||
return 1;
|
||||
}
|
||||
InterruptGuard guard;
|
||||
GameCopier copier = [](const fs::path& from, const fs::path& to) -> Result
|
||||
{
|
||||
std::unique_ptr<DiscSource> source;
|
||||
if (Result r = OpenDiscSource(from, source); !r.Ok())
|
||||
return r;
|
||||
printf("Copying the game from %s (%s), checking every file:\n", from.c_str(),
|
||||
FormatSize(GameManifest().totalBytes).c_str());
|
||||
ProgressPrinter printer;
|
||||
Result r = Install(*source, to, std::ref(printer), &s_cancel);
|
||||
printer.Finish();
|
||||
return r;
|
||||
};
|
||||
std::vector<std::string> said;
|
||||
Result r = on ? MakePortable(BundleFolder(), HomeUserPath(), copier, said)
|
||||
: LeavePortable(portable::Current(), HomeUserPath(), copier, said);
|
||||
for (const std::string& line : said)
|
||||
printf("%s\n", line.c_str());
|
||||
if (!r.Ok())
|
||||
return Failed(r);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -269,6 +319,14 @@ namespace install
|
||||
return RunFindImages(std::vector<fs::path>(argv + i + 1, argv + argc));
|
||||
if (arg == "--where")
|
||||
return RunWhere();
|
||||
if (arg == "--portable")
|
||||
{
|
||||
std::string_view what = i + 1 < argc ? std::string_view(argv[i + 1]) : std::string_view();
|
||||
if ((what != "on" && what != "off") || i + 2 < argc)
|
||||
return Usage("--portable takes on (copy this computer's SpeedBreaker data and game into SpeedBreaker's "
|
||||
"folder, and keep them there from now on) or off (copy them back).");
|
||||
return RunPortable(what == "on");
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// SpeedBreaker --install <image|folder> [--dest <dir>] copy and verify (default dest: GetUserPath()/game)
|
||||
// SpeedBreaker --verify <image|folder> hash-check every file without copying
|
||||
// SpeedBreaker --find-images [folder...] list disc images in the usual places (or these)
|
||||
// SpeedBreaker --where print where the game install was found
|
||||
// SpeedBreaker --where print where the game install was found, and the data folders
|
||||
// SpeedBreaker --portable on|off copy the data and the game into SpeedBreaker's folder and keep
|
||||
// them there (portable mode), or back (install/portable_switch.h)
|
||||
// Ctrl-C cancels an install cleanly (the partial copy is removed).
|
||||
#pragma once
|
||||
#include <optional>
|
||||
|
||||
+40
-26
@@ -186,24 +186,41 @@ namespace install
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> RecordedInstallPath()
|
||||
std::optional<std::filesystem::path> ReadInstallRecord(const std::filesystem::path& file, const portable::Layout& layout)
|
||||
{
|
||||
FILE* f = fopen(LocationFile().c_str(), "rb");
|
||||
FILE* f = fopen(file.c_str(), "rb");
|
||||
if (!f)
|
||||
return std::nullopt;
|
||||
char buffer[4096];
|
||||
size_t n = fread(buffer, 1, sizeof(buffer) - 1, f);
|
||||
fclose(f);
|
||||
std::string text(buffer, n);
|
||||
while (!text.empty() && (text.back() == '\n' || text.back() == '\r'))
|
||||
text.pop_back();
|
||||
if (text.empty() || text.front() != '/')
|
||||
return std::nullopt;
|
||||
// No trailing separator (records before v0.1.1 kept `--dest dir/`'s).
|
||||
fs::path path = fs::path(text).lexically_normal();
|
||||
while (path.has_relative_path() && path.filename().empty())
|
||||
path = path.parent_path();
|
||||
return path;
|
||||
// Absolute, or (portable mode) relative to SpeedBreaker's folder; no
|
||||
// trailing separator (records before v0.1.1 kept `--dest dir/`'s).
|
||||
return portable::ParseRecord(std::string_view(buffer, n), layout);
|
||||
}
|
||||
|
||||
bool WriteInstallRecord(const std::filesystem::path& file, const std::string& text)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::create_directories(file.parent_path(), ec);
|
||||
fs::path temp = file;
|
||||
temp += ".tmp";
|
||||
FILE* f = fopen(temp.c_str(), "wb");
|
||||
if (!f)
|
||||
return false;
|
||||
std::string line = text + "\n";
|
||||
bool ok = fwrite(line.data(), 1, line.size(), f) == line.size();
|
||||
ok = fflush(f) == 0 && ok;
|
||||
fsync(fileno(f));
|
||||
fclose(f);
|
||||
if (ok)
|
||||
fs::rename(temp, file, ec);
|
||||
return ok && !ec;
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> RecordedInstallPath()
|
||||
{
|
||||
return ReadInstallRecord(LocationFile(), portable::Current());
|
||||
}
|
||||
|
||||
bool RecordInstallPath(const std::filesystem::path& dir)
|
||||
@@ -218,25 +235,22 @@ namespace install
|
||||
fs::remove(file, ec);
|
||||
return !ec;
|
||||
}
|
||||
fs::create_directories(file.parent_path(), ec);
|
||||
fs::path temp = file;
|
||||
temp += ".tmp";
|
||||
FILE* f = fopen(temp.c_str(), "wb");
|
||||
if (!f)
|
||||
return false;
|
||||
std::string text = folder.string() + "\n";
|
||||
bool ok = fwrite(text.data(), 1, text.size(), f) == text.size();
|
||||
ok = fflush(f) == 0 && ok;
|
||||
fsync(fileno(f));
|
||||
fclose(f);
|
||||
if (ok)
|
||||
fs::rename(temp, file, ec);
|
||||
return ok && !ec;
|
||||
// In portable mode, a folder inside SpeedBreaker's is recorded
|
||||
// relative to it, so the record still holds once the folder moves.
|
||||
return WriteInstallRecord(file, portable::RecordText(folder, portable::Current()));
|
||||
}
|
||||
|
||||
std::vector<InstallDestination> InstallDestinations()
|
||||
{
|
||||
std::vector<InstallDestination> out;
|
||||
// Portable mode: SpeedBreaker's own folder (its data/game), and no
|
||||
// drive's folder, which would leave the game behind when the folder
|
||||
// moves. Choose a folder... still takes any folder.
|
||||
if (portable::Current().Active())
|
||||
{
|
||||
out.push_back({ "SpeedBreaker's folder", DefaultInstallPath(), false });
|
||||
return out;
|
||||
}
|
||||
out.push_back({ "Internal storage", DefaultInstallPath(), false });
|
||||
auto addVolume = [&](const fs::path& volume)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "manifest.h"
|
||||
#include "result.h"
|
||||
|
||||
#include <user/portable.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
@@ -53,10 +55,18 @@ namespace install
|
||||
// Where the player chose to install when it wasn't DefaultInstallPath()
|
||||
// (an SD card, a USB drive): recorded in GetUserPath()/game-location by
|
||||
// the installer, and searched by FindGameInstall after NFSMW_GAME_DIR and
|
||||
// ./game/files. Recording the default path removes the record.
|
||||
// ./game/files. Recording the default path removes the record. In
|
||||
// portable mode a folder inside SpeedBreaker's own is recorded relative to
|
||||
// it (user/portable.h), so the record moves with the folder.
|
||||
std::optional<std::filesystem::path> RecordedInstallPath();
|
||||
bool RecordInstallPath(const std::filesystem::path& dir);
|
||||
|
||||
// The same for any record file, read as `layout` says (a relative record
|
||||
// only in portable mode), and a record's text written to one, atomically
|
||||
// (install/portable_switch.h, which moves records between data folders).
|
||||
std::optional<std::filesystem::path> ReadInstallRecord(const std::filesystem::path& file, const portable::Layout& layout);
|
||||
bool WriteInstallRecord(const std::filesystem::path& file, const std::string& text);
|
||||
|
||||
struct InstallDestination
|
||||
{
|
||||
std::string label; // "Internal storage", or the drive's name
|
||||
@@ -66,6 +76,7 @@ namespace install
|
||||
|
||||
// Where the installer offers to put the game: DefaultInstallPath() first,
|
||||
// then a folder on each mounted removable drive (<drive>/speedbreaker/game; SpeedBreaker on macOS).
|
||||
// In portable mode, only DefaultInstallPath() (in SpeedBreaker's folder).
|
||||
std::vector<InstallDestination> InstallDestinations();
|
||||
|
||||
struct DiscImageCandidate
|
||||
|
||||
@@ -118,6 +118,29 @@ namespace install
|
||||
strerror(error ? error : EACCES));
|
||||
}
|
||||
|
||||
// Portable mode: the parts of SpeedBreaker's folder (`root`) the game
|
||||
// can't go in, and why ("" for the rest of it). `named`: the folder
|
||||
// as the player named it.
|
||||
std::string PortableReserved(const fs::path& gameDir, const fs::path& named, const fs::path& root)
|
||||
{
|
||||
if (gameDir == root)
|
||||
return std::format("{} is SpeedBreaker's own folder, which holds the program. Choose a folder inside it, "
|
||||
"such as {} (where the game goes by default), or another folder.", root.string(),
|
||||
(root / portable::kDataFolder / "game").string());
|
||||
const fs::path relative = gameDir.lexically_relative(root);
|
||||
const fs::path first = *relative.begin();
|
||||
if (first == "lib" || first == "licenses")
|
||||
return std::format("{} is inside SpeedBreaker's {} folder, which holds the program's own files: an update "
|
||||
"overwrites them. Choose another folder.", named.string(), first.string());
|
||||
if (first == portable::kCacheFolder)
|
||||
return std::format("{} is inside SpeedBreaker's cache folder, which can be deleted at any time. Choose "
|
||||
"another folder.", named.string());
|
||||
if (relative == portable::kDataFolder)
|
||||
return std::format("{} holds your settings and saves. Choose a folder inside it, such as {} (where the game "
|
||||
"goes by default), or another folder.", gameDir.string(), (gameDir / "game").string());
|
||||
return {};
|
||||
}
|
||||
|
||||
// The refusals every game folder gets, picked or exact. Ok if none.
|
||||
// `picked` is the folder the player chose (gameDir, or the folder
|
||||
// gameDir would be made in): the messages name it, not a subfolder
|
||||
@@ -130,7 +153,15 @@ namespace install
|
||||
if (!context.home.empty() && gameDir == Normal(context.home))
|
||||
return Refusal(Error::DestinationNotWritable, std::format("{} is your home folder itself. Choose a folder in "
|
||||
"it instead.", gameDir.string()), gameDir);
|
||||
if (fs::path bundle = Normal(context.bundle); !bundle.empty() && IsAtOrInside(gameDir, bundle))
|
||||
// Portable mode: SpeedBreaker's folder is where the player keeps
|
||||
// the game (data/game by default), except its own parts.
|
||||
if (fs::path root = Normal(context.portable); !root.empty() && IsAtOrInside(gameDir, root))
|
||||
{
|
||||
const fs::path& named = IsAtOrInside(picked, root) ? picked : gameDir;
|
||||
if (std::string message = PortableReserved(gameDir, named, root); !message.empty())
|
||||
return Refusal(Error::DestinationNotWritable, std::move(message), gameDir);
|
||||
}
|
||||
else if (fs::path bundle = Normal(context.bundle); !bundle.empty() && IsAtOrInside(gameDir, bundle))
|
||||
{
|
||||
const fs::path& named = IsAtOrInside(picked, bundle) ? picked : gameDir;
|
||||
std::string message;
|
||||
@@ -170,6 +201,9 @@ namespace install
|
||||
Result FileSystemRules(const fs::path& gameDir, const fs::path& picked, const FolderFacts& facts,
|
||||
const PlacementContext& context, std::vector<std::string>& notes)
|
||||
{
|
||||
if (fs::path root = Normal(context.portable); !root.empty() && !IsAtOrInside(gameDir, root))
|
||||
notes.push_back(std::format("This folder is outside SpeedBreaker's folder ({}): in portable mode, the game "
|
||||
"there won't move with it.", root.string()));
|
||||
if (context.macOS)
|
||||
{
|
||||
// iCloud Drive (and Desktop and Documents, when the Mac keeps
|
||||
@@ -746,6 +780,8 @@ namespace install
|
||||
context.home = Resolved(home);
|
||||
if (fs::path bundle = BundleFolder(); !bundle.empty())
|
||||
context.bundle = Resolved(bundle);
|
||||
if (const portable::Layout& layout = portable::Current(); layout.Active())
|
||||
context.portable = Resolved(layout.root);
|
||||
context.installs.push_back(Resolved(DefaultInstallPath()));
|
||||
if (std::optional<fs::path> recorded = RecordedInstallPath())
|
||||
context.installs.push_back(Resolved(*recorded));
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
// or an uninstall replaces it, and on the Mac the app's signature would
|
||||
// break) or anything inside it, or a folder inside an install (the
|
||||
// default one or the one recorded last time).
|
||||
// - Portable mode (user/portable.h): SpeedBreaker's own folder is where
|
||||
// the game goes (data/game by default), so inside it is fine, except the
|
||||
// folder's top itself (the program's), lib/ and licenses/ (the
|
||||
// download's own files, overwritten by an update), data/ itself
|
||||
// (settings and saves) and cache/ (deleted at will). A folder outside it
|
||||
// gets a note: the game there won't move with the folder.
|
||||
// - Never into an existing folder SpeedBreaker can't write: Install
|
||||
// replaces it or moves it aside, which macOS refuses for a folder its
|
||||
// owner can't write, after the whole copy.
|
||||
@@ -120,6 +126,7 @@ namespace install
|
||||
{
|
||||
std::filesystem::path home; // the home folder (empty: unknown)
|
||||
std::filesystem::path bundle; // SpeedBreaker's own folder (empty: unknown)
|
||||
std::filesystem::path portable; // the same in portable mode (user/portable.h); empty otherwise
|
||||
std::vector<std::filesystem::path> installs; // installs nothing may go inside: the default and the recorded one
|
||||
uint64_t largestFile = 0; // the manifest's biggest file
|
||||
bool macOS = false;
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See portable_switch.h.
|
||||
#include "portable_switch.h"
|
||||
#include "installer.h"
|
||||
#include "locate.h"
|
||||
#include "placement.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <format>
|
||||
#include <optional>
|
||||
#include <system_error>
|
||||
|
||||
namespace install
|
||||
{
|
||||
namespace
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
Result Fail(Error error, std::string message, const fs::path& path = {})
|
||||
{
|
||||
Result r;
|
||||
r.error = error;
|
||||
r.message = std::move(message);
|
||||
r.file = path.string();
|
||||
return r;
|
||||
}
|
||||
|
||||
// Not there, or a folder with nothing in it (hidden files count).
|
||||
bool EmptyOrMissing(const fs::path& dir)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::file_status status = fs::symlink_status(dir, ec);
|
||||
if (status.type() == fs::file_type::not_found)
|
||||
return true;
|
||||
if (ec || !fs::is_directory(status))
|
||||
return false;
|
||||
fs::directory_iterator it(dir, ec);
|
||||
return !ec && it == fs::directory_iterator();
|
||||
}
|
||||
|
||||
// An unfinished copy from an earlier attempt goes (only one carrying
|
||||
// the tag: anything else of that name is the player's).
|
||||
Result ClearPartial(const fs::path& partial, std::vector<std::string>& said)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::file_status status = fs::symlink_status(partial, ec);
|
||||
if (status.type() == fs::file_type::not_found)
|
||||
return {};
|
||||
std::error_code tagEc;
|
||||
if (!fs::is_directory(status) || !fs::is_regular_file(fs::symlink_status(partial / kPortablePartialTag, tagEc)))
|
||||
return Fail(Error::DestinationNotEmpty, std::format("{} is in the way, and it isn't an unfinished copy of "
|
||||
"SpeedBreaker's. Move it or delete it, then try again.", partial.string()), partial);
|
||||
fs::remove_all(partial, ec);
|
||||
if (ec)
|
||||
return Fail(Error::WriteError, std::format("Couldn't delete {}, an unfinished copy from an earlier attempt ({}). "
|
||||
"Delete it, then try again.", partial.string(), ec.message()), partial);
|
||||
said.push_back(std::format("Deleted {}, an unfinished copy from an earlier attempt.", partial.string()));
|
||||
return {};
|
||||
}
|
||||
|
||||
Result StartPartial(const fs::path& partial)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::create_directories(partial.parent_path(), ec);
|
||||
fs::create_directory(partial, ec);
|
||||
if (!ec)
|
||||
{
|
||||
FILE* f = fopen((partial / kPortablePartialTag).string().c_str(), "wb");
|
||||
if (f && fputs("An unfinished copy of SpeedBreaker's data (speedbreaker.sh --portable).\n", f) >= 0 && fclose(f) == 0)
|
||||
return {};
|
||||
if (f)
|
||||
fclose(f);
|
||||
ec = std::error_code(errno ? errno : EIO, std::generic_category());
|
||||
}
|
||||
return Fail(Error::DestinationNotWritable, std::format("SpeedBreaker can't write to {} ({}): nothing was copied.",
|
||||
partial.parent_path().string(), ec.message()), partial);
|
||||
}
|
||||
|
||||
// The installed game a data folder's record and default point to, as
|
||||
// FindGameInstall picks it: the recorded one if it's usable, else the
|
||||
// default one.
|
||||
struct FoundGame
|
||||
{
|
||||
fs::path dir; // the game to copy (empty: none)
|
||||
std::optional<fs::path> lost; // a recorded install that isn't usable (and no default one is)
|
||||
std::vector<fs::path> skipped; // left out of the data copy: the game folders and their in-between folders
|
||||
};
|
||||
|
||||
FoundGame FindGame(const fs::path& data, const portable::Layout& layout, const Manifest& manifest)
|
||||
{
|
||||
FoundGame found;
|
||||
fs::path fallback = data / "game";
|
||||
std::optional<fs::path> recorded = ReadInstallRecord(data / "game-location", layout);
|
||||
if (recorded && IsUsableInstall(*recorded, true, manifest))
|
||||
found.dir = *recorded;
|
||||
else if (IsUsableInstall(fallback, true, manifest))
|
||||
found.dir = fallback;
|
||||
else if (recorded)
|
||||
found.lost = recorded;
|
||||
for (const std::optional<fs::path>& game : { std::optional<fs::path>(fallback), recorded })
|
||||
if (game)
|
||||
{
|
||||
fs::path dir = ResolvedFolder(*game);
|
||||
found.skipped.push_back(dir);
|
||||
found.skipped.push_back(fs::path(dir.string() + ".partial"));
|
||||
found.skipped.push_back(fs::path(dir.string() + ".previous"));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
struct CopyStats
|
||||
{
|
||||
uintmax_t files = 0, bytes = 0;
|
||||
std::vector<std::string> skipped; // links and special files, not copied
|
||||
};
|
||||
|
||||
// `from`'s contents into the folder `to` (made by the caller): folders
|
||||
// and files, with their modification times (the bug report and the log
|
||||
// rotation sort by them). Left out: the game's record at the top, and
|
||||
// the folders in `skip` (the game and its in-between folders).
|
||||
Result CopyData(const fs::path& from, const fs::path& to, const std::vector<fs::path>& skip, CopyStats& stats, bool top)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::directory_iterator it(from, ec);
|
||||
for (fs::directory_iterator end; !ec && it != end; it.increment(ec))
|
||||
{
|
||||
const fs::path source = it->path();
|
||||
const fs::path name = source.filename();
|
||||
if (top && (name == "game-location" || name == "game-location.tmp" || name == ".write-check"))
|
||||
continue;
|
||||
if (std::any_of(skip.begin(), skip.end(), [&](const fs::path& s) { return IsAtOrInside(source, s); }))
|
||||
continue;
|
||||
std::error_code typeEc;
|
||||
fs::file_status status = it->symlink_status(typeEc);
|
||||
const fs::path target = to / name;
|
||||
if (fs::is_symlink(status) || (!fs::is_directory(status) && !fs::is_regular_file(status)))
|
||||
{
|
||||
stats.skipped.push_back(source.string());
|
||||
continue;
|
||||
}
|
||||
std::error_code copyEc;
|
||||
if (fs::is_directory(status))
|
||||
{
|
||||
fs::create_directory(target, copyEc);
|
||||
if (copyEc)
|
||||
return Fail(Error::WriteError, std::format("Couldn't make {} ({}): nothing was changed.", target.string(),
|
||||
copyEc.message()), target);
|
||||
if (Result r = CopyData(source, target, skip, stats, false); !r.Ok())
|
||||
return r;
|
||||
}
|
||||
else
|
||||
{
|
||||
fs::copy_file(source, target, fs::copy_options::none, copyEc);
|
||||
if (copyEc)
|
||||
return Fail(Error::WriteError, std::format("Couldn't copy {} to {} ({}): nothing was changed.",
|
||||
source.string(), target.string(), copyEc.message()), source);
|
||||
stats.files++;
|
||||
stats.bytes += fs::file_size(source, typeEc);
|
||||
}
|
||||
std::error_code timeEc;
|
||||
fs::last_write_time(target, fs::last_write_time(source, timeEc), timeEc);
|
||||
}
|
||||
if (ec)
|
||||
return Fail(Error::ReadError, std::format("Couldn't read {} ({}): nothing was changed.", from.string(), ec.message()),
|
||||
from);
|
||||
return {};
|
||||
}
|
||||
|
||||
void SayCopied(const CopyStats& stats, const fs::path& from, const fs::path& to, std::vector<std::string>& said)
|
||||
{
|
||||
if (stats.files == 0)
|
||||
said.push_back(std::format("{} holds no settings, saves or logs: nothing of them to copy.", from.string()));
|
||||
else
|
||||
said.push_back(std::format("Copied settings, saves and logs ({} files, {}) from {} to {}.", stats.files,
|
||||
FormatSize(stats.bytes), from.string(), to.string()));
|
||||
for (const std::string& path : stats.skipped)
|
||||
said.push_back(std::format("Not copied (a link or a special file): {}", path));
|
||||
}
|
||||
|
||||
// A name for the old folder that isn't taken: <folder>.before-portable-off-<UTC date>-<time>[-N].
|
||||
fs::path AsideName(const fs::path& folder)
|
||||
{
|
||||
std::string stamp = std::format("{:%Y%m%d-%H%M%S}",
|
||||
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now()));
|
||||
fs::path aside = folder.string() + ".before-portable-off-" + stamp;
|
||||
std::error_code ec;
|
||||
for (int n = 2; fs::exists(fs::symlink_status(aside, ec)); n++)
|
||||
aside = folder.string() + ".before-portable-off-" + stamp + "-" + std::to_string(n);
|
||||
return aside;
|
||||
}
|
||||
|
||||
bool WriteMarker(const fs::path& file)
|
||||
{
|
||||
fs::path temp = file;
|
||||
temp += ".tmp";
|
||||
FILE* f = fopen(temp.string().c_str(), "wb");
|
||||
if (!f)
|
||||
return false;
|
||||
bool ok = fputs("SpeedBreaker portable mode: with this file here, SpeedBreaker keeps its settings, saves, logs and "
|
||||
"caches in this folder (data/ and cache/) and installs the game into data/game. Delete this file to use this "
|
||||
"computer's own data folder again (README.md, \"Portable mode\").\n", f) >= 0;
|
||||
ok = fclose(f) == 0 && ok;
|
||||
std::error_code ec;
|
||||
if (ok)
|
||||
fs::rename(temp, file, ec);
|
||||
else
|
||||
fs::remove(temp, ec);
|
||||
return ok && !ec;
|
||||
}
|
||||
}
|
||||
|
||||
Result MakePortable(const std::filesystem::path& root, const std::filesystem::path& home, const GameCopier& copyGame,
|
||||
std::vector<std::string>& said, const Manifest& manifest)
|
||||
{
|
||||
if (root.empty() || !root.is_absolute())
|
||||
return Fail(Error::NotFound, "SpeedBreaker can't tell which folder it runs from, so it can't make that folder "
|
||||
"portable. Start it with the speedbreaker.sh in that folder.");
|
||||
const fs::path folder = ResolvedFolder(root);
|
||||
const fs::path data = folder / portable::kDataFolder, marker = folder / portable::kMarker;
|
||||
std::error_code ec;
|
||||
if (fs::exists(fs::symlink_status(marker, ec)))
|
||||
return Fail(Error::DestinationNotEmpty, std::format("Portable mode is already on ({} is there): nothing was copied.",
|
||||
marker.string()), marker);
|
||||
if (!EmptyOrMissing(data))
|
||||
return Fail(Error::DestinationNotEmpty, std::format("{} already holds files (from an earlier portable setup?), so "
|
||||
"nothing was copied. To use them as they are, create {} yourself. To copy this computer's data instead, move "
|
||||
"{} somewhere else first.", data.string(), marker.string(), data.string()), data);
|
||||
|
||||
const fs::path source = ResolvedFolder(home);
|
||||
FoundGame game = FindGame(source, portable::Layout{}, manifest);
|
||||
if (game.dir.empty() && game.lost)
|
||||
return Fail(Error::NotFound, std::format("The game is installed in {}, which isn't there now or isn't a current "
|
||||
"install (a drive not connected?), so nothing was copied. Connect it and try again. If it's gone for good, "
|
||||
"delete {} and try again: SpeedBreaker then installs the game into {} at its first start.",
|
||||
game.lost->string(), (source / "game-location").string(), (data / "game").string()), *game.lost);
|
||||
|
||||
const fs::path partial = folder / (std::string(portable::kDataFolder) + ".partial");
|
||||
if (Result r = ClearPartial(partial, said); !r.Ok())
|
||||
return r;
|
||||
if (Result r = StartPartial(partial); !r.Ok())
|
||||
return r;
|
||||
auto abandon = [&](Result r) {
|
||||
std::error_code removeEc;
|
||||
fs::remove_all(partial, removeEc);
|
||||
return r;
|
||||
};
|
||||
|
||||
CopyStats stats;
|
||||
if (fs::is_directory(source, ec))
|
||||
if (Result r = CopyData(source, partial, game.skipped, stats, true); !r.Ok())
|
||||
return abandon(r);
|
||||
if (!game.dir.empty())
|
||||
if (Result r = copyGame(game.dir, partial / "game"); !r.Ok())
|
||||
return abandon(r);
|
||||
|
||||
fs::remove(partial / kPortablePartialTag, ec);
|
||||
fs::remove(data, ec); // an empty one (EmptyOrMissing): replaced
|
||||
ec.clear();
|
||||
fs::rename(partial, data, ec);
|
||||
if (ec)
|
||||
return abandon(Fail(Error::WriteError, std::format("Couldn't rename {} to {} ({}): nothing was changed.",
|
||||
partial.string(), data.string(), ec.message()), data));
|
||||
if (!WriteMarker(marker))
|
||||
return Fail(Error::WriteError, std::format("The data was copied to {}, but {} couldn't be written. Create it "
|
||||
"yourself (an empty file will do) to turn portable mode on.", data.string(), marker.string()), marker);
|
||||
|
||||
SayCopied(stats, source, data, said);
|
||||
if (!game.dir.empty())
|
||||
said.push_back(std::format("Copied the game to {} and checked every file.", (data / "game").string()));
|
||||
else
|
||||
said.push_back(std::format("No installed game was found, so none was copied: SpeedBreaker installs it into {} "
|
||||
"at its first start.", (data / "game").string()));
|
||||
said.push_back(std::format("Portable mode is on ({}): SpeedBreaker keeps everything in {} now. Move or copy that "
|
||||
"whole folder to take it all along.", marker.string(), folder.string()));
|
||||
if (stats.files || !game.dir.empty())
|
||||
said.push_back(std::format("Nothing was removed: the old copies are still in {}{}. Delete them once you're happy "
|
||||
"with the portable folder, or keep them as a backup.", source.string(),
|
||||
game.dir.empty() || IsAtOrInside(ResolvedFolder(game.dir), source) ? ""
|
||||
: std::format(" (and the game in {})", game.dir.string())));
|
||||
return {};
|
||||
}
|
||||
|
||||
Result LeavePortable(const portable::Layout& layout, const std::filesystem::path& home, const GameCopier& copyGame,
|
||||
std::vector<std::string>& said, const Manifest& manifest)
|
||||
{
|
||||
if (!layout.Active())
|
||||
return Fail(Error::NotFound, std::format("Portable mode isn't on (there's no {} in SpeedBreaker's folder): nothing "
|
||||
"to do.", portable::kMarker));
|
||||
const fs::path target = ResolvedFolder(home);
|
||||
const fs::path marker = layout.root / portable::kMarker;
|
||||
if (IsAtOrInside(target, layout.root))
|
||||
return Fail(Error::DestinationNotWritable, std::format("This computer's data folder, {}, is inside SpeedBreaker's "
|
||||
"folder ({}): nothing was copied. Is XDG_DATA_HOME set to a folder in it?", target.string(),
|
||||
layout.root.string()), target);
|
||||
|
||||
FoundGame game = FindGame(layout.data, layout, manifest);
|
||||
const bool copyTheGame = !game.dir.empty() && IsAtOrInside(ResolvedFolder(game.dir), layout.root);
|
||||
|
||||
const fs::path partial = fs::path(target.string() + ".partial");
|
||||
if (Result r = ClearPartial(partial, said); !r.Ok())
|
||||
return r;
|
||||
if (Result r = StartPartial(partial); !r.Ok())
|
||||
return r;
|
||||
auto abandon = [&](Result r) {
|
||||
std::error_code removeEc;
|
||||
fs::remove_all(partial, removeEc);
|
||||
return r;
|
||||
};
|
||||
|
||||
CopyStats stats;
|
||||
std::error_code ec;
|
||||
if (fs::is_directory(layout.data, ec))
|
||||
if (Result r = CopyData(layout.data, partial, game.skipped, stats, true); !r.Ok())
|
||||
return abandon(r);
|
||||
if (copyTheGame)
|
||||
{
|
||||
if (Result r = copyGame(game.dir, partial / "game"); !r.Ok())
|
||||
return abandon(r);
|
||||
}
|
||||
else if (!game.dir.empty() && !WriteInstallRecord(partial / "game-location", portable::RecordText(game.dir, {})))
|
||||
return abandon(Fail(Error::WriteError, std::format("Couldn't write {}: nothing was changed.",
|
||||
(partial / "game-location").string()), partial));
|
||||
fs::remove(partial / kPortablePartialTag, ec);
|
||||
|
||||
// The data folder there now (usually the one from before portable
|
||||
// mode) is kept, renamed aside; an empty one is replaced.
|
||||
std::optional<fs::path> aside;
|
||||
if (fs::exists(fs::symlink_status(target, ec)))
|
||||
{
|
||||
if (EmptyOrMissing(target))
|
||||
fs::remove(target, ec);
|
||||
else
|
||||
{
|
||||
aside = AsideName(target);
|
||||
fs::rename(target, *aside, ec);
|
||||
if (ec)
|
||||
return abandon(Fail(Error::WriteError, std::format("Couldn't rename {} to {} ({}): nothing was changed.",
|
||||
target.string(), aside->string(), ec.message()), target));
|
||||
}
|
||||
}
|
||||
ec.clear();
|
||||
fs::rename(partial, target, ec);
|
||||
if (ec)
|
||||
{
|
||||
Result r = Fail(Error::WriteError, std::format("Couldn't rename {} to {} ({}): nothing was changed.", partial.string(),
|
||||
target.string(), ec.message()), target);
|
||||
std::error_code backEc;
|
||||
if (aside)
|
||||
fs::rename(*aside, target, backEc);
|
||||
return abandon(r);
|
||||
}
|
||||
fs::remove(marker, ec);
|
||||
if (ec)
|
||||
return Fail(Error::WriteError, std::format("The data was copied to {}, but {} couldn't be removed ({}). Delete it "
|
||||
"yourself to turn portable mode off.", target.string(), marker.string(), ec.message()), marker);
|
||||
|
||||
if (aside)
|
||||
said.push_back(std::format("Renamed the data folder that was already there, {}, to {}: kept as it was. Delete it "
|
||||
"once you're happy.", target.string(), aside->string()));
|
||||
SayCopied(stats, layout.data, target, said);
|
||||
if (copyTheGame)
|
||||
said.push_back(std::format("Copied the game to {} and checked every file.", (target / "game").string()));
|
||||
else if (!game.dir.empty())
|
||||
said.push_back(std::format("The game stays where it is, {} (outside SpeedBreaker's folder), and is recorded there.",
|
||||
game.dir.string()));
|
||||
else
|
||||
said.push_back("No installed game was found, so none was copied: SpeedBreaker installs it at its first start.");
|
||||
said.push_back(std::format("Portable mode is off ({} removed): SpeedBreaker uses {} again. Nothing in {} was removed: "
|
||||
"delete it once you're happy, or keep it as a backup.", marker.string(), target.string(), layout.root.string()));
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Switching an existing setup into portable mode (user/portable.h) and back,
|
||||
// with a copy: speedbreaker.sh --portable on|off (install_cli.cpp). Making or
|
||||
// deleting portable.txt by hand switches too, with nothing copied (a fresh
|
||||
// start, or this computer's data as it was).
|
||||
//
|
||||
// on: this computer's data folder (settings, saves, logs; not the game
|
||||
// folder or its record) is copied into SpeedBreaker's data/, then the
|
||||
// installed game (the default one or the recorded one) into
|
||||
// data/game, verified (GameCopier: the installer's copy, every file
|
||||
// hashed against the manifest), then portable.txt is written.
|
||||
// off: data/ (not the game) is copied into this computer's data folder,
|
||||
// and the game into its game/ when it's in SpeedBreaker's folder; a
|
||||
// game recorded outside it stays recorded where it is. A data folder
|
||||
// already there (usually the one from before "on") is renamed aside
|
||||
// to <name>.before-portable-off-<date>-<time>, then portable.txt is
|
||||
// removed.
|
||||
// The command line refuses both on Apple platforms (portable::kSupported);
|
||||
// the functions themselves work anywhere (tests run them on a Mac).
|
||||
// Copies, never moves: the source is left as it was, and the messages say
|
||||
// what can be deleted once the player is happy. Each copy is built in a
|
||||
// tagged "<folder>.partial" beside its destination and renamed into place at
|
||||
// the end, with the marker changed last, so an interruption leaves the
|
||||
// previous mode working; the next attempt replaces its own unfinished copy
|
||||
// (one with the tag), and refuses anything else of that name.
|
||||
#pragma once
|
||||
#include "result.h"
|
||||
|
||||
#include <user/portable.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace install
|
||||
{
|
||||
struct Manifest;
|
||||
const Manifest& GameManifest();
|
||||
|
||||
// Copies the installed game in `from` into `to` (which doesn't exist),
|
||||
// verified. The command line's: OpenDiscSource + Install, with progress.
|
||||
using GameCopier = std::function<Result(const std::filesystem::path& from, const std::filesystem::path& to)>;
|
||||
|
||||
// The tag file in an unfinished copy (see above).
|
||||
inline constexpr const char* kPortablePartialTag = ".speedbreaker-portable-partial";
|
||||
|
||||
// --portable on. `root`: SpeedBreaker's folder (absolute; portable.txt
|
||||
// not in it yet); `home`: this computer's data folder (HomeUserPath()).
|
||||
// `said` gets what was done, a line each, for the terminal.
|
||||
Result MakePortable(const std::filesystem::path& root, const std::filesystem::path& home, const GameCopier& copyGame,
|
||||
std::vector<std::string>& said, const Manifest& manifest = GameManifest());
|
||||
|
||||
// --portable off. `layout`: portable mode's (active); `home` as above.
|
||||
Result LeavePortable(const portable::Layout& layout, const std::filesystem::path& home, const GameCopier& copyGame,
|
||||
std::vector<std::string>& said, const Manifest& manifest = GameManifest());
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
#include "memory.h"
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#ifdef __APPLE__
|
||||
@@ -23,6 +25,7 @@ bool Memory::MapPhysicalMirrors()
|
||||
if (kr != KERN_SUCCESS)
|
||||
{
|
||||
fprintf(stderr, "[memory] mach_make_memory_entry_64: %s\n", mach_error_string(kr));
|
||||
failedError = kr;
|
||||
return false;
|
||||
}
|
||||
bool ok = true;
|
||||
@@ -32,11 +35,14 @@ bool Memory::MapPhysicalMirrors()
|
||||
int fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600);
|
||||
if (fd < 0)
|
||||
{
|
||||
failedError = errno;
|
||||
perror("[memory] shm_open");
|
||||
return false;
|
||||
}
|
||||
shm_unlink(name); // the mappings keep it alive
|
||||
bool ok = ftruncate(fd, PHYSICAL_SIZE) == 0;
|
||||
if (!ok)
|
||||
failedError = errno;
|
||||
#endif
|
||||
|
||||
struct View { uint32_t guest; size_t offset; size_t length; };
|
||||
@@ -67,11 +73,13 @@ bool Memory::MapPhysicalMirrors()
|
||||
if (kr != KERN_SUCCESS)
|
||||
{
|
||||
fprintf(stderr, "[memory] vm_map physical view: %s\n", mach_error_string(kr));
|
||||
failedError = kr;
|
||||
ok = false;
|
||||
}
|
||||
#else
|
||||
if (mmap(at, v.length, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, off_t(offset)) == MAP_FAILED)
|
||||
{
|
||||
failedError = errno;
|
||||
perror("[memory] mmap physical view");
|
||||
ok = false;
|
||||
}
|
||||
@@ -100,13 +108,28 @@ Memory::Memory()
|
||||
DWORD oldProtect;
|
||||
VirtualProtect(base, 4096, PAGE_NOACCESS, &oldProtect);
|
||||
#else
|
||||
base = (uint8_t*)mmap((void*)0x100000000ull, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
|
||||
// NFSMW_TEST_RESERVE_FAIL=1|2: fail the reservation or the mirrors as an
|
||||
// iOS without room would (memory_status.h), to see the startup message
|
||||
// anywhere.
|
||||
const char* testFail = std::getenv("NFSMW_TEST_RESERVE_FAIL");
|
||||
int failStep = testFail ? std::atoi(testFail) : 0;
|
||||
|
||||
if (base == (uint8_t*)MAP_FAILED)
|
||||
// SpeedBreaker: one contiguous 4 GB, since guest addresses are offsets
|
||||
// from base. iOS makes room for it with Extended Virtual Addressing or
|
||||
// Increased Memory Limit (scripts/ios/SpeedBreaker/SpeedBreaker.entitlements,
|
||||
// kernel/memory_status.h); without them only about 5 GB is free in one
|
||||
// piece, so this runs in static initialization, before anything else
|
||||
// takes address space. If it fails (ENOMEM), main.cpp says so on screen.
|
||||
base = failStep == 1 ? (uint8_t*)MAP_FAILED
|
||||
: (uint8_t*)mmap((void*)0x100000000ull, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
|
||||
|
||||
if (base == (uint8_t*)MAP_FAILED && failStep != 1)
|
||||
base = (uint8_t*)mmap(NULL, PPC_MEMORY_SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
|
||||
|
||||
if (base == (uint8_t*)MAP_FAILED)
|
||||
{
|
||||
failedStep = 1;
|
||||
failedError = failStep == 1 ? ENOMEM : errno;
|
||||
base = nullptr;
|
||||
return;
|
||||
}
|
||||
@@ -118,8 +141,17 @@ Memory::Memory()
|
||||
// 0xE0000000 maps physical +0x1000 (Xenia's map_info). D3D writes records
|
||||
// through one window and reads them through another, so all three must be
|
||||
// the same host memory: one shared object, mapped three times.
|
||||
if (!MapPhysicalMirrors())
|
||||
if (failStep == 2 || !MapPhysicalMirrors())
|
||||
{
|
||||
failedStep = 2;
|
||||
if (failStep == 2)
|
||||
{
|
||||
#ifdef __APPLE__
|
||||
failedError = KERN_NO_SPACE;
|
||||
#else
|
||||
failedError = ENOMEM;
|
||||
#endif
|
||||
}
|
||||
munmap(base, PPC_MEMORY_SIZE);
|
||||
base = nullptr;
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,13 @@ struct Memory
|
||||
// Physical offset of the 0xE0000000 window: 0x1000 on hardware, 0 on
|
||||
// hosts whose page size can't express it (see memory.cpp).
|
||||
uint32_t eWindowShift = 0;
|
||||
// SpeedBreaker: base is null because a step failed (memory_status.h's
|
||||
// Failure: 1, the 4 GB reservation; 2, the physical memory mirrors) with
|
||||
// this error (errno; a kern_return_t for the Apple mirrors). main.cpp
|
||||
// says so on screen. NFSMW_TEST_RESERVE_FAIL=1 or 2 fails that step on
|
||||
// purpose (testing the message on any machine).
|
||||
int failedStep = 0;
|
||||
int failedError = 0;
|
||||
|
||||
bool IsInMemoryRange(const void* host) const noexcept
|
||||
{
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See memory_status.h.
|
||||
#include "memory_status.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
#include <mach/mach.h>
|
||||
#include <platform/code_signature.h>
|
||||
#if TARGET_OS_IOS
|
||||
#include <os/proc.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace memstatus
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr const char* kExtendedVa = "com.apple.developer.kernel.extended-virtual-addressing";
|
||||
constexpr const char* kIncreasedMemory = "com.apple.developer.kernel.increased-memory-limit";
|
||||
// The mirrors' errors are kern_return_t values on Apple (Mach memory
|
||||
// entries, kernel/memory.cpp), errno elsewhere.
|
||||
#ifdef __APPLE__
|
||||
constexpr bool kMachErrors = true;
|
||||
#else
|
||||
constexpr bool kMachErrors = false;
|
||||
#endif
|
||||
|
||||
std::string ErrorText(const Status& s)
|
||||
{
|
||||
if (s.failure == Failure::AddressSpace)
|
||||
return std::format("reserving 4 GB failed: {} (errno {})", s.errorText.empty() ? "unknown error" : s.errorText, s.error);
|
||||
if (s.failure == Failure::PhysicalMemory)
|
||||
return std::format("mapping the 512 MB of physical memory failed: {} ({})",
|
||||
s.errorText.empty() ? "unknown error" : s.errorText, s.error);
|
||||
return "";
|
||||
}
|
||||
|
||||
// "entitlements in the signature: Extended Virtual Addressing yes,
|
||||
// Increased Memory Limit no", or why they aren't known.
|
||||
std::string EntitlementsText(const Status& s)
|
||||
{
|
||||
if (!s.signatureRead)
|
||||
return "entitlements: not read";
|
||||
return std::format("entitlements in the signature: Extended Virtual Addressing {}, Increased Memory Limit {}",
|
||||
FlagName(s.extendedVirtualAddressing), FlagName(s.increasedMemoryLimit));
|
||||
}
|
||||
|
||||
std::string Facts(const Status& s)
|
||||
{
|
||||
std::string text = s.failure == Failure::None ? std::format("guest 4 GB at {:#x}", s.base) : ErrorText(s);
|
||||
if (s.ios)
|
||||
{
|
||||
text += "; " + EntitlementsText(s);
|
||||
if (s.addressSpaceTop)
|
||||
text += std::format("; address space up to {:#x} ({} GB)", s.addressSpaceTop, s.addressSpaceTop >> 30);
|
||||
text += s.memoryLimitMB > 0 ? std::format("; memory limit {} MB", s.memoryLimitMB) : "; memory limit unknown";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
bool ReadVmInfo(task_vm_info_data_t& info, mach_msg_type_number_t& count)
|
||||
{
|
||||
count = TASK_VM_INFO_COUNT;
|
||||
return task_info(mach_task_self(), TASK_VM_INFO, task_info_t(&info), &count) == KERN_SUCCESS;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
const char* FlagName(Flag flag)
|
||||
{
|
||||
return flag == Flag::Yes ? "yes" : flag == Flag::No ? "no" : "unknown";
|
||||
}
|
||||
|
||||
std::string LogLine(const Status& status)
|
||||
{
|
||||
return "[memory] " + Facts(status);
|
||||
}
|
||||
|
||||
std::string SummaryItem(const Status& status)
|
||||
{
|
||||
return Facts(status);
|
||||
}
|
||||
|
||||
Message StartupFailure(const Status& s)
|
||||
{
|
||||
Message m;
|
||||
if (s.failure == Failure::None)
|
||||
return m;
|
||||
m.details = "Details: " + Facts(s) + ".";
|
||||
if (!s.ios)
|
||||
{
|
||||
m.title = "SpeedBreaker can't start";
|
||||
m.paragraphs.push_back(s.failure == Failure::AddressSpace
|
||||
? "The system refused the 4 GB of address space the game needs to lay out the Xbox 360's memory."
|
||||
: "The system refused to map the Xbox 360's 512 MB of memory into the game's address space.");
|
||||
m.paragraphs.push_back("Close other programs and try again. On Linux, check that no address-space limit applies "
|
||||
"(ulimit -v must say unlimited).");
|
||||
return m;
|
||||
}
|
||||
m.title = "This copy of SpeedBreaker can't start";
|
||||
if (s.failure == Failure::PhysicalMemory)
|
||||
{
|
||||
m.paragraphs.push_back("iOS refused to map the Xbox 360's 512 MB of memory into the game's address space.");
|
||||
m.paragraphs.push_back("Restart the device and try again. If it happens again, please report it with a "
|
||||
"screenshot of this page.");
|
||||
return m;
|
||||
}
|
||||
if (s.extendedVirtualAddressing == Flag::Yes)
|
||||
{
|
||||
m.paragraphs.push_back("iOS refused the 4 GB of address space the game needs to lay out the Xbox 360's memory, "
|
||||
"even though this copy has the Extended Virtual Addressing entitlement.");
|
||||
m.paragraphs.push_back("Restart the device and try again. If it happens again, please report it with a "
|
||||
"screenshot of this page.");
|
||||
return m;
|
||||
}
|
||||
// iOS gives an app room for 4 GB with Extended Virtual Addressing,
|
||||
// or with Increased Memory Limit on devices that support it (both
|
||||
// lift its address space past 63 GB); without either it has about
|
||||
// 5 GB in one piece above the system's shared region, and the 4 GB
|
||||
// fits there only if nothing took that space first. A free Apple Account can
|
||||
// never give Extended Virtual Addressing; some sideloading tools
|
||||
// keep Increased Memory Limit for it (scripts/ios/README.md).
|
||||
std::string why = "iOS refused the 4 GB of address space the game needs for the Xbox 360's memory layout. ";
|
||||
if (s.extendedVirtualAddressing == Flag::No && s.increasedMemoryLimit == Flag::No)
|
||||
why += "iOS gives an app that much room with the Extended Virtual Addressing or the Increased Memory Limit "
|
||||
"entitlement, and this copy was signed with neither.";
|
||||
else if (s.extendedVirtualAddressing == Flag::No)
|
||||
why += "This copy was signed with Increased Memory Limit, which gives that room only on devices that support "
|
||||
"it, but not with Extended Virtual Addressing.";
|
||||
else
|
||||
why += "It takes the Extended Virtual Addressing or the Increased Memory Limit entitlement, and iOS didn't "
|
||||
"give this copy the room.";
|
||||
m.paragraphs.push_back(why);
|
||||
m.paragraphs.push_back("This build needs the Extended Virtual Addressing entitlement, which a paid Apple Developer "
|
||||
"Program account can give, or Increased Memory Limit, which AltStore 2.2 or later keeps even "
|
||||
"with a free Apple Account (SideStore 0.7 and some other tools drop it). Sideload "
|
||||
"SpeedBreaker again that way, or build it yourself with Xcode: see scripts/ios/README.md in "
|
||||
"the source.");
|
||||
m.paragraphs.push_back("You can close SpeedBreaker now: swipe it away in the app switcher.");
|
||||
return m;
|
||||
}
|
||||
|
||||
std::string LowMemoryNotice(const Status& s)
|
||||
{
|
||||
if (!s.ios || s.failure != Failure::None)
|
||||
return "";
|
||||
bool smallSpace = s.addressSpaceTop != 0 && s.addressSpaceTop < kLargeAddressSpace;
|
||||
bool lowLimit = s.memoryLimitMB > 0 && s.memoryLimitMB < kPeakFootprintMB;
|
||||
if (smallSpace && lowLimit)
|
||||
return std::format("iOS gives SpeedBreaker little room ({} GB of address space, {} MB of memory: no memory "
|
||||
"entitlements): it may close the game in busy scenes", s.addressSpaceTop >> 30, s.memoryLimitMB);
|
||||
if (smallSpace)
|
||||
return std::format("iOS gives SpeedBreaker only {} GB of address space (no memory entitlements): it may close "
|
||||
"the game in busy scenes", s.addressSpaceTop >> 30);
|
||||
if (lowLimit && s.increasedMemoryLimit != Flag::Yes)
|
||||
return std::format("iOS lets SpeedBreaker use only {} MB (no Increased Memory Limit entitlement): it may close "
|
||||
"the game in busy scenes", s.memoryLimitMB);
|
||||
if (lowLimit)
|
||||
return std::format("iOS lets SpeedBreaker use only {} MB on this device: it may close the game in busy scenes",
|
||||
s.memoryLimitMB);
|
||||
return "";
|
||||
}
|
||||
|
||||
void Gather(Status& status)
|
||||
{
|
||||
if (status.failure == Failure::AddressSpace || (status.failure == Failure::PhysicalMemory && !kMachErrors))
|
||||
status.errorText = std::strerror(status.error);
|
||||
#ifdef __APPLE__
|
||||
else if (status.failure == Failure::PhysicalMemory)
|
||||
status.errorText = mach_error_string(status.error);
|
||||
#endif
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
status.ios = true;
|
||||
#else
|
||||
// NFSMW_TEST_MEMORY_IOS=1: the iOS wording on another system (with
|
||||
// NFSMW_TEST_RESERVE_FAIL, the iOS page), the entitlements read from
|
||||
// this executable's own signature (a Mac build has none: "no").
|
||||
const char* test = std::getenv("NFSMW_TEST_MEMORY_IOS");
|
||||
status.ios = test && test[0] == '1';
|
||||
#endif
|
||||
#ifdef __APPLE__
|
||||
if (!status.ios)
|
||||
return;
|
||||
if (std::optional<platform::codesign::Entitlements> e = platform::codesign::ReadOwn())
|
||||
{
|
||||
using platform::codesign::Lookup;
|
||||
using platform::codesign::Value;
|
||||
status.signatureRead = true;
|
||||
status.extendedVirtualAddressing = Lookup(*e, kExtendedVa) == Value::True ? Flag::Yes : Flag::No;
|
||||
status.increasedMemoryLimit = Lookup(*e, kIncreasedMemory) == Value::True ? Flag::Yes : Flag::No;
|
||||
}
|
||||
#else
|
||||
(void)kExtendedVa;
|
||||
(void)kIncreasedMemory;
|
||||
#endif
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
// The limit iOS kills the app at: what it has (its footprint) plus
|
||||
// what it may still take. The address space's top shows whether
|
||||
// Extended Virtual Addressing took effect.
|
||||
task_vm_info_data_t info{};
|
||||
mach_msg_type_number_t count = 0;
|
||||
size_t available = os_proc_available_memory();
|
||||
if (ReadVmInfo(info, count))
|
||||
{
|
||||
if (available > 0)
|
||||
status.memoryLimitMB = int64_t((uint64_t(info.phys_footprint) + available) >> 20);
|
||||
if (count >= TASK_VM_INFO_REV2_COUNT)
|
||||
status.addressSpaceTop = info.max_address;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void Poll()
|
||||
{
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
using clock = std::chrono::steady_clock;
|
||||
static clock::time_point s_next;
|
||||
clock::time_point now = clock::now();
|
||||
if (now < s_next)
|
||||
return;
|
||||
s_next = now + std::chrono::seconds(60);
|
||||
task_vm_info_data_t info{};
|
||||
mach_msg_type_number_t count = 0;
|
||||
if (!ReadVmInfo(info, count))
|
||||
return;
|
||||
int64_t peak = count >= TASK_VM_INFO_REV3_COUNT ? info.ledger_phys_footprint_peak : 0;
|
||||
fprintf(stderr, "[memory] footprint %llu MB, peak %lld MB, %zu MB left before iOS's limit\n",
|
||||
(unsigned long long)(info.phys_footprint >> 20), (long long)(peak >> 20), os_proc_available_memory() >> 20);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Whether the game got the memory it needs, and what to tell the player when
|
||||
// it didn't. The recompiled code addresses guest memory as base + a 32-bit
|
||||
// guest address, so the game needs one contiguous 4 GB of host address space
|
||||
// (kernel/memory.cpp), with the 360's 512 MB of physical memory mapped three
|
||||
// times inside it (0xA0000000, 0xC0000000, 0xE0000000). There is no smaller
|
||||
// layout: the game's heaps reach 0x7EFFFFFF, its image sits at 0x82000000 and
|
||||
// the three physical windows end at 0xFFFFFFFF.
|
||||
//
|
||||
// iOS limits an app's address space to 17.4 GB on a device with more than
|
||||
// 3 GB of memory, about 5 GB of it free in one piece above the system's
|
||||
// shared region, unless it has Extended Virtual Addressing (then the 4 GB
|
||||
// lands at 0x7000000000) or Increased Memory Limit on a device that supports
|
||||
// it (63 GB since iOS 16: xnu's kern_exec.c). Increased Memory Limit also
|
||||
// raises the memory limit (about 3.3 GB -> 6 GB on an 8 GB iPhone), which the
|
||||
// game's peak footprint (about 2.5-3 GB, scripts/ios/README.md, "Memory")
|
||||
// needs on smaller devices. scripts/ios/SpeedBreaker/SpeedBreaker.entitlements
|
||||
// asks for both. A sideloaded copy (the IPA, scripts/ios/make_ipa.sh) is
|
||||
// re-signed with the player's own Apple Account: a free one never gets
|
||||
// Extended Virtual Addressing, and keeps Increased Memory Limit only with
|
||||
// some tools. When the 4 GB can't be had the game can't start: main.cpp shows
|
||||
// StartupFailure() on screen instead of quitting. With a low memory limit it
|
||||
// starts, and LowMemoryNotice() says iOS may close it.
|
||||
//
|
||||
// Everything here is pure (tests/memory_status_test.cpp) except Gather(),
|
||||
// which reads the device.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace memstatus
|
||||
{
|
||||
// Which step of Memory::Memory() failed (kernel/memory.h: failedStep).
|
||||
enum class Failure : uint8_t { None, AddressSpace, PhysicalMemory };
|
||||
|
||||
// From the app's own code signature (platform/code_signature.h):
|
||||
// Unknown when it couldn't be read (or isn't iOS's concern).
|
||||
enum class Flag : uint8_t { Unknown, No, Yes };
|
||||
|
||||
struct Status
|
||||
{
|
||||
Failure failure = Failure::None;
|
||||
int error = 0; // errno for the reservation, a kern_return_t for the mirrors
|
||||
std::string errorText; // strerror / mach_error_string
|
||||
uint64_t base = 0; // host address of the guest's 4 GB (0: none)
|
||||
Flag extendedVirtualAddressing = Flag::Unknown;
|
||||
Flag increasedMemoryLimit = Flag::Unknown;
|
||||
bool signatureRead = false; // the entitlements above come from the signature
|
||||
int64_t memoryLimitMB = -1; // iOS: footprint + os_proc_available_memory() at startup; -1 unknown
|
||||
// iOS: the top of the app's address space (task_vm_info's
|
||||
// max_address), which shows whether the entitlements took effect:
|
||||
// 0xFC0000000 (63 GB) or more with them, 0x458000000 (17.4 GB)
|
||||
// without on a device with more than 3 GB; 0 unknown.
|
||||
uint64_t addressSpaceTop = 0;
|
||||
bool ios = false;
|
||||
};
|
||||
|
||||
// The game's peak memory footprint with room to spare (measured 2.2 GB
|
||||
// in a race at 1x on a Mac, about 2.5 GB expected in an iPhone's free
|
||||
// roam: scripts/ios/README.md, "Memory"): below this limit iOS may
|
||||
// close the game in a busy scene.
|
||||
constexpr int64_t kPeakFootprintMB = 3072;
|
||||
// An address space that ends below this (MACH_VM_JUMBO_ADDRESS, 63 GB)
|
||||
// is iOS's default one: neither entitlement took effect, and the game's
|
||||
// 4 GB plus the 3 GB or so it needs besides (the graphics driver's,
|
||||
// its heaps) must fit in the 7 GB or so iOS leaves an app.
|
||||
constexpr uint64_t kLargeAddressSpace = 0xFC0000000;
|
||||
|
||||
// The log's line: "[memory] guest 4 GB at 0x7000000000; ...".
|
||||
std::string LogLine(const Status& status);
|
||||
|
||||
// The system summary's Memory item (the same facts, without the prefix).
|
||||
std::string SummaryItem(const Status& status);
|
||||
|
||||
struct Message
|
||||
{
|
||||
std::string title;
|
||||
std::vector<std::string> paragraphs; // the first says what happened, the rest what to do
|
||||
std::string details; // small print: the error and the entitlements
|
||||
};
|
||||
// What the player sees when the game can't start (failure != None).
|
||||
Message StartupFailure(const Status& status);
|
||||
|
||||
// A one-line notice (a toast) when the game started but iOS may close
|
||||
// it: iOS's default address space (below kLargeAddressSpace), or a
|
||||
// memory limit below kPeakFootprintMB. "" otherwise (and off iOS).
|
||||
std::string LowMemoryNotice(const Status& status);
|
||||
|
||||
// The device's side (iOS): the entitlements from the app's signature,
|
||||
// the memory limit and the address space's top; elsewhere only the
|
||||
// error's text (NFSMW_TEST_MEMORY_IOS=1: the iOS wording); `base`, the
|
||||
// failure and its error come from g_memory. Main thread, once, early.
|
||||
void Gather(Status& status);
|
||||
|
||||
// iOS, main thread, every frame (video::RunFrame): once a minute a log
|
||||
// line with the app's memory footprint, its peak and what is left before
|
||||
// iOS's limit ("[memory] footprint 2410 MB, peak 2511 MB, 3626 MB left"),
|
||||
// so a log cut short by iOS closing the game shows how close it was.
|
||||
// Nothing elsewhere.
|
||||
void Poll();
|
||||
|
||||
const char* FlagName(Flag flag); // "yes", "no", "unknown"
|
||||
}
|
||||
+59
-5
@@ -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
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See code_signature.h.
|
||||
//
|
||||
// Formats (Apple's <mach-o/loader.h>, <mach-o/fat.h> and the code signing
|
||||
// blobs of xnu's osfmk/kern/cs_blobs.h), read field by field so this builds
|
||||
// and tests anywhere:
|
||||
// - Fat header, big-endian: magic 0xCAFEBABE (32-bit offsets, 20-byte
|
||||
// entries) or 0xCAFEBABF (64-bit, 32-byte entries), then the entries:
|
||||
// cputype, cpusubtype, offset, size, align (, reserved).
|
||||
// - mach_header_64, the file's (little-endian on arm64): magic 0xFEEDFACF,
|
||||
// cputype, cpusubtype, filetype, ncmds, sizeofcmds, flags, reserved;
|
||||
// then the load commands, each cmd and cmdsize first.
|
||||
// LC_CODE_SIGNATURE (0x1D) is a linkedit_data_command: dataoff and
|
||||
// datasize, file offsets within the slice.
|
||||
// - The embedded signature, big-endian: a SuperBlob (magic 0xFADE0CC0,
|
||||
// length, count, then count index entries of type and offset from the
|
||||
// SuperBlob's start). Slot 5 is the XML entitlements blob (magic
|
||||
// 0xFADE7171), slot 7 the DER one (0xFADE7172): magic, length (the
|
||||
// blob's whole size, header included), then the bytes.
|
||||
#include "code_signature.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <fcntl.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace platform::codesign
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr uint32_t kFatMagic = 0xCAFEBABE, kFatMagic64 = 0xCAFEBABF;
|
||||
constexpr uint32_t kMachMagic64 = 0xFEEDFACF;
|
||||
constexpr uint32_t kCpuArm64 = 0x0100000C;
|
||||
constexpr uint32_t kLcCodeSignature = 0x1D;
|
||||
constexpr uint32_t kSuperBlobMagic = 0xFADE0CC0;
|
||||
constexpr uint32_t kEntitlementsMagic = 0xFADE7171, kDerEntitlementsMagic = 0xFADE7172;
|
||||
constexpr uint32_t kSlotEntitlements = 5, kSlotDerEntitlements = 7;
|
||||
constexpr size_t kMachHeaderSize = 32;
|
||||
|
||||
bool Has(std::span<const uint8_t> b, uint64_t at, uint64_t n)
|
||||
{
|
||||
return at <= b.size() && n <= b.size() - at;
|
||||
}
|
||||
uint32_t Be32(std::span<const uint8_t> b, size_t at)
|
||||
{
|
||||
return uint32_t(b[at]) << 24 | uint32_t(b[at + 1]) << 16 | uint32_t(b[at + 2]) << 8 | b[at + 3];
|
||||
}
|
||||
uint64_t Be64(std::span<const uint8_t> b, size_t at)
|
||||
{
|
||||
return uint64_t(Be32(b, at)) << 32 | Be32(b, at + 4);
|
||||
}
|
||||
uint32_t Le32(std::span<const uint8_t> b, size_t at)
|
||||
{
|
||||
return uint32_t(b[at]) | uint32_t(b[at + 1]) << 8 | uint32_t(b[at + 2]) << 16 | uint32_t(b[at + 3]) << 24;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Range> MachOSlice(std::span<const uint8_t> fileStart, uint64_t fileSize)
|
||||
{
|
||||
if (!Has(fileStart, 0, 8))
|
||||
return std::nullopt;
|
||||
if (Le32(fileStart, 0) == kMachMagic64)
|
||||
return Range{ 0, fileSize };
|
||||
uint32_t magic = Be32(fileStart, 0);
|
||||
if (magic != kFatMagic && magic != kFatMagic64)
|
||||
return std::nullopt;
|
||||
uint32_t count = Be32(fileStart, 4);
|
||||
size_t entry = magic == kFatMagic64 ? 32 : 20;
|
||||
if (count == 0 || count > 64 || !Has(fileStart, 8, uint64_t(count) * entry))
|
||||
return std::nullopt;
|
||||
std::optional<Range> chosen;
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
{
|
||||
size_t at = 8 + i * entry;
|
||||
Range r;
|
||||
if (magic == kFatMagic64)
|
||||
r = { Be64(fileStart, at + 8), Be64(fileStart, at + 16) };
|
||||
else
|
||||
r = { Be32(fileStart, at + 8), Be32(fileStart, at + 12) };
|
||||
if (r.offset > fileSize || r.size > fileSize - r.offset)
|
||||
continue;
|
||||
if (Be32(fileStart, at) == kCpuArm64)
|
||||
return r;
|
||||
if (count == 1)
|
||||
chosen = r;
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
std::optional<Range> SignatureRange(std::span<const uint8_t> sliceStart, uint64_t sliceOffset)
|
||||
{
|
||||
if (!Has(sliceStart, 0, kMachHeaderSize) || Le32(sliceStart, 0) != kMachMagic64)
|
||||
return std::nullopt;
|
||||
uint32_t ncmds = Le32(sliceStart, 16), sizeofcmds = Le32(sliceStart, 20);
|
||||
if (!Has(sliceStart, kMachHeaderSize, sizeofcmds))
|
||||
return std::nullopt;
|
||||
size_t at = kMachHeaderSize, end = kMachHeaderSize + sizeofcmds;
|
||||
for (uint32_t i = 0; i < ncmds; i++)
|
||||
{
|
||||
if (at + 8 > end)
|
||||
return std::nullopt;
|
||||
uint32_t cmd = Le32(sliceStart, at), cmdsize = Le32(sliceStart, at + 4);
|
||||
if (cmdsize < 8 || cmdsize > end - at)
|
||||
return std::nullopt;
|
||||
if (cmd == kLcCodeSignature)
|
||||
{
|
||||
if (cmdsize < 16)
|
||||
return std::nullopt;
|
||||
uint32_t dataoff = Le32(sliceStart, at + 8), datasize = Le32(sliceStart, at + 12);
|
||||
if (datasize == 0)
|
||||
return std::nullopt;
|
||||
return Range{ sliceOffset + dataoff, datasize };
|
||||
}
|
||||
at += cmdsize;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Entitlements> FromSuperBlob(std::span<const uint8_t> blob)
|
||||
{
|
||||
if (!Has(blob, 0, 12) || Be32(blob, 0) != kSuperBlobMagic)
|
||||
return std::nullopt;
|
||||
uint32_t length = Be32(blob, 4), count = Be32(blob, 8);
|
||||
if (length < 12 || length > blob.size())
|
||||
return std::nullopt;
|
||||
blob = blob.first(length);
|
||||
if (!Has(blob, 12, uint64_t(count) * 8))
|
||||
return std::nullopt;
|
||||
Entitlements der;
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
{
|
||||
uint32_t type = Be32(blob, 12 + i * 8), offset = Be32(blob, 16 + i * 8);
|
||||
if (type != kSlotEntitlements && type != kSlotDerEntitlements)
|
||||
continue;
|
||||
if (!Has(blob, offset, 8))
|
||||
return std::nullopt;
|
||||
uint32_t magic = Be32(blob, offset), size = Be32(blob, offset + 4);
|
||||
if (size < 8 || !Has(blob, offset, size))
|
||||
return std::nullopt;
|
||||
const char* bytes = reinterpret_cast<const char*>(blob.data() + offset + 8);
|
||||
if (type == kSlotEntitlements && magic == kEntitlementsMagic)
|
||||
return Entitlements{ true, std::string(bytes, size - 8), false }; // XML: the one to read
|
||||
if (type == kSlotDerEntitlements && magic == kDerEntitlementsMagic)
|
||||
der = Entitlements{ true, std::string(bytes, size - 8), true };
|
||||
}
|
||||
return der; // DER only, or found == false: no entitlements at all
|
||||
}
|
||||
|
||||
Value Lookup(const Entitlements& entitlements, std::string_view key)
|
||||
{
|
||||
if (!entitlements.found || key.empty())
|
||||
return Value::Absent;
|
||||
const std::string& t = entitlements.text;
|
||||
if (entitlements.der)
|
||||
{
|
||||
// UTF8String (tag 0x0C, short length) holding the key, then a
|
||||
// BOOLEAN (0x01, length 1, 0x00 false, anything else true).
|
||||
if (key.size() >= 0x80)
|
||||
return Value::Absent;
|
||||
std::string pattern;
|
||||
pattern += char(0x0C);
|
||||
pattern += char(key.size());
|
||||
pattern += key;
|
||||
for (size_t at = t.find(pattern); at != std::string::npos; at = t.find(pattern, at + 1))
|
||||
{
|
||||
size_t v = at + pattern.size();
|
||||
if (v + 3 <= t.size() && t[v] == 0x01 && t[v + 1] == 0x01)
|
||||
return t[v + 2] != 0 ? Value::True : Value::False;
|
||||
}
|
||||
return Value::Absent;
|
||||
}
|
||||
std::string pattern = "<key>" + std::string(key) + "</key>";
|
||||
size_t at = t.find(pattern);
|
||||
if (at == std::string::npos)
|
||||
return Value::Absent;
|
||||
at += pattern.size();
|
||||
while (at < t.size() && (t[at] == ' ' || t[at] == '\t' || t[at] == '\r' || t[at] == '\n'))
|
||||
at++;
|
||||
std::string_view rest = std::string_view(t).substr(at);
|
||||
if (rest.starts_with("<true/>") || rest.starts_with("<true />"))
|
||||
return Value::True;
|
||||
if (rest.starts_with("<false/>") || rest.starts_with("<false />"))
|
||||
return Value::False;
|
||||
return Value::Absent;
|
||||
}
|
||||
|
||||
std::optional<Entitlements> FromFile(std::span<const uint8_t> file)
|
||||
{
|
||||
std::optional<Range> slice = MachOSlice(file, file.size());
|
||||
if (!slice)
|
||||
return std::nullopt;
|
||||
std::optional<Range> sig = SignatureRange(file.subspan(slice->offset, slice->size), slice->offset);
|
||||
if (!sig || !Has(file, sig->offset, sig->size))
|
||||
return std::nullopt;
|
||||
return FromSuperBlob(file.subspan(sig->offset, sig->size));
|
||||
}
|
||||
|
||||
std::optional<Entitlements> ReadOwn()
|
||||
{
|
||||
#ifdef __APPLE__
|
||||
char path[4096];
|
||||
uint32_t pathSize = sizeof(path);
|
||||
if (_NSGetExecutablePath(path, &pathSize) != 0)
|
||||
return std::nullopt;
|
||||
int fd = open(path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0)
|
||||
return std::nullopt;
|
||||
off_t fileSize = lseek(fd, 0, SEEK_END);
|
||||
auto read = [fd](uint64_t offset, uint64_t length) {
|
||||
std::vector<uint8_t> bytes(static_cast<size_t>(length));
|
||||
ssize_t got = length ? pread(fd, bytes.data(), bytes.size(), off_t(offset)) : 0;
|
||||
bytes.resize(got > 0 ? size_t(got) : 0);
|
||||
return bytes;
|
||||
};
|
||||
std::optional<Entitlements> result;
|
||||
if (fileSize > 0)
|
||||
{
|
||||
std::vector<uint8_t> start = read(0, std::min<uint64_t>(uint64_t(fileSize), 4096));
|
||||
if (std::optional<Range> slice = MachOSlice(start, uint64_t(fileSize)))
|
||||
{
|
||||
std::vector<uint8_t> header = read(slice->offset, kMachHeaderSize);
|
||||
if (header.size() == kMachHeaderSize && Le32(header, 0) == kMachMagic64)
|
||||
{
|
||||
uint64_t commands = std::min<uint64_t>(Le32(header, 20), 16u << 20);
|
||||
std::vector<uint8_t> head = read(slice->offset, kMachHeaderSize + commands);
|
||||
std::optional<Range> sig = SignatureRange(head, slice->offset);
|
||||
if (sig && sig->size <= (64u << 20))
|
||||
result = FromSuperBlob(read(sig->offset, sig->size));
|
||||
}
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return result;
|
||||
#else
|
||||
return std::nullopt;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The entitlements in the game's own code signature, as installed. On iOS
|
||||
// what the app may do is what its signature says (and its provisioning
|
||||
// profile allows: iOS won't install or open an app signed with entitlements
|
||||
// its profile lacks), and a sideloading tool (AltStore, SideStore,
|
||||
// Sideloadly...) re-signs the app with the player's own Apple Account: a
|
||||
// tool may keep, or drop, the entitlements the app asks for
|
||||
// (scripts/ios/SpeedBreaker/SpeedBreaker.entitlements). So the signature on
|
||||
// the device answers "did this copy get Extended Virtual Addressing?" for
|
||||
// the log and the startup message (kernel/memory_status.h).
|
||||
//
|
||||
// Parsing is pure (tests/code_signature_test.cpp): a Mach-O file (thin, or
|
||||
// fat with an arm64 slice) -> its LC_CODE_SIGNATURE -> the embedded
|
||||
// signature's SuperBlob -> the entitlements blob, as XML (slot 5) or DER
|
||||
// (slot 7). ReadOwn() reads the running executable (Apple only).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace platform::codesign
|
||||
{
|
||||
// A file range: where the embedded signature is in the executable.
|
||||
struct Range
|
||||
{
|
||||
uint64_t offset = 0;
|
||||
uint64_t size = 0;
|
||||
};
|
||||
|
||||
// The arm64 slice of a fat file, or the whole file if thin: from the
|
||||
// file's first bytes (the fat header, which is small). nullopt: neither a
|
||||
// Mach-O nor a fat file with an arm64 (or the only) slice.
|
||||
std::optional<Range> MachOSlice(std::span<const uint8_t> fileStart, uint64_t fileSize);
|
||||
|
||||
// The embedded signature's range in the file, from the slice's first
|
||||
// bytes (its header and load commands, `sliceOffset` into the file).
|
||||
// nullopt: not a 64-bit Mach-O, malformed, or unsigned (no
|
||||
// LC_CODE_SIGNATURE).
|
||||
std::optional<Range> SignatureRange(std::span<const uint8_t> sliceStart, uint64_t sliceOffset);
|
||||
|
||||
struct Entitlements
|
||||
{
|
||||
bool found = false; // the signature has an entitlements blob (else: none at all)
|
||||
std::string text; // XML (slot 5); else the DER blob's bytes (slot 7)
|
||||
bool der = false;
|
||||
};
|
||||
// The entitlements from the embedded signature (a CS SuperBlob,
|
||||
// big-endian). nullopt: not a SuperBlob or malformed. found == false: a
|
||||
// signature without entitlements (an ad-hoc one, as the linker makes).
|
||||
std::optional<Entitlements> FromSuperBlob(std::span<const uint8_t> blob);
|
||||
|
||||
enum class Value : uint8_t { Absent, False, True };
|
||||
// `key`'s boolean in the entitlements: XML <key>key</key><true/>, or DER
|
||||
// (UTF8String key, BOOLEAN). Absent: not there (or not a boolean).
|
||||
Value Lookup(const Entitlements& entitlements, std::string_view key);
|
||||
|
||||
// The whole chain on a file's bytes (tests): nullopt as above.
|
||||
std::optional<Entitlements> FromFile(std::span<const uint8_t> file);
|
||||
|
||||
// Apple: the running executable's entitlements, read from its file.
|
||||
// nullopt: unreadable, unsigned or not parsed (and elsewhere).
|
||||
std::optional<Entitlements> ReadOwn();
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
#include <video/swapchain_policy.h>
|
||||
|
||||
struct SDL_Window;
|
||||
|
||||
namespace platform::ios
|
||||
@@ -41,4 +43,54 @@ namespace platform::ios
|
||||
// An iPhone (UIDevice's interface idiom), not an iPad: Frame Rate's
|
||||
// default (main.cpp). Main thread.
|
||||
bool IsPhone();
|
||||
|
||||
// Orientation (video/swapchain_policy.h says why). SDL 3.4.16 sets its
|
||||
// view's frame itself when a keyboard shows or hides (-[SDL_uikitview-
|
||||
// controller updateKeyboard] -> UIKit_ComputeViewFrame, SDL_uikitvideo.m)
|
||||
// and swaps its width and height unless it believes the interface is
|
||||
// landscape: UIApplication.statusBarOrientation is landscape (on iOS 27
|
||||
// it reads "unknown") or the window can't be portrait. A resizable
|
||||
// window can be, to SDL, unless SDL_HINT_ORIENTATIONS says otherwise
|
||||
// (UIKit_GetSupportedOrientations ignores the Info.plist then).
|
||||
|
||||
// Before SDL_CreateWindow: on a device where the app is landscape-only
|
||||
// (LandscapeOnly), SDL_HINT_ORIENTATIONS = "LandscapeLeft LandscapeRight"
|
||||
// (unless set already), so SDL never turns the view portrait. Main thread.
|
||||
void PrepareOrientation();
|
||||
|
||||
// Once the window is up: the log notes keyboards showing and hiding, with
|
||||
// the view's geometry (the trigger of any turn that gets past the hint),
|
||||
// at most 20 times.
|
||||
void WatchKeyboards(SDL_Window* window);
|
||||
|
||||
// The app shows only in landscape here: an iPhone, whose Info.plist
|
||||
// lists no portrait orientation. (An iPad's window can take any shape.)
|
||||
bool LandscapeOnly();
|
||||
|
||||
// The interface's orientation: the window's scene's, else its bounds'
|
||||
// shape. Unknown without a window.
|
||||
video::Orientation InterfaceOrientation(SDL_Window* window);
|
||||
|
||||
// SDL's view (the Metal view the surface is) fills its UIWindow: the
|
||||
// same size (an offset for a keyboard is SDL's to make).
|
||||
bool ViewFitsWindow(SDL_Window* window);
|
||||
|
||||
// Fits SDL's view to its UIWindow's bounds again, and lays it out (SDL
|
||||
// then sizes the drawables and sends its resize events). True: the view
|
||||
// had to be changed. Logs what it did.
|
||||
bool FitViewToWindow(SDL_Window* window);
|
||||
|
||||
// The scene's interface orientation by name ("landscape left", ...),
|
||||
// for the log: a string literal, so a change shows as another pointer.
|
||||
const char* SceneOrientationName(SDL_Window* window);
|
||||
|
||||
// The view, its window and the scene in one line, for the log.
|
||||
std::string DescribeGeometry(SDL_Window* window);
|
||||
// The battery, for the log: its level and charging state once known
|
||||
// ("[power] battery 87%, charging, at 0.4 s"), then each change ("[power]
|
||||
// battery 87% -> 86%, on battery, at 312.0 s"), so a session's drain per
|
||||
// minute reads off its log beside the [thermal] lines. Main thread, every
|
||||
// frame (video::RunFrame): reads UIDevice every 5 seconds at most, with
|
||||
// battery monitoring left on from the first call.
|
||||
void PollBattery();
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
// press still comes through once.
|
||||
#include "ios_ui.h"
|
||||
|
||||
#include <SDL3/SDL_hints.h>
|
||||
#include <SDL3/SDL_keyboard.h>
|
||||
#include <SDL3/SDL_properties.h>
|
||||
#include <SDL3/SDL_video.h>
|
||||
@@ -33,9 +34,13 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
#include <report/report.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -69,6 +74,40 @@ namespace
|
||||
bool s_noViewLogged = false, s_logged = false;
|
||||
int s_retakes = 0;
|
||||
constexpr int kRetakesLogged = 10;
|
||||
|
||||
UIWindow* UiWindow(SDL_Window* window)
|
||||
{
|
||||
return window ? static_cast<UIWindow*>(SDL_GetPointerProperty(SDL_GetWindowProperties(window),
|
||||
SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER, nullptr)) : nil;
|
||||
}
|
||||
|
||||
const char* InterfaceName(UIInterfaceOrientation o)
|
||||
{
|
||||
switch (o)
|
||||
{
|
||||
case UIInterfaceOrientationPortrait:
|
||||
return "portrait";
|
||||
case UIInterfaceOrientationPortraitUpsideDown:
|
||||
return "portrait upside down";
|
||||
case UIInterfaceOrientationLandscapeLeft:
|
||||
return "landscape left";
|
||||
case UIInterfaceOrientationLandscapeRight:
|
||||
return "landscape right";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
bool SameSize(CGSize a, CGSize b)
|
||||
{
|
||||
return std::fabs(a.width - b.width) < 0.5 && std::fabs(a.height - b.height) < 0.5;
|
||||
}
|
||||
|
||||
// Keyboard notifications reach SDL's view controller, which sets the
|
||||
// view's frame on each (see ios_ui.h): the log says when, and whose.
|
||||
int s_keyboardNotes = 0;
|
||||
constexpr int kKeyboardNotesLogged = 20;
|
||||
SDL_Window* s_window = nullptr; // the game's, once it exists (FitViewToWindow)
|
||||
}
|
||||
|
||||
namespace platform::ios
|
||||
@@ -192,4 +231,203 @@ namespace platform::ios
|
||||
{
|
||||
return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone;
|
||||
}
|
||||
|
||||
bool LandscapeOnly()
|
||||
{
|
||||
static const bool only = [] {
|
||||
@autoreleasepool
|
||||
{
|
||||
if (!IsPhone())
|
||||
return false;
|
||||
// NSBundle hands over the device's own variant of the key.
|
||||
id list = [NSBundle.mainBundle objectForInfoDictionaryKey:@"UISupportedInterfaceOrientations"];
|
||||
if (![list isKindOfClass:[NSArray class]] || [(NSArray*)list count] == 0)
|
||||
return false;
|
||||
for (id o in (NSArray*)list)
|
||||
if (![o isKindOfClass:[NSString class]] || ![(NSString*)o hasPrefix:@"UIInterfaceOrientationLandscape"])
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}();
|
||||
return only;
|
||||
}
|
||||
|
||||
void PrepareOrientation()
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
if (LandscapeOnly())
|
||||
{
|
||||
if (const char* set = SDL_GetHint(SDL_HINT_ORIENTATIONS))
|
||||
fprintf(stderr, "[video] orientation: SDL_HINT_ORIENTATIONS is \"%s\" already: left as it is\n", set);
|
||||
else
|
||||
{
|
||||
SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight");
|
||||
fprintf(stderr, "[video] orientation: landscape only (iPhone): SDL_HINT_ORIENTATIONS = \"LandscapeLeft LandscapeRight\", "
|
||||
"so SDL never turns its view portrait\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WatchKeyboards(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
s_window = window;
|
||||
static bool watching = false;
|
||||
if (std::exchange(watching, true))
|
||||
return;
|
||||
for (NSString* name in @[ UIKeyboardWillShowNotification, UIKeyboardWillHideNotification ])
|
||||
[NSNotificationCenter.defaultCenter addObserverForName:name object:nil queue:nil usingBlock:^(NSNotification* note) {
|
||||
if (s_keyboardNotes >= kKeyboardNotesLogged)
|
||||
return;
|
||||
s_keyboardNotes++;
|
||||
NSNumber* local = note.userInfo[UIKeyboardIsLocalUserInfoKey];
|
||||
fprintf(stderr, "[ui] a keyboard will %s (%s)%s: %s\n", [note.name isEqualToString:UIKeyboardWillShowNotification] ? "show" : "hide",
|
||||
local == nil ? "whose not said" : local.boolValue ? "this app's" : "another app's",
|
||||
s_keyboardNotes == kKeyboardNotesLogged ? " (no more of these logged)" : "", DescribeGeometry(s_window).c_str());
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
video::Orientation InterfaceOrientation(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
if (!uiWindow)
|
||||
return video::Orientation::Unknown;
|
||||
UIInterfaceOrientation o = uiWindow.windowScene ? uiWindow.windowScene.effectiveGeometry.interfaceOrientation
|
||||
: UIInterfaceOrientationUnknown;
|
||||
if (UIInterfaceOrientationIsLandscape(o))
|
||||
return video::Orientation::Landscape;
|
||||
if (UIInterfaceOrientationIsPortrait(o))
|
||||
return video::Orientation::Portrait;
|
||||
const CGSize size = uiWindow.bounds.size;
|
||||
return size.width <= 0 || size.height <= 0 ? video::Orientation::Unknown
|
||||
: size.width >= size.height ? video::Orientation::Landscape
|
||||
: video::Orientation::Portrait;
|
||||
}
|
||||
}
|
||||
|
||||
bool ViewFitsWindow(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
UIView* view = uiWindow.rootViewController.viewIfLoaded;
|
||||
return !view || SameSize(view.bounds.size, uiWindow.bounds.size);
|
||||
}
|
||||
}
|
||||
|
||||
bool FitViewToWindow(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
UIView* view = uiWindow.rootViewController.viewIfLoaded;
|
||||
if (!view)
|
||||
return false;
|
||||
const CGRect before = view.frame;
|
||||
if (SameSize(view.bounds.size, uiWindow.bounds.size))
|
||||
{
|
||||
fprintf(stderr, "[video] the view already fills its window: %s\n", DescribeGeometry(window).c_str());
|
||||
return false;
|
||||
}
|
||||
view.frame = uiWindow.bounds;
|
||||
[view setNeedsLayout];
|
||||
[view layoutIfNeeded];
|
||||
fprintf(stderr, "[video] the view was %.0fx%.0f points in a %.0fx%.0f window: fitted back to it (%s)\n", before.size.width,
|
||||
before.size.height, uiWindow.bounds.size.width, uiWindow.bounds.size.height, DescribeGeometry(window).c_str());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const char* SceneOrientationName(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
return InterfaceName(uiWindow.windowScene ? uiWindow.windowScene.effectiveGeometry.interfaceOrientation
|
||||
: UIInterfaceOrientationUnknown);
|
||||
}
|
||||
}
|
||||
|
||||
std::string DescribeGeometry(SDL_Window* window)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIWindow* uiWindow = UiWindow(window);
|
||||
UIView* view = uiWindow.rootViewController.viewIfLoaded;
|
||||
if (!view)
|
||||
return "no view";
|
||||
UIInterfaceOrientation scene = uiWindow.windowScene ? uiWindow.windowScene.effectiveGeometry.interfaceOrientation
|
||||
: UIInterfaceOrientationUnknown;
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
UIInterfaceOrientation bar = UIApplication.sharedApplication.statusBarOrientation;
|
||||
#pragma clang diagnostic pop
|
||||
const char* state = "active";
|
||||
switch (UIApplication.sharedApplication.applicationState)
|
||||
{
|
||||
case UIApplicationStateInactive:
|
||||
state = "inactive";
|
||||
break;
|
||||
case UIApplicationStateBackground:
|
||||
state = "in the background";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
CGSize drawables = [view.layer isKindOfClass:[CAMetalLayer class]] ? ((CAMetalLayer*)view.layer).drawableSize : CGSizeZero;
|
||||
char text[320];
|
||||
snprintf(text, sizeof text,
|
||||
"view %.0fx%.0f at %.0f,%.0f in a %.0fx%.0f window; drawables %.0fx%.0f at scale %.3f; scene %s, status bar %s; app %s",
|
||||
view.frame.size.width, view.frame.size.height, view.frame.origin.x, view.frame.origin.y, uiWindow.bounds.size.width,
|
||||
uiWindow.bounds.size.height, drawables.width, drawables.height, double(view.layer.contentsScale), InterfaceName(scene),
|
||||
InterfaceName(bar), state);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
void PollBattery()
|
||||
{
|
||||
static double next = 0.0;
|
||||
static int lastPercent = -1;
|
||||
static const char* lastState = nullptr; // nullptr: nothing logged yet
|
||||
const double now = report::SessionSeconds();
|
||||
if (now < next)
|
||||
return;
|
||||
next = now + 5.0;
|
||||
@autoreleasepool
|
||||
{
|
||||
UIDevice* device = UIDevice.currentDevice;
|
||||
if (!device.batteryMonitoringEnabled)
|
||||
device.batteryMonitoringEnabled = YES; // left on: SDL's power API isn't used
|
||||
const float level = device.batteryLevel; // -1 while unknown
|
||||
const int percent = level < 0.0f ? -1 : int(std::lround(double(level) * 100.0));
|
||||
const char* state = "unknown";
|
||||
switch (device.batteryState)
|
||||
{
|
||||
case UIDeviceBatteryStateUnplugged: state = "on battery"; break;
|
||||
case UIDeviceBatteryStateCharging: state = "charging"; break;
|
||||
case UIDeviceBatteryStateFull: state = "full, on a charger"; break;
|
||||
default: break;
|
||||
}
|
||||
if (percent < 0)
|
||||
return; // monitoring just turned on (or no battery): next time
|
||||
if (!lastState)
|
||||
fprintf(stderr, "[power] battery %d%%, %s, at %.1f s\n", percent, state, now);
|
||||
else if (percent != lastPercent || std::strcmp(state, lastState) != 0)
|
||||
{
|
||||
if (percent != lastPercent)
|
||||
fprintf(stderr, "[power] battery %d%% -> %d%%, %s, at %.1f s\n", lastPercent, percent, state, now);
|
||||
else
|
||||
fprintf(stderr, "[power] battery %d%%, %s -> %s, at %.1f s\n", percent, lastState, state, now);
|
||||
}
|
||||
lastPercent = percent;
|
||||
lastState = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,10 @@ namespace ui
|
||||
// refuses for a file chooser, so no picker would open at all.
|
||||
SDL_PropertiesID props = SDL_CreateProperties();
|
||||
SDL_SetPointerProperty(props, SDL_PROP_FILE_DIALOG_WINDOW_POINTER, GetWindow());
|
||||
if (const char* home = std::getenv("HOME"))
|
||||
// Portable mode: it opens in SpeedBreaker's folder, where the game moves with it.
|
||||
if (const portable::Layout& layout = portable::Current(); layout.Active())
|
||||
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_LOCATION_STRING, layout.root.string().c_str());
|
||||
else if (const char* home = std::getenv("HOME"))
|
||||
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_LOCATION_STRING, home);
|
||||
SDL_SetStringProperty(props, SDL_PROP_FILE_DIALOG_TITLE_STRING, "Choose where to install the game");
|
||||
#ifdef __APPLE__
|
||||
|
||||
@@ -0,0 +1,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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -7,6 +7,8 @@
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
|
||||
#include "portable.h"
|
||||
|
||||
// Extracted disc contents (default.xex, NFS/, Movies/): where startup found
|
||||
// or installed the game (install::FindGameInstall, SetGamePath); before
|
||||
// that, NFSMW_GAME_DIR or the repo layout.
|
||||
@@ -54,7 +56,7 @@ inline const char*& CacheFolderName()
|
||||
return name;
|
||||
}
|
||||
|
||||
// Where the per-user folders go.
|
||||
// Where the per-user folders go (outside portable mode: user/portable.h).
|
||||
// macOS: ~/Library/Application Support, ~/Library/Caches
|
||||
// iOS: the same in the app's container; $XDG_DATA_HOME and
|
||||
// $XDG_CACHE_HOME when set (test runs: iOS sets HOME itself)
|
||||
@@ -93,21 +95,41 @@ inline std::filesystem::path CacheBase()
|
||||
#endif
|
||||
}
|
||||
|
||||
// Per-user data: saves, settings, logs and (by default) the installed game.
|
||||
inline std::filesystem::path GetUserPath()
|
||||
// This computer's own data and cache folders, portable mode or not
|
||||
// (install/portable_switch.h copies between them and SpeedBreaker's folder).
|
||||
inline std::filesystem::path HomeUserPath()
|
||||
{
|
||||
return UserDataBase() / DataFolderName();
|
||||
}
|
||||
|
||||
// Caches that can be deleted any time (compiled shaders, pipelines).
|
||||
inline std::filesystem::path GetCachePath()
|
||||
inline std::filesystem::path HomeCachePath()
|
||||
{
|
||||
return CacheBase() / CacheFolderName();
|
||||
}
|
||||
|
||||
// Per-user data: saves, settings, logs and (by default) the installed game.
|
||||
// In portable mode, the data folder in SpeedBreaker's own folder.
|
||||
inline std::filesystem::path GetUserPath()
|
||||
{
|
||||
if (const portable::Layout& layout = portable::Current(); layout.Active())
|
||||
return layout.data;
|
||||
return HomeUserPath();
|
||||
}
|
||||
|
||||
// Caches that can be deleted any time (compiled shaders, pipelines). In
|
||||
// portable mode, the cache folder in SpeedBreaker's own folder.
|
||||
inline std::filesystem::path GetCachePath()
|
||||
{
|
||||
if (const portable::Layout& layout = portable::Current(); layout.Active())
|
||||
return layout.cache;
|
||||
return HomeCachePath();
|
||||
}
|
||||
|
||||
// Renames nfsmw-recomp folders to the current name, leaving the old name as
|
||||
// a link to the new folder (for older builds and scripts). Call first thing
|
||||
// in main; returns what it did, for the log once that is open.
|
||||
// in main (after portable::Detect: in portable mode it does nothing, since
|
||||
// the home folder isn't SpeedBreaker's then); returns what it did, for the
|
||||
// log once that is open.
|
||||
std::string MigrateUserFolders();
|
||||
|
||||
inline std::filesystem::path GetSavePath(bool)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See portable.h.
|
||||
#include "portable.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <format>
|
||||
#include <system_error>
|
||||
|
||||
namespace portable
|
||||
{
|
||||
namespace
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// Normalized, no trailing separator.
|
||||
fs::path Normal(const fs::path& path)
|
||||
{
|
||||
fs::path p = path.lexically_normal();
|
||||
while (p.has_relative_path() && p.filename().empty())
|
||||
p = p.parent_path();
|
||||
return p;
|
||||
}
|
||||
|
||||
// `relative` stays inside the folder it's relative to (not "", ".",
|
||||
// or "../...").
|
||||
bool Inside(const fs::path& relative)
|
||||
{
|
||||
return !relative.empty() && relative != "." && *relative.begin() != "..";
|
||||
}
|
||||
}
|
||||
|
||||
Layout Resolve(const std::filesystem::path& root, bool marker, bool supported)
|
||||
{
|
||||
Layout layout;
|
||||
if (!supported || !marker || root.empty() || !root.is_absolute())
|
||||
return layout;
|
||||
layout.root = Normal(root);
|
||||
layout.data = layout.root / kDataFolder;
|
||||
layout.cache = layout.root / kCacheFolder;
|
||||
return layout;
|
||||
}
|
||||
|
||||
bool MarkerIn(const std::filesystem::path& root)
|
||||
{
|
||||
std::error_code ec;
|
||||
return !root.empty() && fs::is_regular_file(root / kMarker, ec);
|
||||
}
|
||||
|
||||
const Layout& Detect(const std::filesystem::path& root)
|
||||
{
|
||||
CurrentLayout() = Resolve(root, kSupported && MarkerIn(root));
|
||||
return Current();
|
||||
}
|
||||
|
||||
std::string RecordText(const std::filesystem::path& folder, const Layout& layout)
|
||||
{
|
||||
fs::path path = Normal(folder);
|
||||
if (layout.Active())
|
||||
if (fs::path relative = path.lexically_relative(layout.root); Inside(relative))
|
||||
return relative.generic_string();
|
||||
return path.string();
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> ParseRecord(std::string_view text, const Layout& layout)
|
||||
{
|
||||
while (!text.empty() && (text.back() == '\n' || text.back() == '\r'))
|
||||
text.remove_suffix(1);
|
||||
if (text.empty())
|
||||
return std::nullopt;
|
||||
fs::path path{ std::string(text) };
|
||||
// (Records before v0.1.1 kept `--dest dir/`'s trailing separator.)
|
||||
if (path.is_absolute())
|
||||
return Normal(path);
|
||||
if (!layout.Active())
|
||||
return std::nullopt;
|
||||
fs::path relative = path.lexically_normal();
|
||||
if (!Inside(relative) || relative.has_root_path())
|
||||
return std::nullopt;
|
||||
return Normal(layout.root / relative);
|
||||
}
|
||||
|
||||
int CheckWritable(const Layout& layout)
|
||||
{
|
||||
if (!layout.Active())
|
||||
return 0;
|
||||
std::error_code ec;
|
||||
fs::create_directories(layout.data, ec);
|
||||
if (ec)
|
||||
{
|
||||
std::error_condition condition = ec.default_error_condition();
|
||||
return condition.category() == std::generic_category() ? condition.value() : EIO;
|
||||
}
|
||||
// A real write: a read-only mount, a locked card or permissions all
|
||||
// show here, whatever the file system.
|
||||
fs::path probe = layout.data / ".write-check";
|
||||
errno = 0;
|
||||
FILE* f = fopen(probe.string().c_str(), "wb");
|
||||
if (!f)
|
||||
return errno ? errno : EACCES;
|
||||
bool ok = fputc('1', f) != EOF;
|
||||
int error = errno;
|
||||
ok = fclose(f) == 0 && ok;
|
||||
if (!ok && !error)
|
||||
error = errno;
|
||||
fs::remove(probe, ec);
|
||||
return ok ? 0 : (error ? error : EIO);
|
||||
}
|
||||
|
||||
std::string Describe(const Layout& layout)
|
||||
{
|
||||
if (!layout.Active())
|
||||
return {};
|
||||
return std::format("[paths] portable mode ({} in {}): data in {}, caches in {}\n", kMarker, layout.root.string(),
|
||||
layout.data.string(), layout.cache.string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Portable mode (GitHub issue #1): everything SpeedBreaker keeps (settings,
|
||||
// saves, logs, caches and, by default, the installed game) in SpeedBreaker's
|
||||
// own folder, so the whole folder can be moved to another machine and run
|
||||
// there. Opt-in: a file named portable.txt in that folder (the one with
|
||||
// speedbreaker.sh; a development build's program folder; on Windows, the
|
||||
// one with SpeedBreaker.exe: install::BundleFolder()). Its contents are
|
||||
// ignored.
|
||||
//
|
||||
// Why a file: it travels with the folder (an environment variable lives in
|
||||
// a Steam shortcut or a shell profile, per machine, so the next machine
|
||||
// would quietly use its own data folder), nothing makes it by accident (the
|
||||
// download never holds one, and unpacking an update over the folder leaves
|
||||
// it), and players know it from Xenia, Dolphin and DuckStation. Not a data/
|
||||
// folder as the switch: deleting or renaming a folder would flip the mode
|
||||
// without a word, and a leftover can't be told from an opt-in.
|
||||
//
|
||||
// The layout, in SpeedBreaker's folder (the "root"):
|
||||
// data/ what GetUserPath() is otherwise (~/.local/share/speedbreaker),
|
||||
// the same layout: settings.toml, profile_settings.bin, save/,
|
||||
// logs/ (with crash and hang reports), game-location, and game/,
|
||||
// the installer's default
|
||||
// cache/ what GetCachePath() is otherwise (~/.cache/speedbreaker):
|
||||
// SPIR-V (GPU-independent, keyed by shader source) and the Vulkan
|
||||
// pipeline cache (its header names the GPU and driver: another
|
||||
// GPU's driver ignores it and it is rebuilt). Beside data/, so it
|
||||
// can be deleted without touching saves.
|
||||
// Nothing outside the folder is read or written for SpeedBreaker's data:
|
||||
// MigrateUserFolders does nothing, XDG_DATA_HOME and XDG_CACHE_HOME are
|
||||
// ignored, and no symlink is made. The game's location is recorded relative
|
||||
// to the folder when it is inside it (RecordText), so it moves with it.
|
||||
//
|
||||
// Not on Apple platforms: a signed Mac app can't hold data (macOS stops
|
||||
// opening it), a folder beside it is App-Translocated or privacy-prompted in
|
||||
// the common cases, and iOS apps write only their container. There the
|
||||
// layout is never active.
|
||||
//
|
||||
// Switching an existing setup in or out with a copy: install/portable_switch.h
|
||||
// (speedbreaker.sh --portable on|off). By hand, making or deleting the file
|
||||
// switches with nothing copied.
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace portable
|
||||
{
|
||||
inline constexpr const char* kMarker = "portable.txt";
|
||||
inline constexpr const char* kDataFolder = "data";
|
||||
inline constexpr const char* kCacheFolder = "cache";
|
||||
|
||||
#if defined(__APPLE__)
|
||||
inline constexpr bool kSupported = false;
|
||||
#else
|
||||
inline constexpr bool kSupported = true;
|
||||
#endif
|
||||
|
||||
// Where portable mode keeps things; all empty when it's off.
|
||||
struct Layout
|
||||
{
|
||||
std::filesystem::path root; // SpeedBreaker's folder: absolute, normalized, no trailing separator
|
||||
std::filesystem::path data; // root/data
|
||||
std::filesystem::path cache; // root/cache
|
||||
|
||||
bool Active() const { return !root.empty(); }
|
||||
};
|
||||
|
||||
// Pure: the layout for SpeedBreaker's folder `root` (absolute; empty if
|
||||
// unknown), with the marker in it or not. Off unless supported, marked
|
||||
// and absolute.
|
||||
Layout Resolve(const std::filesystem::path& root, bool marker, bool supported = kSupported);
|
||||
|
||||
// The layout in effect: set by Detect at startup, before any other thread
|
||||
// starts and before anything uses a data folder (paths.h reads it).
|
||||
inline Layout& CurrentLayout()
|
||||
{
|
||||
static Layout layout;
|
||||
return layout;
|
||||
}
|
||||
|
||||
inline const Layout& Current()
|
||||
{
|
||||
return CurrentLayout();
|
||||
}
|
||||
|
||||
// Is there a portable.txt in `root`: a file, or a link to one (one stat)?
|
||||
bool MarkerIn(const std::filesystem::path& root);
|
||||
|
||||
// Startup: Resolve(root, MarkerIn(root)) made current, and returned. No
|
||||
// look at the disk on Apple platforms.
|
||||
const Layout& Detect(const std::filesystem::path& root);
|
||||
|
||||
// The text recorded for the game folder `folder` (absolute, links
|
||||
// resolved): relative to the root, with '/' separators, when portable
|
||||
// mode is on and the folder is inside the root; otherwise the absolute
|
||||
// path.
|
||||
std::string RecordText(const std::filesystem::path& folder, const Layout& layout);
|
||||
|
||||
// The folder a record names, or nullopt: an absolute path as it is (no
|
||||
// trailing separator); a relative one only in portable mode, inside the
|
||||
// root (resolved against where the root is now; "../" out of it is
|
||||
// ignored). Trailing newlines are ignored.
|
||||
std::optional<std::filesystem::path> ParseRecord(std::string_view text, const Layout& layout);
|
||||
|
||||
// Startup, portable mode: makes the data folder if needed and writes a
|
||||
// small file there (then deletes it). 0 if that worked, else the errno
|
||||
// (EROFS, EACCES...): settings and saves won't be kept. 0 when off.
|
||||
int CheckWritable(const Layout& layout);
|
||||
|
||||
// One log line: where the data and caches are ("" when off).
|
||||
std::string Describe(const Layout& layout);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Which Vulkan device the presenter draws with (video/presenter.cpp,
|
||||
// ChooseDevice), and the log's one line about it. Up to v0.1.1 it was the
|
||||
// loader's first that could present to the window. On a laptop with two GPUs
|
||||
// (an Intel or AMD one in the processor beside an NVIDIA or AMD graphics
|
||||
// card) the loader, and Mesa's device-select layer, usually list the
|
||||
// integrated one first, so the game ran there (GitHub PR #6). Now, of the
|
||||
// devices that can present to the window:
|
||||
// 1. one with every device extension the game needs, before one without
|
||||
// (the game can't start on that);
|
||||
// 2. a discrete GPU, then an integrated one, a virtual one, any other kind,
|
||||
// then a software renderer (llvmpipe); a layered one (Mesa's Dozen,
|
||||
// Vulkan on Direct3D 12, which lists the same GPUs again) after every
|
||||
// native driver's;
|
||||
// 3. among equals, the one with the most device-local memory;
|
||||
// 4. then the loader's order.
|
||||
// A device that can't present to the window is never chosen. So where only
|
||||
// one can present (a Steam Deck, a Steam Machine, a Steam Frame, a Mac, an
|
||||
// iPhone or iPad: one GPU each), it is that one, as before.
|
||||
// NFSMW_GPU=<index or part of a name> picks one by hand: an index from the
|
||||
// log's list, or any part of a name, in any case (several matching: the best
|
||||
// of them by the rules above). A number that is no device's index is tried as
|
||||
// part of a name ("3050").
|
||||
//
|
||||
// Header only and Vulkan-free (`type` holds VkPhysicalDeviceType's values):
|
||||
// tests/gpu_choice_test.cpp. The windows branch's first version of this file
|
||||
// had rules 2 and 4 (and NFSMW_GPU); this one adds 1 and 3, and the log line.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct GpuCandidate
|
||||
{
|
||||
size_t index; // in the loader's list (vkEnumeratePhysicalDevices)
|
||||
int type; // VkPhysicalDeviceType: 1 integrated, 2 discrete, 3 virtual, 4 CPU, else other
|
||||
bool layered = false; // Mesa's Dozen: Vulkan on Direct3D 12
|
||||
std::string name; // deviceName
|
||||
uint64_t localBytes = 0; // its device-local memory heaps, added up
|
||||
bool presents = true; // a graphics queue family can present to the window
|
||||
std::string lacks; // a device extension the game needs that it doesn't have ("": none)
|
||||
};
|
||||
|
||||
inline constexpr size_t kNoGpu = size_t(-1);
|
||||
|
||||
inline const char* GpuKind(int type)
|
||||
{
|
||||
return type == 2 ? "discrete" : type == 1 ? "integrated" : type == 3 ? "virtual" : type == 4 ? "software" : "other";
|
||||
}
|
||||
|
||||
// "a discrete GPU", "an integrated GPU on Direct3D 12".
|
||||
inline std::string GpuDescription(const GpuCandidate& c)
|
||||
{
|
||||
const char* text = c.type == 2 ? "a discrete GPU" : c.type == 1 ? "an integrated GPU" : c.type == 3 ? "a virtual GPU"
|
||||
: c.type == 4 ? "a software renderer" : "a device of another kind";
|
||||
return std::format("{}{}", text, c.layered ? " on Direct3D 12" : "");
|
||||
}
|
||||
|
||||
// "7.6 GB" (binary gigabytes, as drivers size their heaps).
|
||||
inline std::string GpuMemory(uint64_t bytes)
|
||||
{
|
||||
return std::format("{:.1f} GB", double(bytes) / double(1ull << 30));
|
||||
}
|
||||
|
||||
// Rule 2's order: lower comes first.
|
||||
inline int GpuKindRank(const GpuCandidate& c)
|
||||
{
|
||||
int kind = c.type == 2 ? 0 : c.type == 1 ? 1 : c.type == 3 ? 2 : c.type == 4 ? 4 : 3;
|
||||
return kind + (c.layered ? 5 : 0);
|
||||
}
|
||||
|
||||
// Rules 1-4: whether `a` comes before `b`.
|
||||
inline bool GpuBefore(const GpuCandidate& a, const GpuCandidate& b)
|
||||
{
|
||||
if (a.lacks.empty() != b.lacks.empty())
|
||||
return a.lacks.empty();
|
||||
if (GpuKindRank(a) != GpuKindRank(b))
|
||||
return GpuKindRank(a) < GpuKindRank(b);
|
||||
if (a.localBytes != b.localBytes)
|
||||
return a.localBytes > b.localBytes;
|
||||
return a.index < b.index;
|
||||
}
|
||||
|
||||
struct GpuChoice
|
||||
{
|
||||
size_t pick = kNoGpu; // into the list; kNoGpu: none can present to the window
|
||||
std::string why; // why that one, for the log
|
||||
std::string want; // NFSMW_GPU as given ("": not set)
|
||||
std::string wantNote; // when NFSMW_GPU was set but not used: why not ("": used, or not set)
|
||||
};
|
||||
|
||||
namespace detail
|
||||
{
|
||||
inline std::string Lower(std::string text)
|
||||
{
|
||||
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return char(std::tolower(c)); });
|
||||
return text;
|
||||
}
|
||||
|
||||
inline std::string Trim(const char* text)
|
||||
{
|
||||
std::string s(text ? text : "");
|
||||
size_t b = s.find_first_not_of(" \t"), e = s.find_last_not_of(" \t");
|
||||
return b == std::string::npos ? std::string() : s.substr(b, e - b + 1);
|
||||
}
|
||||
|
||||
// The best of the presenting devices, by rules 1-4 (kNoGpu: none).
|
||||
inline size_t Best(const std::vector<GpuCandidate>& devices, size_t skip = kNoGpu)
|
||||
{
|
||||
size_t best = kNoGpu;
|
||||
for (size_t i = 0; i < devices.size(); i++)
|
||||
if (i != skip && devices[i].presents && (best == kNoGpu || GpuBefore(devices[i], devices[best])))
|
||||
best = i;
|
||||
return best;
|
||||
}
|
||||
|
||||
// Why `pick` came before `next` (the best of the rest), by the first
|
||||
// rule that tells them apart.
|
||||
inline std::string Why(const GpuCandidate& pick, const GpuCandidate& next)
|
||||
{
|
||||
if (pick.lacks.empty() != next.lacks.empty())
|
||||
return std::format("{} {} lacks {}, which the game needs", next.index, next.name, next.lacks);
|
||||
if (GpuKindRank(pick) != GpuKindRank(next))
|
||||
return std::format("{} before {}", GpuDescription(pick), GpuDescription(next));
|
||||
if (pick.localBytes != next.localBytes)
|
||||
return std::format("{}, with more device-local memory than {}: {} against {}", GpuDescription(pick), next.index,
|
||||
GpuMemory(pick.localBytes), GpuMemory(next.localBytes));
|
||||
return std::format("{}, with as much device-local memory as {}: the loader's order", GpuDescription(pick), next.index);
|
||||
}
|
||||
}
|
||||
|
||||
// `devices`: every device the loader lists, in its order. `want`:
|
||||
// NFSMW_GPU, or null.
|
||||
inline GpuChoice ChooseGpu(const std::vector<GpuCandidate>& devices, const char* want)
|
||||
{
|
||||
GpuChoice choice;
|
||||
choice.pick = detail::Best(devices);
|
||||
if (choice.pick == kNoGpu)
|
||||
return choice;
|
||||
const size_t next = detail::Best(devices, choice.pick);
|
||||
choice.why = next == kNoGpu ? std::string("the only one that can present to the window")
|
||||
: detail::Why(devices[choice.pick], devices[next]);
|
||||
|
||||
choice.want = detail::Trim(want);
|
||||
if (choice.want.empty())
|
||||
return choice;
|
||||
// An index from the log's list, else part of a name.
|
||||
size_t named = kNoGpu;
|
||||
char* end = nullptr;
|
||||
unsigned long long index = std::strtoull(choice.want.c_str(), &end, 10);
|
||||
if (end != choice.want.c_str() && *end == '\0' && std::isdigit((unsigned char)choice.want[0]))
|
||||
for (size_t i = 0; i < devices.size() && named == kNoGpu; i++)
|
||||
if (devices[i].index == index)
|
||||
named = i;
|
||||
bool anyName = false;
|
||||
if (named == kNoGpu)
|
||||
{
|
||||
const std::string part = detail::Lower(choice.want);
|
||||
for (size_t i = 0; i < devices.size(); i++)
|
||||
if (detail::Lower(devices[i].name).find(part) != std::string::npos)
|
||||
{
|
||||
anyName = true;
|
||||
if (devices[i].presents && (named == kNoGpu || GpuBefore(devices[i], devices[named])))
|
||||
named = i;
|
||||
}
|
||||
}
|
||||
if (named != kNoGpu && devices[named].presents)
|
||||
{
|
||||
choice.pick = named;
|
||||
choice.why = std::format("NFSMW_GPU={}", choice.want);
|
||||
return choice;
|
||||
}
|
||||
choice.wantNote = named != kNoGpu
|
||||
? std::format("NFSMW_GPU={} names {} {}, which can't present to the window", choice.want, devices[named].index, devices[named].name)
|
||||
: anyName ? std::format("NFSMW_GPU={} names only devices that can't present to the window", choice.want)
|
||||
: std::format("NFSMW_GPU={} names none of them (an index from this list, or part of a name)", choice.want);
|
||||
return choice;
|
||||
}
|
||||
|
||||
// The log's line (after "[video] "): every device the loader listed, the
|
||||
// one used, and why.
|
||||
// Vulkan devices: 0 Intel(R) Iris(R) Xe Graphics (integrated, 7.6 GB),
|
||||
// 1 NVIDIA GeForce RTX 3050 Laptop GPU (discrete, 4.0 GB); using 1: a
|
||||
// discrete GPU before an integrated GPU (the loader listed 0 first);
|
||||
// NFSMW_GPU=<index or part of a name> picks another
|
||||
inline std::string GpuLogLine(const std::vector<GpuCandidate>& devices, const GpuChoice& choice)
|
||||
{
|
||||
std::string line = "Vulkan devices: ";
|
||||
if (devices.empty())
|
||||
line += "none";
|
||||
size_t presenting = 0, first = kNoGpu;
|
||||
for (size_t i = 0; i < devices.size(); i++)
|
||||
{
|
||||
const GpuCandidate& c = devices[i];
|
||||
line += std::format("{}{} {} ({}{}{}{}{})", i ? ", " : "", c.index, c.name, GpuKind(c.type),
|
||||
c.layered ? " on Direct3D 12" : "", c.localBytes ? ", " + GpuMemory(c.localBytes) : std::string(),
|
||||
c.presents ? "" : ", can't present to the window", c.lacks.empty() ? std::string() : ", lacks " + c.lacks);
|
||||
if (c.presents)
|
||||
{
|
||||
presenting++;
|
||||
if (first == kNoGpu)
|
||||
first = i;
|
||||
}
|
||||
}
|
||||
if (choice.pick == kNoGpu)
|
||||
return line + "; none can present to the window";
|
||||
line += std::format("; using {}: {}", devices[choice.pick].index,
|
||||
choice.wantNote.empty() ? choice.why : choice.wantNote + ", so the usual choice: " + choice.why);
|
||||
if (presenting > 1)
|
||||
{
|
||||
line += choice.pick == first ? " (the loader listed it first)" : std::format(" (the loader listed {} first)", devices[first].index);
|
||||
if (choice.want.empty())
|
||||
line += "; NFSMW_GPU=<index or part of a name> picks another";
|
||||
}
|
||||
return line;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See picture_fit.h.
|
||||
#include "picture_fit.h"
|
||||
|
||||
#include <gpu/edge_bars.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
@@ -58,6 +60,22 @@ namespace video
|
||||
return fit;
|
||||
}
|
||||
|
||||
FrameBand BandOf(uint32_t width, uint32_t height, float scaleX, float scaleY)
|
||||
{
|
||||
FrameBand band{ 0, int32_t(width), 0, int32_t(height) };
|
||||
if (scaleX < 1.0f)
|
||||
{
|
||||
band.x0 = gpu::bars::Edge(-scaleX, float(width));
|
||||
band.x1 = gpu::bars::Edge(scaleX, float(width));
|
||||
}
|
||||
if (scaleY < 1.0f)
|
||||
{
|
||||
band.y0 = gpu::bars::Edge(-scaleY, float(height));
|
||||
band.y1 = gpu::bars::Edge(scaleY, float(height));
|
||||
}
|
||||
return band;
|
||||
}
|
||||
|
||||
void AutoRenderScale(uint32_t regionW, uint32_t regionH, uint32_t budget, uint32_t& sx, uint32_t& sy)
|
||||
{
|
||||
// Enough host pixels to cover the screen area the frame fills
|
||||
|
||||
@@ -53,6 +53,18 @@ namespace video
|
||||
// for another one (a redraw after a resize) or clamped to 4:3.
|
||||
PictureFit PlacePicture(uint32_t screenW, uint32_t screenH, float frameW, float frameH, float scaleX, float scaleY);
|
||||
|
||||
// The 16:9 band of a frame image of width x height texels (any internal
|
||||
// resolution) rendered with the view scales (scaleX, scaleY): what a
|
||||
// 16:9 screen shows, the columns [x0, x1) and rows [y0, y1) whose
|
||||
// centres lie inside clip +-scaleX and +-scaleY, by gpu::bars::Edge's
|
||||
// rule, so the presenter splits a frame where the renderer's band clip
|
||||
// and edge-bar fills do. The whole image at 1, 1.
|
||||
struct FrameBand
|
||||
{
|
||||
int32_t x0 = 0, x1 = 0, y0 = 0, y1 = 0;
|
||||
};
|
||||
FrameBand BandOf(uint32_t width, uint32_t height, float scaleX, float scaleY);
|
||||
|
||||
// Auto internal resolution: the render scale (sx x sy host pixels per
|
||||
// guest pixel, 1-3 each) for a picture covering regionW x regionH of the
|
||||
// screen (video::FrameRegion), within `budget` host pixels per guest
|
||||
|
||||
+419
-83
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See scale_budget.h.
|
||||
#include "scale_budget.h"
|
||||
|
||||
namespace video
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// "Apple <family><generation>[ <tier>] GPU": family 'A' (iPhones, the
|
||||
// iPad mini and the base iPad) or 'M' (iPad Pro and Air), tier "Pro"
|
||||
// or empty. False for any other name.
|
||||
bool ParseAppleChip(std::string_view name, char& family, unsigned& generation, std::string_view& tier)
|
||||
{
|
||||
constexpr std::string_view kApple = "Apple ";
|
||||
if (!name.starts_with(kApple))
|
||||
return false;
|
||||
name.remove_prefix(kApple.size());
|
||||
if (name.empty() || (name[0] != 'A' && name[0] != 'M'))
|
||||
return false;
|
||||
family = name[0];
|
||||
name.remove_prefix(1);
|
||||
generation = 0;
|
||||
size_t digits = 0;
|
||||
while (digits < name.size() && name[digits] >= '0' && name[digits] <= '9' && digits < 4)
|
||||
generation = generation * 10 + unsigned(name[digits++] - '0');
|
||||
if (digits == 0 || (digits < name.size() && name[digits] != ' '))
|
||||
return false;
|
||||
name.remove_prefix(digits < name.size() ? digits + 1 : digits);
|
||||
// What's left: "GPU", or "<tier> GPU". MoltenVK names the device
|
||||
// after Metal's, which ends in " GPU"; accept a name without it too.
|
||||
constexpr std::string_view kGpu = "GPU";
|
||||
if (name.ends_with(kGpu))
|
||||
name.remove_suffix(kGpu.size());
|
||||
while (!name.empty() && name.back() == ' ')
|
||||
name.remove_suffix(1);
|
||||
tier = name;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
ScaleBudget MobileScaleBudget(std::string_view gpuName)
|
||||
{
|
||||
char family = 0;
|
||||
unsigned generation = 0;
|
||||
std::string_view tier;
|
||||
if (!ParseAppleChip(gpuName, family, generation, tier))
|
||||
return { 1, "an unrecognised GPU keeps 1x1" };
|
||||
if (family == 'A' && generation == 20 && tier == "Pro")
|
||||
{
|
||||
// iPhone 18 Pro Max (2868x1320 screen), on a charger, scripted
|
||||
// fly-in and race: 60 fps at 1x1 with GPU to spare (race 7.7 ms a
|
||||
// frame), but iOS's thermal state went nominal -> fair at 75 s and
|
||||
// -> serious at 350 s (Frame Rate's Auto then dropped to 30 fps),
|
||||
// and was still serious 25 minutes later. More pixels only heat
|
||||
// it sooner: 1x1 until 2x2 is shown to stay out of serious.
|
||||
return { 1, "the A20 Pro heats to serious within 6 minutes at 1x1 on a charger" };
|
||||
}
|
||||
if (family == 'A' && generation <= 17)
|
||||
{
|
||||
// iPhone 15 Pro Max (A17 Pro), scripted race: GPU 15 ms a frame
|
||||
// at 1x1, 36-40 ms at 2x2.
|
||||
return { 1, "A17 Pro and older hold a race at 60 fps at 1x1 only" };
|
||||
}
|
||||
if (family == 'M')
|
||||
{
|
||||
// iPad Pro M2: 2x2 = 43-50 fps in a race. Newer M chips are not
|
||||
// measured in an iPad.
|
||||
return { 1, "iPads keep 1x1: the M2 makes 43-50 fps at 2x2" };
|
||||
}
|
||||
// An A-series chip newer than the A17 Pro but not measured (the
|
||||
// A18/A19, a plain A20, an A21...).
|
||||
return { 1, "a chip not measured yet keeps 1x1" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Internal Resolution's Auto on iPhones and iPads: how many host pixels per
|
||||
// guest pixel (sx x sy, video::AutoRenderScale's budget) the GPU gets, chip
|
||||
// by chip. A pure function of the GPU's name, for tests/scale_budget_test.cpp.
|
||||
//
|
||||
// Only chips measured to hold a race at 60 fps with headroom, and to stay out
|
||||
// of serious thermal throttling over a long session, get more than 1x1. Any
|
||||
// other chip, a newer one included, keeps 1x1 until it is measured: a phone
|
||||
// that renders too much heats up and throttles to 30 fps mid-session, which
|
||||
// is worse than a softer picture. None has qualified yet: the A20 Pro (iPhone
|
||||
// 18 Pro Max) reaches the serious thermal state at 1x1 already (on a
|
||||
// charger), so it keeps 1x1 too, and Settings > Graphics > Internal
|
||||
// Resolution > 2x stays the player's choice.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct ScaleBudget
|
||||
{
|
||||
// At most this many host pixels per guest pixel (1: 1x1, 4: 2x2).
|
||||
uint32_t pixels = 1;
|
||||
// Why, for the log ("[renderer] Auto internal resolution: ...").
|
||||
const char* reason = "";
|
||||
};
|
||||
|
||||
// The budget for the GPU that MoltenVK names `gpuName` (Metal's device
|
||||
// name, "Apple A20 Pro GPU", "Apple M2 GPU").
|
||||
ScaleBudget MobileScaleBudget(std::string_view gpuName);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See swapchain_policy.h.
|
||||
#include "swapchain_policy.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace video
|
||||
{
|
||||
const char* OrientationName(Orientation orientation)
|
||||
{
|
||||
switch (orientation)
|
||||
{
|
||||
case Orientation::Landscape:
|
||||
return "landscape";
|
||||
case Orientation::Portrait:
|
||||
return "portrait";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const char* SwapActionName(SwapAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case SwapAction::Recreate:
|
||||
return "recreate";
|
||||
case SwapAction::RepairView:
|
||||
return "repair the view";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
ExtentChoice ChooseExtent(Extent surface, Orientation interface, bool landscapeOnly, bool refusePortrait)
|
||||
{
|
||||
// Unknown counts as landscape: the app shows in nothing else.
|
||||
if (!refusePortrait || !landscapeOnly || !surface.Portrait() || interface == Orientation::Portrait)
|
||||
return { surface, false };
|
||||
return { Extent{ surface.height, surface.width }, true };
|
||||
}
|
||||
|
||||
void SwapchainPolicy::Suspended()
|
||||
{
|
||||
resumeWatch_ = false;
|
||||
}
|
||||
|
||||
void SwapchainPolicy::Resumed()
|
||||
{
|
||||
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
|
||||
resumeWatch_ = rules_.settleFrames > 0;
|
||||
}
|
||||
|
||||
void SwapchainPolicy::SizeChanged()
|
||||
{
|
||||
sizePending_ = true;
|
||||
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
|
||||
}
|
||||
|
||||
void SwapchainPolicy::Reported(SurfaceResult result)
|
||||
{
|
||||
if (result == SurfaceResult::OutOfDate)
|
||||
outOfDate_ = true;
|
||||
else if (rules_.followSuboptimal)
|
||||
suboptimal_ = true;
|
||||
}
|
||||
|
||||
void SwapchainPolicy::Created(Extent)
|
||||
{
|
||||
outOfDate_ = false;
|
||||
sizePending_ = false;
|
||||
settleLeft_ = std::max(settleLeft_, rules_.settleFrames);
|
||||
}
|
||||
|
||||
bool SwapchainPolicy::WantsSurface() const
|
||||
{
|
||||
return sizePending_ || suboptimal_ || outOfDate_ || settleLeft_ > 0 || misfitFrames_ > 0 || refusedFrames_ > 0 ||
|
||||
(rules_.periodFrames > 0 && (frame_ + 1) % rules_.periodFrames == 0);
|
||||
}
|
||||
|
||||
SwapDecision SwapchainPolicy::Decide(const SurfaceState& s)
|
||||
{
|
||||
SwapDecision d;
|
||||
if (!s.active && rules_.holdWhileInactive)
|
||||
{
|
||||
// What was seen meanwhile waits for the resume: a size reported
|
||||
// now (iOS turning or snapshotting the window) is not the one the
|
||||
// app will show at.
|
||||
d.reason = "inactive";
|
||||
return d;
|
||||
}
|
||||
frame_++;
|
||||
if (settleLeft_ > 0 && --settleLeft_ == 0 && resumeWatch_)
|
||||
{
|
||||
resumeWatch_ = false;
|
||||
d.settled = true;
|
||||
}
|
||||
|
||||
if (outOfDate_)
|
||||
{
|
||||
// The old one can't be presented to: build again whatever the size.
|
||||
outOfDate_ = suboptimal_ = sizePending_ = false;
|
||||
ExtentChoice choice = ChooseExtent(s.surface, s.interface, s.landscapeOnly, rules_.refusePortrait);
|
||||
d.action = SwapAction::Recreate;
|
||||
d.reason = "VK_ERROR_OUT_OF_DATE_KHR";
|
||||
d.extent = choice.extent;
|
||||
d.refused = choice.refused;
|
||||
return d;
|
||||
}
|
||||
if (!s.surfaceRead)
|
||||
return d;
|
||||
if (s.surface.Empty())
|
||||
{
|
||||
// Minimised (or no layer): as before, a size event tears the
|
||||
// swapchain down and the presenter waits for a size again.
|
||||
refusedFrames_ = misfitFrames_ = 0;
|
||||
if (sizePending_ && !s.swapchain.Empty())
|
||||
{
|
||||
sizePending_ = suboptimal_ = false;
|
||||
d.action = SwapAction::Recreate;
|
||||
d.reason = "no surface (minimised)";
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// iOS: the surface is the view's layer. A view that doesn't fill its
|
||||
// window was sized by someone guessing (SDL's portrait guess): fit it
|
||||
// back, and look again next frame, before following it anywhere.
|
||||
if (!s.viewFitsWindow && rules_.repairView)
|
||||
{
|
||||
misfitFrames_++;
|
||||
if (misfitFrames_ <= rules_.repairPatience)
|
||||
{
|
||||
if (misfitFrames_ <= 3 || (rules_.repairEvery && misfitFrames_ % rules_.repairEvery == 0))
|
||||
{
|
||||
d.action = SwapAction::RepairView;
|
||||
d.reason = "the view doesn't fill its window";
|
||||
}
|
||||
else
|
||||
d.reason = "waiting for the view to fill its window";
|
||||
return d;
|
||||
}
|
||||
// It won't: the swapchain follows the layer after all (below).
|
||||
}
|
||||
else
|
||||
misfitFrames_ = 0;
|
||||
|
||||
const ExtentChoice choice = ChooseExtent(s.surface, s.interface, s.landscapeOnly, rules_.refusePortrait);
|
||||
if (choice.extent != s.swapchain)
|
||||
{
|
||||
d.action = SwapAction::Recreate;
|
||||
d.reason = s.swapchain.Empty() ? "no swapchain"
|
||||
: sizePending_ ? "the size changed"
|
||||
: resumeWatch_ ? "after the resume"
|
||||
: suboptimal_ ? "VK_SUBOPTIMAL_KHR"
|
||||
: "the surface's size moved";
|
||||
d.extent = choice.extent;
|
||||
d.refused = choice.refused;
|
||||
sizePending_ = suboptimal_ = false;
|
||||
refusedFrames_ = choice.refused ? refusedFrames_ + 1 : 0;
|
||||
return d;
|
||||
}
|
||||
sizePending_ = false;
|
||||
if (choice.refused)
|
||||
{
|
||||
// A portrait layer under a landscape-only interface: the swapchain
|
||||
// keeps its landscape size (its SUBOPTIMALs say only that), and the
|
||||
// view is fitted back to its window.
|
||||
suboptimal_ = false;
|
||||
refusedFrames_++;
|
||||
d.refused = true;
|
||||
if (rules_.repairView && (refusedFrames_ <= 3 || (rules_.repairEvery && refusedFrames_ % rules_.repairEvery == 0)))
|
||||
{
|
||||
d.action = SwapAction::RepairView;
|
||||
d.reason = "a portrait surface under a landscape interface";
|
||||
}
|
||||
else
|
||||
d.reason = "a portrait surface refused";
|
||||
return d;
|
||||
}
|
||||
refusedFrames_ = 0;
|
||||
if (suboptimal_)
|
||||
{
|
||||
suboptimal_ = false;
|
||||
// The same size: the drawables or the layer's scale disagree with
|
||||
// it. Build again, but not every frame if that doesn't settle it.
|
||||
if (lastSameRecreate_ == 0 || frame_ - lastSameRecreate_ >= rules_.suboptimalBackoff)
|
||||
{
|
||||
lastSameRecreate_ = frame_;
|
||||
d.action = SwapAction::Recreate;
|
||||
d.reason = "VK_SUBOPTIMAL_KHR at the same size";
|
||||
d.extent = choice.extent;
|
||||
return d;
|
||||
}
|
||||
d.reason = "VK_SUBOPTIMAL_KHR again (backing off)";
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
UiDisplay FitUiDisplay(float windowWidth, float windowHeight, Extent fb)
|
||||
{
|
||||
UiDisplay u;
|
||||
u.width = windowWidth;
|
||||
u.height = windowHeight;
|
||||
if (fb.Empty() || windowWidth <= 0 || windowHeight <= 0)
|
||||
return u;
|
||||
// Pixels per point, whichever way round the two are.
|
||||
const float ppp = float(std::max(fb.width, fb.height)) / std::max(windowWidth, windowHeight);
|
||||
const float w = float(fb.width) / ppp, h = float(fb.height) / ppp;
|
||||
if (std::fabs(w - windowWidth) > 2.0f || std::fabs(h - windowHeight) > 2.0f)
|
||||
{
|
||||
u.width = w;
|
||||
u.height = h;
|
||||
u.fromSwapchain = true;
|
||||
}
|
||||
u.scaleX = float(fb.width) / u.width;
|
||||
u.scaleY = float(fb.height) / u.height;
|
||||
return u;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// When the presenter builds its swapchain again, and at what size; and the
|
||||
// size the settings UI lays out at. Pure decisions (no Vulkan, SDL or UIKit:
|
||||
// presenter.cpp feeds them what it reads), tested with planted wrong rules in
|
||||
// tests/swapchain_policy_test.cpp. Main thread.
|
||||
//
|
||||
// Why (v0.1.2, an iPhone 18 Pro Max on iOS 27): after an iOS screenshot the
|
||||
// game's picture sat in a corner of the screen and the settings UI was blown
|
||||
// up. SDL 3.4.16 sets its view's frame itself whenever a keyboard shows or
|
||||
// hides (-[SDL_uikitviewcontroller updateKeyboard], UIKit_ComputeViewFrame),
|
||||
// swapping width and height when it believes the interface is portrait:
|
||||
// iOS 27's UIApplication.statusBarOrientation is "unknown" under the scene
|
||||
// life cycle, and SDL thinks a resizable window may be portrait whatever the
|
||||
// Info.plist says. The view became 440x956 points in a 956x440 window, its
|
||||
// layer followed, and the presenter rebuilt its swapchain at the layer's
|
||||
// 1320x2868 and never back (nothing moved the view again). So:
|
||||
// - nothing is built while the app is inactive or in the background, and a
|
||||
// size seen then is only a reason to look again once it is active;
|
||||
// - after a resume, a size event or a new swapchain the surface is read
|
||||
// every frame for a while (and once a second always), and the swapchain
|
||||
// follows it only when they differ;
|
||||
// - VK_SUBOPTIMAL_KHR and VK_ERROR_OUT_OF_DATE_KHR are reasons to build
|
||||
// again (a SUBOPTIMAL at an unchanged size at most once per backoff);
|
||||
// - on iOS a view that doesn't fill its window is fitted back first (the
|
||||
// surface is its layer: following it would follow SDL's mistake);
|
||||
// - a landscape-only app (SpeedBreaker on iPhone) never takes a portrait
|
||||
// swapchain while its interface is landscape;
|
||||
// - the UI lays out at the swapchain's size in points, not a window size
|
||||
// that disagrees with it.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace video
|
||||
{
|
||||
struct Extent
|
||||
{
|
||||
uint32_t width = 0, height = 0;
|
||||
bool Empty() const { return width == 0 || height == 0; }
|
||||
bool Portrait() const { return height > width; }
|
||||
friend bool operator==(const Extent&, const Extent&) = default;
|
||||
};
|
||||
|
||||
enum class Orientation : uint8_t
|
||||
{
|
||||
Unknown,
|
||||
Landscape,
|
||||
Portrait,
|
||||
};
|
||||
const char* OrientationName(Orientation orientation);
|
||||
|
||||
// The size to build a swapchain at for a surface of `surface`. A
|
||||
// landscape-only app refuses a portrait surface unless its interface is
|
||||
// portrait too: then it takes the same size turned to landscape (which
|
||||
// is what its view's layer measures once fitted back to its window).
|
||||
struct ExtentChoice
|
||||
{
|
||||
Extent extent;
|
||||
bool refused = false; // `surface` itself was refused
|
||||
};
|
||||
ExtentChoice ChooseExtent(Extent surface, Orientation interface, bool landscapeOnly, bool refusePortrait = true);
|
||||
|
||||
// What vkAcquireNextImageKHR or vkQueuePresentKHR said, other than success.
|
||||
enum class SurfaceResult : uint8_t
|
||||
{
|
||||
Suboptimal,
|
||||
OutOfDate,
|
||||
};
|
||||
|
||||
// What the presenter read this frame.
|
||||
struct SurfaceState
|
||||
{
|
||||
bool active = true; // the app is in front (not inactive, not in the background)
|
||||
bool surfaceRead = false; // `surface` (and the fields after it) were read this frame (WantsSurface)
|
||||
Extent surface; // the surface's extent now (Vulkan's currentExtent, else the window's pixels)
|
||||
Extent swapchain; // the current swapchain's (0x0: none)
|
||||
Orientation interface = Orientation::Unknown; // the interface's (iOS: the window scene's), Unknown elsewhere
|
||||
bool landscapeOnly = false; // the app shows only in landscape here (iPhone)
|
||||
bool viewFitsWindow = true; // iOS: SDL's view fills its UIWindow (always elsewhere)
|
||||
};
|
||||
|
||||
enum class SwapAction : uint8_t
|
||||
{
|
||||
None,
|
||||
Recreate, // build the swapchain again, at `extent`
|
||||
RepairView, // fit the view to its window (iOS), then look again next frame
|
||||
};
|
||||
|
||||
struct SwapDecision
|
||||
{
|
||||
SwapAction action = SwapAction::None;
|
||||
const char* reason = "";
|
||||
Extent extent; // Recreate: the size (ChooseExtent's)
|
||||
bool refused = false; // the surface's own size was refused
|
||||
bool settled = false; // the watch after a resume ended this frame: the presenter logs where it stands
|
||||
};
|
||||
const char* SwapActionName(SwapAction action);
|
||||
|
||||
// The rules, a field each, so the tests can plant a wrong one.
|
||||
struct SwapchainRules
|
||||
{
|
||||
bool holdWhileInactive = true; // nothing built while the app is inactive or in the background
|
||||
bool refusePortrait = true; // landscape-only: no portrait swapchain under a landscape interface
|
||||
bool repairView = true; // a view that doesn't fill its window is fitted back before anything else
|
||||
bool followSuboptimal = true; // VK_SUBOPTIMAL_KHR builds again
|
||||
uint32_t settleFrames = 120; // frames the surface is read every frame after a resume, a size event or a new swapchain
|
||||
uint32_t periodFrames = 60; // otherwise once every this many frames
|
||||
uint32_t suboptimalBackoff = 30; // a SUBOPTIMAL at an unchanged size builds again at most once per this many frames
|
||||
uint32_t repairEvery = 30; // after the first three, a repair is tried once per this many frames
|
||||
uint32_t repairPatience = 180; // a view that won't fit: after this many frames the swapchain follows its layer anyway
|
||||
};
|
||||
|
||||
class SwapchainPolicy
|
||||
{
|
||||
public:
|
||||
explicit SwapchainPolicy(SwapchainRules rules = {}) : rules_(rules) {}
|
||||
|
||||
void Suspended(); // the app went inactive (or to the background)
|
||||
void Resumed(); // it is active again: watch the surface
|
||||
void SizeChanged(); // a window, pixel-size or view-size event
|
||||
void Reported(SurfaceResult result);
|
||||
void Created(Extent extent); // a swapchain was built (on any path)
|
||||
|
||||
// Whether to read the surface (and on iOS the view) this frame.
|
||||
bool WantsSurface() const;
|
||||
// Once a frame, before acquiring an image.
|
||||
SwapDecision Decide(const SurfaceState& state);
|
||||
|
||||
const SwapchainRules& Rules() const { return rules_; }
|
||||
|
||||
private:
|
||||
SwapchainRules rules_;
|
||||
uint64_t frame_ = 0; // active frames decided
|
||||
uint32_t settleLeft_ = 0; // frames left reading the surface every frame
|
||||
bool sizePending_ = false;
|
||||
bool suboptimal_ = false;
|
||||
bool outOfDate_ = false;
|
||||
bool resumeWatch_ = false; // settleLeft_ counts down from a resume
|
||||
uint32_t misfitFrames_ = 0; // frames the view hasn't filled its window
|
||||
uint32_t refusedFrames_ = 0; // frames the surface has been refused
|
||||
uint64_t lastSameRecreate_ = 0; // frame_ of the last SUBOPTIMAL rebuild at an unchanged size (0: none)
|
||||
};
|
||||
|
||||
// The UI's display size (points) and framebuffer scale for a swapchain
|
||||
// of `framebuffer` pixels, given the window's size in points. While they
|
||||
// agree (to 2 points) the window's size stands, as before; when they
|
||||
// don't (a view SDL turned, or a swapchain kept while it is fitted back)
|
||||
// the swapchain's own shape wins, at the window's pixels per point.
|
||||
struct UiDisplay
|
||||
{
|
||||
float width = 0, height = 0; // ImGui's DisplaySize
|
||||
float scaleX = 1, scaleY = 1; // DisplayFramebufferScale
|
||||
bool fromSwapchain = false; // the window's size was overruled
|
||||
};
|
||||
UiDisplay FitUiDisplay(float windowWidth, float windowHeight, Extent framebuffer);
|
||||
}
|
||||
+157
-20
@@ -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
@@ -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))"
|
||||
|
||||
|
||||
Executable
+184
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Executable
+152
@@ -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 ]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user