Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c206ab98 | ||
|
|
b49fc321d1 | ||
|
|
34e1f75901 | ||
|
|
e128baca5e | ||
|
|
a4a72ce103 |
No files matched your search
@@ -4,6 +4,178 @@ 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.4
|
||||
|
||||
### What's new
|
||||
|
||||
- The picture now matches a real Xbox 360 on a TV: slightly deeper shadows and slightly brighter
|
||||
mid-tones. The game sets a gamma ramp for the console's TV output, and SpeedBreaker never
|
||||
applied it until now. The new Settings > Display > Xbox 360 Display Gamma is on by default, also
|
||||
after an update: to get the previous look back, turn it off.
|
||||
- Settings > Display > Brightness: lighter or darker shadows and mid-tones, from -5 to +5, a
|
||||
gentle step at a time, like a TV's brightness. Black and white stay put, and only the game's
|
||||
picture changes, never the menus. At 0, the default, it leaves the picture as it is.
|
||||
- The game's own Brightness slider (pause menu > Options > Video) now changes the picture as you
|
||||
move it, as on an Xbox 360: the game writes a new gamma ramp for each step, and SpeedBreaker
|
||||
applies it. It needs Settings > Display > Xbox 360 Display Gamma, which is on by default; with
|
||||
that off it does nothing, as before. If you moved the slider in an earlier version and the game
|
||||
has saved since, that brightness now takes effect: a slider left at maximum gives a much
|
||||
brighter, washed-out picture, as a 360 with that save would show. Set the slider back to the
|
||||
middle, or press Y (Defaults) and choose Yes. Thanks to m9cy884mv6-a11y for the report (GitHub
|
||||
issue #18).
|
||||
- Keyboard: the right stick has keys, O and L for up and down, K and ; for left and right. The
|
||||
game shifts gears with the right stick, so you can now shift from the keyboard and finish a
|
||||
drag race. Thanks to love-gamer, who wrote it (GitHub pull request #11).
|
||||
- Settings > Quit Game, at the bottom of the menu after Close: quits SpeedBreaker with a controller,
|
||||
the mouse or the keyboard, so a fullscreen game no longer needs a keyboard shortcut to leave. It
|
||||
asks first, with Cancel chosen, so no single press quits. Not on iPhone and iPad, where apps are
|
||||
closed from the app switcher.
|
||||
- Settings > Display > Fullscreen now decides how the game starts, every time. On a Mac, the
|
||||
window's green button (or Ctrl+Cmd+F) switches fullscreen until you quit, so the game no longer
|
||||
sometimes starts fullscreen and sometimes not.
|
||||
- Mac: the mouse pointer no longer sits over the game. It hides after two seconds without moving
|
||||
while you play, and comes back as soon as you move the mouse; in the settings menu and the
|
||||
installer it stays. Thanks to DoubleDips for the report (GitHub issue #15).
|
||||
- Mac: smoother frame pacing. The game's 60 Hz clock now keeps time on macOS (it ran up to 9 ms
|
||||
late), and on a MacBook Pro's 120 Hz ProMotion screen each frame now follows the last by 1/60 s
|
||||
instead of arriving whenever the GPU finished it. Other screens show frames as before, and the
|
||||
timing turns itself off if a screen doesn't keep it.
|
||||
- On slow GPUs, below 15 fps, the game no longer runs a few percent slow: its clock now keeps up
|
||||
with the screen (a laptop with Intel graphics at Internal Resolution 1.5x, about 12 fps, ran 4%
|
||||
slow).
|
||||
- Changing the window's shape while a movie plays (for example the green button during the
|
||||
opening movie) no longer shows a stray "EVENT HANDLER" label above the picture.
|
||||
|
||||
### More about it
|
||||
|
||||
- The gamma ramp: the game sets it when it starts, and the console applies it to every frame on
|
||||
its way to the TV. Two real Xbox 360 recordings of the intro movie and the title screen match it
|
||||
to within half a level, against about two levels without it. With Xbox 360 Display Gamma off
|
||||
and Brightness at 0, the picture is exactly the one of earlier versions.
|
||||
- The game's slider: each step writes a new ramp, which shows on that frame. At its lowest, its
|
||||
default and its highest, the picture's average brightness moves by about -13, +1 and +24 levels
|
||||
(of 255) against Xbox 360 Display Gamma off. The game keeps the slider in your alias's save the
|
||||
next time it saves the profile; without an alias it starts at the middle every time.
|
||||
- The right-stick keys: holding both keys of a pair centres the stick. No other key changed.
|
||||
- Fullscreen: a switch the game didn't ask for (on a Mac, the green button or Ctrl+Cmd+F) lasts
|
||||
until you quit and never changes the setting. While the window differs from the setting,
|
||||
Settings > Display > Fullscreen says NOW FULLSCREEN or NOW WINDOWED; choosing On or Off there
|
||||
saves it and switches the window.
|
||||
- The pointer hides only over the game's window, so in a window it still shows over the desktop,
|
||||
and the mouse is never captured.
|
||||
- The pacing: macOS let the thread that drives the game's 60 Hz clock wake up to 9 ms late; it
|
||||
now runs with real-time priority. And a ProMotion screen shows each frame as soon as it's ready,
|
||||
so frames that took a little longer to draw arrived unevenly: SpeedBreaker now asks for each
|
||||
frame to be shown at a set time after its tick. Measured on a MacBook Pro (M1 Pro) in
|
||||
fullscreen: 99.9% of frames 1/60 s apart in menus and about 94% while driving (99-100% on the
|
||||
open road, less in the heaviest scenes, where a frame takes longer than the time allowed),
|
||||
against about a quarter without it, for about 5 ms more latency.
|
||||
- The slow clock: when a frame took longer than six screen refreshes, SpeedBreaker started its
|
||||
count over and dropped up to three refreshes of game time each time; below 15 fps that happened
|
||||
every second or so. That time is now given back over the next frames. Above 20 fps nothing
|
||||
changes. Checked by replaying frame timings recorded on that laptop: game time went from 96% to
|
||||
99.9% of real time.
|
||||
- The label: it belongs to a menu the game keeps just above the picture. After a window change
|
||||
mid-movie the game kept the old window's layout for it, so the label slipped into view; it now
|
||||
gets the new layout at once.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.3
|
||||
|
||||
### What's new
|
||||
|
||||
- Smoother driving: no more small hitch every few seconds (the car jumped ahead, then stood
|
||||
still for a frame or two), which frame-time graphs didn't show (GitHub issue #9). Thanks to
|
||||
m9cy884mv6-a11y for the careful report.
|
||||
- Sun shadows of cars, walls and tunnel roofs: cars cast their hard shadow in sunlight again, and
|
||||
walls, barriers and tunnel roofs shade the road and the cars, as on the Xbox 360. Inside tunnels
|
||||
the car no longer flickers between sunlight and tree shadows (GitHub issue #3). Thanks to the
|
||||
players who reported the flicker on GitHub and the missing car shadows on Reddit.
|
||||
- Nitrous light trails are as long as on the Xbox 360 again: at 60 fps they were half as long, and
|
||||
looked brighter (GitHub issue #10). Thanks to jaswinprakash for the report, and to victorgbd,
|
||||
whose Android port of another Most Wanted recompilation found the cause.
|
||||
- Pause menu > Performance Tuning: each slider has its own name again (Steering, Handling, Brakes,
|
||||
Ride Height, Aerodynamics, Nitrous, Turbo or Supercharger) instead of "Steering" on every row
|
||||
(GitHub issue #8).
|
||||
|
||||
### More about it
|
||||
|
||||
- The hitch: the game's main loop runs a couple of frames ahead of the screen. When one of its
|
||||
frames took longer, SpeedBreaker gave it two or three steps of game time and the next ones
|
||||
none, while the screen still showed one frame per refresh. A frame the screen shows for one
|
||||
refresh now runs one step. The game's speed is unchanged.
|
||||
- The shadows: the game draws cars, walls and tunnel roofs into its sun shadow map as plain solid
|
||||
shapes, which on the Xbox 360 run no pixel shader. SpeedBreaker ran the trees' leaf-cutting
|
||||
shader on them too, and it threw them all away. Checked against real Xbox 360 footage of the
|
||||
same race. Sunlit roads are as bright as in v0.1.1 and v0.1.2.
|
||||
- The light trails: the game draws them from where the car was over its last two frames, and the
|
||||
Xbox 360 races at 30 fps, so at 60 fps they covered half the distance. They now span the same
|
||||
time as on the 360. At Frame Rate 30 nothing changes.
|
||||
- The tuning labels: the recompiled code looked up each row's name in a table that had lost all
|
||||
but its first entry.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.2
|
||||
|
||||
### What's new
|
||||
|
||||
- Internal Resolution 1.5x (1080p), in Settings > Graphics: a much sharper picture than 1x, with
|
||||
a little over half the pixels of 2x: for a GPU with room above 1x but not for 2x. Thanks to the
|
||||
Reddit player who asked for it.
|
||||
- Laptops with two GPUs: the game now runs on the dedicated graphics card (NVIDIA or AMD), not on
|
||||
the one built into the processor. On Linux with NVIDIA's driver, the launcher also turns on
|
||||
NVIDIA's PRIME render offload: thanks to lydacious for the idea and the first version (GitHub
|
||||
pull request #6).
|
||||
- Portable mode on Steam Deck, Steam Machine and Linux: put a file named `portable.txt` next to
|
||||
`speedbreaker.sh`, and SpeedBreaker keeps your settings, saves, logs and caches in that folder,
|
||||
and installs the game there too. Move or copy the whole folder to another machine and play on
|
||||
(GitHub issue #1).
|
||||
- Taller and wider screens: the sun's glare is round again (it was stretched up to 1.33 times
|
||||
taller on the Steam Deck, 4:3 and 3:2, and wider on ultrawides); no faint line of a letterboxed
|
||||
movie shows in the black past the 16:9 picture; and the race results screen is black past its
|
||||
16:9 picture, as on a 16:9 TV, instead of showing the road behind it.
|
||||
- iPhone: after a screenshot, the picture no longer shrinks into a corner of the screen with the
|
||||
settings menu blown up.
|
||||
- Experimental: an IPA for iPhone and iPad, to sideload with your own Apple Account.
|
||||
|
||||
### More about it
|
||||
|
||||
- 1.5x renders 2.25 times the pixels of 1x, where 2x renders 4 times. Auto still chooses between
|
||||
whole scales (1x, 2x or 3x) as before.
|
||||
- The game picks a dedicated (discrete) GPU over a built-in (integrated) one, and of two alike the
|
||||
one with more video memory. The log's `[video] Vulkan devices:` line lists every GPU it found,
|
||||
the one it uses, and why. To use another, put `NFSMW_GPU=<its number or part of its name>` in
|
||||
the launch options, e.g. `NFSMW_GPU=intel %command%`. On the Steam Deck, Steam Machine, Steam
|
||||
Frame, a Mac, an iPhone or an iPad (one GPU each) nothing changes. The launcher sets
|
||||
`__NV_PRIME_RENDER_OFFLOAD=1` only on a PC where NVIDIA's driver runs one GPU beside another and
|
||||
NVIDIA's Vulkan driver is installed, and keeps a value you set yourself.
|
||||
- `speedbreaker.sh --portable on` copies what you have now (settings, saves and the installed game)
|
||||
into the folder and turns portable mode on; `--portable off` copies them back. Both copy and
|
||||
never delete: your old files stay until you remove them. Not on the Mac, where an app can't keep
|
||||
its data inside itself.
|
||||
- The IPA: the game asks iOS for two memory entitlements, and a free Apple Account can give at most
|
||||
one of them, with some tools only: with a free account it may not start, or iOS may close it in
|
||||
busy scenes. scripts/ios/README.md says which tools keep what. When iOS refuses the memory, the
|
||||
game now says why on screen instead of closing.
|
||||
- iPhone and iPad: on iOS 27 the screenshot screen could leave the game laid out as if the phone
|
||||
were held upright, until the game was closed. The game now checks its picture against the
|
||||
screen after coming back from the screenshot screen, the app switcher or Control Center, and
|
||||
puts it right if they disagree; an iPhone, which plays only in landscape, never draws upright.
|
||||
- iPhone and iPad: Internal Resolution's Auto now decides chip by chip, and the log says what it
|
||||
chose and why. Every chip still gets 1x (720p) under Auto, the iPhone 18 Pro Max included: in a
|
||||
long race on a charger it got hot even at 1x. 1.5x and 2x are in Settings > Graphics > Internal
|
||||
Resolution. The log also records the battery level and charging state.
|
||||
- After a macOS, SteamOS or graphics driver update, the first drive can stutter for a moment while
|
||||
the game rebuilds its shaders for the new system. It happens once, and the game's log now says
|
||||
so.
|
||||
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
|
||||
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
|
||||
unpack the new download over the old folder.
|
||||
|
||||
## v0.1.1
|
||||
|
||||
### What's new
|
||||
@@ -30,7 +202,7 @@ it's built into the game).
|
||||
already holds files gets a new SpeedBreaker Game folder inside. The game remembers the folder,
|
||||
and says so if its drive isn't connected. On SteamOS the folder picker opens only in Desktop
|
||||
Mode: in Game Mode, switch to Desktop Mode and choose the folder there, or run
|
||||
speedbreaker.sh --install <image> --dest <folder> from Konsole in Desktop Mode (or over SSH).
|
||||
`speedbreaker.sh --install <image> --dest <folder>` from Konsole in Desktop Mode (or over SSH).
|
||||
- Check for Updates asks GitHub whether a newer SpeedBreaker is out. If one is, it shows what
|
||||
changed, opens the download page, and shows that page as a QR code to scan with a phone (for
|
||||
Steam Deck Game Mode and the Steam Frame, where no browser may open). Nothing is checked online
|
||||
|
||||
@@ -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.4 LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<b>An unofficial static recompilation of the Xbox 360 version of <i>Need for Speed: Most Wanted</i> (2005):<br>
|
||||
a native port, not an emulator.<br>
|
||||
Play your own copy on Steam Deck, Steam Machine and Steam Frame (SteamOS), Linux and Mac,<br>
|
||||
or build it yourself for iPhone and iPad.</b>
|
||||
and for iPhone and iPad, sideload the experimental IPA or build it yourself.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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.
|
||||
@@ -69,15 +69,16 @@ and a settings menu you can open mid-race.
|
||||
speed, or *Auto*, which drops to 30 only while a device is hot or can't hold 60.
|
||||
- **The sun glare is back.** The low sun glows in open sky and fades behind buildings, as it did
|
||||
on the console.
|
||||
- **The original look:** sunlit roads and shadows as on the console, correct colours on Apple
|
||||
devices, proper car paint and mirror reflections, and distant textures that don't shimmer. Anti-aliasing, sharpening and up to 16x
|
||||
anisotropic filtering are also available.
|
||||
- **The original look:** sunlit roads and shadows as on the console, the picture a real Xbox 360
|
||||
sends to a TV (the game's own display gamma), correct colours on Apple devices, proper car paint
|
||||
and mirror reflections, and distant textures that don't shimmer. Anti-aliasing, sharpening, a
|
||||
Brightness setting and up to 16x anisotropic filtering are also available.
|
||||
- **A guided installer.** It finds your disc image, checks that it is the right game, and
|
||||
verifies all 49 files as it copies them. An interrupted install never leaves a broken game.
|
||||
- **Your controller, or the keyboard.** Controllers work as an Xbox 360 pad, with hot-plugging
|
||||
and rumble support.
|
||||
- **A settings menu that pauses the game.** Open it with F1, Back + Start, or a three-finger
|
||||
tap. Every change applies at once.
|
||||
tap. Every change applies at once, and *Quit Game* leaves the game from a controller too.
|
||||
- **Suspend and resume on iPhone and iPad.** Switch away and the game freezes; come back and it
|
||||
carries on where it stopped.
|
||||
- **One-button bug reports.** Logs, settings and a screenshot go into one zip file. Nothing is
|
||||
@@ -101,8 +102,9 @@ and a settings menu you can open mid-race.
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Screenshots: SpeedBreaker on a Mac Studio (M4 Max). They are unedited frames from the game's
|
||||
own output, except that the licence plate in the top image is blurred. The game content in them
|
||||
<sub>Screenshots: SpeedBreaker v0.1.2 on a Mac Studio (M4 Max). They are unedited frames from the
|
||||
game's own output, except that the licence plates in the top image and the first race image are
|
||||
blurred. The game content in them
|
||||
belongs to its owners.</sub>
|
||||
|
||||
## Download
|
||||
@@ -111,9 +113,9 @@ belongs to its owners.</sub>
|
||||
|---|---|---|
|
||||
| **Steam Deck** and **Steam Machine** (SteamOS) | [Download][dl-steamos-x86_64]<br>`SpeedBreaker-steamos-x86_64.tar.xz` | Steam Deck OLED (SteamOS 3.9.2). Steam Machine (SteamOS 3.8) on a 3440x1440 ultrawide. The Steam Deck LCD hasn't been tested yet. |
|
||||
| **Steam Frame** (SteamOS, arm64) | [Download][dl-steamos-arm64]<br>`SpeedBreaker-steamos-arm64.tar.xz` | Steam Frame (SteamOS 0.3.0). It plays as a big flat screen in the headset; it is not a VR mode. |
|
||||
| **Linux** (x86-64) | [Download][dl-steamos-x86_64] the x86-64 SteamOS build, `SpeedBreaker-steamos-x86_64.tar.xz`, or [build from source](#building-from-source) | SteamOS only so far. Other distributions and NVIDIA or Intel GPUs are untested: reports welcome. |
|
||||
| **Mac** (Apple silicon) | [Download][dl-macos]<br>`SpeedBreaker-macos.dmg` | MacBook Pro (M1 Pro) and Mac Studio (M4 Max), on macOS 26; 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) | Tested here on SteamOS. Players reported v0.1.1 working on Fedora 44 (GeForce GTX 1080 Ti) and Ubuntu 24.04 (Intel Arc 140T). Other setups: reports welcome. |
|
||||
| **Mac** (Apple silicon) | [Download][dl-macos]<br>`SpeedBreaker-macos.dmg` | MacBook Pro (M1 Pro) on macOS 27 and Mac Studio (M4 Max) on macOS 26; macOS 15.6 (Sequoia) in a virtual machine. There is no Intel Mac build. |
|
||||
| **iPhone** and **iPad** | [Sideload the IPA](#iphone-and-ipad) (experimental), `SpeedBreaker-<version>-ios.ipa` on the [release page][releases], or [build it yourself](#iphone-and-ipad) with Xcode | iPhone 15 Pro Max and iPhone 18 Pro Max (iOS 27). iPad Pro 12.9-inch (M2, iPadOS 27) with a Backbone Pro. Not on the App Store. Those were Xcode-signed builds: the IPA itself hasn't been installed on a device yet. |
|
||||
| **Windows** | Coming soon | |
|
||||
| **Android** | Coming soon | |
|
||||
|
||||
@@ -131,8 +133,9 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
|
||||
- **Steam Deck, Steam Machine and Linux PCs** (the x86-64 SteamOS download): a 64-bit x86 CPU
|
||||
with AVX2 (x86-64-v3): Intel Haswell (2013) or newer, or AMD Excavator, Zen or newer. A
|
||||
Vulkan 1.2 graphics card.
|
||||
- **Steam Frame** (the arm64 SteamOS download): SteamOS on the Steam Frame. The arm64 download
|
||||
is for the Frame only; it hasn't been tested on any other arm64 Linux device.
|
||||
- **Steam Frame** (the arm64 SteamOS download): SteamOS on the Steam Frame, kept up to date
|
||||
(v0.1.2 needs the September 2026 release or later). The arm64 download is for the Frame only;
|
||||
it hasn't been tested on any other arm64 Linux device.
|
||||
- **Mac:** Apple silicon and **macOS 15** (Sequoia) or later. The app won't open on older
|
||||
versions of macOS, and there is no Intel Mac build.
|
||||
- **iPhone and iPad:** iOS or iPadOS 17 or later, on an A15-class chip or newer (iPhone 13 and
|
||||
@@ -146,7 +149,8 @@ should say OK. What changed in each release is in [CHANGELOG.md](CHANGELOG.md).
|
||||
the extracted disc files. No title update is needed. SpeedBreaker doesn't provide game files
|
||||
or disc images, or link to them.
|
||||
2. **Get SpeedBreaker.** [Download it](#download) for Steam Deck, Steam Machine and Steam Frame
|
||||
(SteamOS), Linux or Mac. On iPhone and iPad you build it yourself. Then follow the
|
||||
(SteamOS), Linux or Mac. On iPhone and iPad you sideload the experimental IPA or build it
|
||||
yourself. Then follow the
|
||||
[setup for your platform](#setup-by-platform).
|
||||
3. **Start it and install.** On first run, SpeedBreaker looks for your disc image, checks that
|
||||
it is the right game and version, then copies and verifies every file. This takes a few
|
||||
@@ -212,6 +216,13 @@ right of the screen).
|
||||
**Uninstall:** delete `~/Games/SpeedBreaker/`, `~/.local/share/speedbreaker/` (this deletes your
|
||||
saves too) and `~/.cache/speedbreaker/`, and remove the shortcut from Steam.
|
||||
|
||||
**Portable mode:** to keep everything in the SpeedBreaker folder (saves, settings, logs, shader
|
||||
caches and the installed game), so the whole folder can be moved or copied to another Steam Deck,
|
||||
Steam Machine or Linux PC, make an empty file named `portable.txt` next to `speedbreaker.sh`. That
|
||||
starts fresh; to bring your current settings, saves and game along, run
|
||||
`~/Games/SpeedBreaker/speedbreaker.sh --portable on` in Konsole instead, and `--portable off` to
|
||||
go back. Both copy and never delete. The folder's README says more.
|
||||
|
||||
### Steam Frame
|
||||
|
||||
The Steam Frame has its own download. You set it up on the Frame's desktop: open the
|
||||
@@ -252,14 +263,21 @@ SpeedBreaker plays as a big flat screen in the headset. It is not a VR mode.
|
||||
|
||||
There's no separate desktop-Linux package yet. Try the x86-64 SteamOS download,
|
||||
[`SpeedBreaker-steamos-x86_64.tar.xz`][dl-steamos-x86_64], which brings its own runtime libraries
|
||||
but hasn't been tested outside SteamOS, or [build from source](#building-from-source). Unpack it,
|
||||
and is tested here on SteamOS (players reported v0.1.1 working on Fedora 44 and Ubuntu 24.04), or
|
||||
[build from source](#building-from-source). Unpack it,
|
||||
put your `.iso` in your home folder, Downloads, Desktop, Documents or Games (or on a USB drive),
|
||||
and run `speedbreaker.sh`: always start the game with it, since it picks the libraries the
|
||||
program in `lib/` needs (started on its own, the program fails on most systems with a message
|
||||
such as `GLIBC_2.44 not found`). The game starts in a window; Display > Fullscreen switches to full
|
||||
screen. The installer puts the game in `~/.local/share/speedbreaker/game`, or on a drive, or in
|
||||
any folder you pick with *Choose a folder...* (it needs a file chooser portal or zenity; without
|
||||
one, use `--dest`, below).
|
||||
screen and decides how the game starts. The installer puts the game in
|
||||
`~/.local/share/speedbreaker/game`, or on a drive, or in any folder you pick with *Choose a
|
||||
folder...* (it needs a file chooser portal or zenity; without one, use `--dest`, below).
|
||||
|
||||
On a laptop with two GPUs, SpeedBreaker uses the dedicated graphics card; with NVIDIA's driver the
|
||||
launcher also turns on PRIME render offload. That hasn't been confirmed on a real two-GPU laptop
|
||||
yet: the log's `[video] Vulkan devices:` line says which GPU it chose and why, and a report with
|
||||
that line from yours would help. To pick another, start it with `NFSMW_GPU=<its number or part of
|
||||
its name>`, e.g. `NFSMW_GPU=intel %command%` in Steam's launch options.
|
||||
|
||||
### Mac
|
||||
|
||||
@@ -282,13 +300,26 @@ one, use `--dest`, below).
|
||||
upload it and can remove its files from the Mac to save space.
|
||||
|
||||
**Settings:** **F1** or **Cmd+,**, or Back + Start on a controller. The game starts in a window;
|
||||
Display > Fullscreen switches to full screen. SpeedBreaker needs macOS 15 (Sequoia) or later.
|
||||
Display > Fullscreen switches to full screen and decides how the game starts from then on. The
|
||||
window's green button (or Ctrl+Cmd+F) switches only until you quit. While you play, the mouse
|
||||
pointer hides after two seconds without moving. SpeedBreaker needs macOS 15 (Sequoia) or later.
|
||||
|
||||
### iPhone and iPad
|
||||
|
||||
There's no download for iPhone and iPad, and SpeedBreaker isn't on the App Store. You build it
|
||||
yourself with Xcode on a Mac and install it on your own device, signed with your own Apple
|
||||
developer account: [scripts/ios/README.md](scripts/ios/README.md) walks you through it.
|
||||
SpeedBreaker isn't on the App Store. There are two ways to get it onto your iPhone or iPad:
|
||||
|
||||
- **Sideload the IPA (experimental).** Each release has an IPA, `SpeedBreaker-<version>-ios.ipa`,
|
||||
on the [release page][releases]. A sideloading tool such as AltStore signs it with your own Apple
|
||||
Account and installs it. The game asks iOS for more memory than most apps, and a free Apple
|
||||
Account can't give all of it, so with a free account it may not start, or iOS may close it in
|
||||
busy scenes. When iOS refuses the memory, the game says so on screen. The IPA itself hasn't been
|
||||
installed on a device yet (the tested iPhones and iPad ran the same app built and signed with
|
||||
Xcode): read [Sideloading the IPA](scripts/ios/README.md#sideloading-the-ipa-experimental)
|
||||
first, and please report how it goes, with your device and tool. Coming from a build you made
|
||||
yourself, the IPA installs as a separate app: install the game again from your disc image, and
|
||||
your saves stay with the old app.
|
||||
- **Build it yourself** with Xcode on a Mac, signed with your own Apple developer account:
|
||||
[scripts/ios/README.md](scripts/ios/README.md) walks you through it.
|
||||
|
||||
Once SpeedBreaker is on your device:
|
||||
|
||||
@@ -323,12 +354,15 @@ is B, Square is X and Triangle is Y.
|
||||
| D-pad | D-pad | Arrow keys |
|
||||
| Start | Start / Menu / Options | Enter |
|
||||
| Back | Back / View / Create | Esc |
|
||||
| Right stick, stick clicks | Same on the controller | C (right stick click) |
|
||||
| Right stick | Right stick | O / L up and down, K / ; left and right |
|
||||
| Stick clicks | Press a stick in | C (right) |
|
||||
|
||||
- One player, one controller: the first one connected is used, and you can plug one in at any
|
||||
time. The keyboard always works as well.
|
||||
- Because Back + Start opens the settings, the game sees Back a moment late: a tap arrives when
|
||||
you let go, a hold after a quarter of a second.
|
||||
- The game shifts gears with the right stick, so from the keyboard you shift with O and L.
|
||||
Holding both keys of a pair centres the stick.
|
||||
- There is no button remapping and no mouse control in game.
|
||||
|
||||
**To open the settings:**
|
||||
@@ -346,7 +380,9 @@ is B, Square is X and Triangle is Y.
|
||||
The game pauses while the settings are open. The D-pad (or arrow keys) chooses and changes a row,
|
||||
LB and RB (or Q and E) turn pages, and B (or Esc) closes the menu. On a touchscreen, tap and
|
||||
drag. The mouse works too. Every change applies at once and is saved when you close the menu.
|
||||
Each page has a *Reset page* button.
|
||||
The bottom row has *Reset page* (the page's defaults), *Close*, and *Quit Game*, which asks
|
||||
first, with *Cancel* chosen (not on iPhone and iPad, where apps are closed from the app
|
||||
switcher).
|
||||
|
||||
<details>
|
||||
<summary><b>All settings and their defaults</b></summary>
|
||||
@@ -355,11 +391,17 @@ Each page has a *Reset page* button.
|
||||
|
||||
| Option | Choices | Default |
|
||||
|---|---|---|
|
||||
| Fullscreen | On / Off | Off: a 1280x720 window you can resize. Always on in SteamOS Game Mode. Not shown on iPhone and iPad, which are always full screen. |
|
||||
| Fullscreen | On / Off | Off: a 1280x720 window you can resize. The game always starts as set here; on a Mac, the window's green button switches only until you quit, and the row then says NOW FULLSCREEN or NOW WINDOWED. Always on in SteamOS Game Mode. Not shown on iPhone and iPad, which are always full screen. |
|
||||
| V-Sync | On / Off | On. Off can tear, but shows frames sooner. |
|
||||
| Frame Rate | Auto / 60 fps / 30 fps | 60 fps on most devices. 30 fps keeps the game at full speed. Auto runs at 60, or at a steady 30 while the device is hot or can't hold 60. |
|
||||
| Screen Resolution | Full / 75% | Full. iPhone and iPad only: 75% is slightly softer and saves graphics work. |
|
||||
| Aspect Ratio | Fill Screen / 16:9 | Fill Screen: wider screens get a wider view; 4:3, 3:2 and 16:10 screens (the Steam Deck, many Macs) a taller one. 16:9 always shows the classic picture with bars. |
|
||||
| Brightness | -5 to +5 | 0, the picture as it is. Each step makes the shadows and mid-tones a little lighter or darker, like a TV's brightness; black and white stay put, and the menus never change. |
|
||||
| Xbox 360 Display Gamma | On / Off | On: the gamma ramp the game sets for a real Xbox 360's TV output, with slightly deeper shadows and slightly brighter mid-tones. Off: the flatter picture of v0.1.3 and earlier. |
|
||||
|
||||
The game's own Brightness slider, in the pause menu's Options > Video, works too while Xbox 360
|
||||
Display Gamma is on (with it off, it does nothing). Its middle is the default look; Y (Defaults),
|
||||
then Yes, puts it back.
|
||||
|
||||
**Graphics**
|
||||
|
||||
@@ -370,7 +412,7 @@ Each page has a *Reset page* button.
|
||||
| Sharpening | 0.00 to 1.00 | 0.30 (0 turns it off) |
|
||||
| Anisotropic Filtering | Auto / Off / 2x / 4x / 8x / 16x / Original | Auto: 16x on dedicated graphics cards and M-series iPads, 4x elsewhere. Original uses each texture's own setting, as on the 360. |
|
||||
| Mipmaps | On / Off | On, so distant textures don't shimmer |
|
||||
| Internal Resolution | Auto / 1x (720p) / 2x (1440p) / 3x (2160p) | Auto: enough to cover your screen, within what the device can afford. On iPhone and iPad, Auto always picks 1x. |
|
||||
| Internal Resolution | Auto / 1x (720p) / 1.5x (1080p) / 2x (1440p) / 3x (2160p) | Auto: enough to cover your screen, within what the device can afford (whole scales only: 1x, 2x, 3x, or 3x2 on an ultrawide). On iPhone and iPad, Auto always picks 1x. |
|
||||
|
||||
**Audio:** Master Volume (100%), Mute (Off), and Mute in Background (Off), which silences the
|
||||
game while its window isn't in front.
|
||||
@@ -389,12 +431,20 @@ online, and only when you press it). The page also shows which build you're runn
|
||||
|
||||
- **Run your display at 60 Hz.** On a Steam Deck OLED, set the refresh rate to 60 Hz and leave
|
||||
Steam's Framerate Limit off.
|
||||
- **MacBook Pro with a 120 Hz (ProMotion) screen:** since v0.1.4 SpeedBreaker asks for each frame
|
||||
to reach the screen 1/60 s after the last. Measured in fullscreen on an M1 Pro: 99.9% of frames
|
||||
in menus and about 94% while driving (fewer in the heaviest scenes). Other screens show frames
|
||||
as before.
|
||||
- **Start with Auto.** If busy scenes drop below 60, set Internal Resolution one step lower, or
|
||||
let Frame Rate *Auto* fall back to a steady 30.
|
||||
- **30 fps is a real option.** It's a steady 30 at full game speed, and the smooth choice in the
|
||||
Steam Frame headset.
|
||||
- **Between 1x and 2x,** try 1.5x (1080p): much sharper than 1x, with a little over half the
|
||||
pixels of 2x.
|
||||
- **On iPhone and iPad,** keep Internal Resolution at Auto (1x). On the tested iPad Pro (M2), 2x
|
||||
is too slow for races. Screen Resolution 75% saves more.
|
||||
is too slow for races. The iPhone 18 Pro Max holds 60 fps at 1.5x, but on a cable it reached
|
||||
iOS's "serious" heat level in about nine minutes (1x did in about six), and Frame Rate Auto then
|
||||
drops to 30. Screen Resolution 75% saves more.
|
||||
- **Docked Deck or Steam Machine:** set the shortcut's Game Resolution to Native.
|
||||
- **See what's happening:** Settings > Advanced > Performance Overlay.
|
||||
|
||||
@@ -441,6 +491,11 @@ or later on Apple silicon.
|
||||
**The game stutters on my Steam Deck OLED.** Set the screen to 60 Hz (see
|
||||
[Steam Deck setup](#steam-deck-and-steam-machine)).
|
||||
|
||||
**My laptop runs the game on the slow built-in graphics.** Since v0.1.2 SpeedBreaker is meant to
|
||||
pick the dedicated GPU by itself (not yet confirmed on a real two-GPU laptop). If it doesn't, see
|
||||
the `[video] Vulkan devices:` line in `last-run.log`, set `NFSMW_GPU` (see [Linux](#linux)), and
|
||||
please open an issue with that line.
|
||||
|
||||
**The picture is small or stretched on my TV or monitor in Game Mode.** Set the shortcut's
|
||||
Properties > General > Game Resolution to Native.
|
||||
|
||||
@@ -462,6 +517,17 @@ says so: connect it and start the game again.
|
||||
|
||||
**The car steers by itself.** Raise Settings > Input > Stick Deadzone.
|
||||
|
||||
**The picture looks different since v0.1.4.** v0.1.4 applies the gamma ramp the game sets for a
|
||||
real Xbox 360's TV output: slightly deeper shadows and slightly brighter mid-tones, as on the
|
||||
console. For the previous, flatter picture, turn off Settings > Display > Xbox 360 Display Gamma.
|
||||
For lighter or darker shadows either way, use Settings > Display > Brightness. If the picture is
|
||||
much brighter and washed out, the game's own Brightness slider (pause menu > Options > Video) was
|
||||
probably moved up in an earlier version, where it did nothing, and saved: it now takes effect. Set
|
||||
the slider back to the middle, or press Y (Defaults) and choose Yes.
|
||||
|
||||
**How do I quit in fullscreen without a keyboard?** Open the settings and choose *Quit Game* on
|
||||
the bottom row; it asks first.
|
||||
|
||||
**The game ignores my touches on iPhone or iPad.** The game needs a controller or a keyboard.
|
||||
Touch only works in SpeedBreaker's own screens; a three-finger tap opens the settings.
|
||||
|
||||
@@ -474,7 +540,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 +550,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.4-<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,10 +584,12 @@ native program for x86-64 or arm64. A runtime written for this project stands in
|
||||
overlay are drawn with Dear ImGui.
|
||||
- **Audio:** XMA decoding done the way the hardware does it, frame by frame, with a patched,
|
||||
decoder-only FFmpeg.
|
||||
- **Resolution:** the 3D scene renders at 1x, 2x or 3x; the bloom and haze stay at the original
|
||||
- **Resolution:** the 3D scene renders at 1x, 1.5x, 2x or 3x; the bloom and haze stay at the original
|
||||
resolution, so the game keeps its colour grade.
|
||||
- **Frame pacing:** the game's clock locks to the display's measured refresh (58.5 to 61.5 Hz),
|
||||
one game step per refresh. 30 fps runs two game steps per frame.
|
||||
one game step per refresh. 30 fps runs two game steps per frame. On a Mac the clock's thread
|
||||
runs with real-time priority, and on a 120 Hz ProMotion screen each frame is shown at a set time
|
||||
after its tick (turned off for the session if the screen doesn't keep those times).
|
||||
- **Sun glare:** the game measures the sun with occlusion queries; SpeedBreaker counts them on
|
||||
the GPU.
|
||||
|
||||
@@ -537,6 +607,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`
|
||||
@@ -691,9 +762,9 @@ research.
|
||||
The x86-64 SteamOS download also carries glibc and libstdc++ (LGPL-2.1+; GPL-3.0 with the GCC
|
||||
Runtime Library Exception); the Steam Frame download uses the system's. Full license texts are in
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and [LICENSES/](LICENSES/), and every download
|
||||
carries them too: in `licenses/` in the SteamOS downloads, and in
|
||||
carries them too: in `licenses/` in the SteamOS downloads, in
|
||||
`SpeedBreaker.app/Contents/Resources/licenses` in the Mac app, with each bundled library's own
|
||||
license. The source code of the LGPL and GPL parts in the downloads (FFmpeg, libmspack, and the
|
||||
license, and in `SpeedBreaker.app/licenses` in the iPhone and iPad IPA. The source code of the LGPL and GPL parts in the downloads (FFmpeg, libmspack, and the
|
||||
x86-64 download's glibc and GCC runtime) is published next to the downloads of each release, as
|
||||
`SpeedBreaker-<version>-third-party-source.tar.xz`.
|
||||
|
||||
@@ -715,7 +786,8 @@ Corporation.**
|
||||
"Need for Speed", "Most Wanted" and "Carbon" are trademarks of Electronic Arts Inc. Steam,
|
||||
Steam Deck, Steam Machine, Steam Frame, SteamOS and their logos and icons are trademarks of
|
||||
Valve Corporation. All other trademarks, including Xbox, Xbox 360, PlayStation, Apple, Mac,
|
||||
macOS, iPhone, iPad, iCloud, Xcode, App Store, Backbone, Vulkan, Linux, Windows and Android,
|
||||
macOS, iPhone, iPad, iCloud, Xcode, App Store, Backbone, Vulkan, Linux, Windows, Android, NVIDIA,
|
||||
GeForce, AMD and Intel,
|
||||
belong to their respective owners. They are used here only to describe what SpeedBreaker works
|
||||
with. The game is named only to say which game SpeedBreaker runs; no logos, box art or key art
|
||||
of the game are used. The game content visible in screenshots belongs to its owners.
|
||||
|
||||
@@ -8,7 +8,7 @@ include, software by others under their own licenses, listed here. The license t
|
||||
|
||||
The downloads carry these notices too: the Mac app in `SpeedBreaker.app/Contents/Resources/licenses`,
|
||||
each SteamOS download in its `licenses/` folder (with the license of every library it bundles,
|
||||
collected when the bundle is made).
|
||||
collected when the bundle is made), and the iPhone and iPad IPA in `SpeedBreaker.app/licenses`.
|
||||
|
||||
## Compiled into SpeedBreaker (every platform)
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 531 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 656 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 294 KiB |
@@ -91,14 +91,14 @@ functions = [
|
||||
{ address = 0x822C2068, size = 0xA8 },
|
||||
{ address = 0x822C5AA0, size = 0x9C },
|
||||
{ address = 0x822C5B40, size = 0xA8 },
|
||||
{ address = 0x822C7720, size = 0x68 },
|
||||
{ address = 0x822C7720, size = 0x108 },
|
||||
{ address = 0x822C8630, size = 0x74 },
|
||||
{ address = 0x822C89F8, size = 0x44 },
|
||||
{ address = 0x822C89F8, size = 0x6C },
|
||||
{ address = 0x822CDEB8, size = 0x26C },
|
||||
{ address = 0x822D3B90, size = 0xDC },
|
||||
{ address = 0x822D9A20, size = 0x12C },
|
||||
{ address = 0x822E3D68, size = 0x304 },
|
||||
{ address = 0x822E7CE0, size = 0x40 },
|
||||
{ address = 0x822E7CE0, size = 0xA0 },
|
||||
{ address = 0x822E7D88, size = 0x164 },
|
||||
{ address = 0x822EA348, size = 0x248 },
|
||||
{ address = 0x822EA590, size = 0x7C },
|
||||
|
||||
@@ -2939,15 +2939,6 @@ labels = [
|
||||
0x822C74F4,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C7720
|
||||
r = 11
|
||||
default = 0x822C76E4
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C8638
|
||||
r = 3
|
||||
@@ -2976,15 +2967,6 @@ labels = [
|
||||
0x822C88D4,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C89F8
|
||||
r = 10
|
||||
default = 0x822C89D0
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822CC614
|
||||
r = 11
|
||||
@@ -3277,14 +3259,6 @@ labels = [
|
||||
0x822E7CB0,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822E7CE0
|
||||
r = 3
|
||||
default = 0x822E7BC4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822E7D9C
|
||||
r = 4
|
||||
@@ -13320,6 +13294,51 @@ labels = [
|
||||
0x822C5BD8,
|
||||
]
|
||||
|
||||
# bctr 0x822c7734 in 0x822c7718: absolute table at 0x822c7738, index r6, 10 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C7728
|
||||
r = 6
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
0x822C7788,
|
||||
0x822C779C,
|
||||
0x822C77B0,
|
||||
0x822C77C4,
|
||||
0x822C77D8,
|
||||
0x822C77EC,
|
||||
0x822C7800,
|
||||
0x822C7814,
|
||||
]
|
||||
|
||||
# bctr 0x822c8a0c in 0x822c89f4: absolute table at 0x822c8a10, index r3, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C8A00
|
||||
r = 3
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
0x822C8A5C,
|
||||
0x822C8A3C,
|
||||
0x822C8A44,
|
||||
0x822C8A4C,
|
||||
0x822C8A54,
|
||||
]
|
||||
|
||||
# bctr 0x822e7cf4 in 0x822e7cdc: absolute table at 0x822e7cf8, index r4, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E7CE8
|
||||
r = 4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
0x822E7D20,
|
||||
0x822E7D2C,
|
||||
0x822E7D38,
|
||||
0x822E7D44,
|
||||
0x822E7D50,
|
||||
0x822E7D5C,
|
||||
]
|
||||
|
||||
# bctr 0x822e9a30 in 0x822e9a00: absolute table at 0x822e9a34, index r5, 56 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E9A24
|
||||
|
||||
@@ -131,6 +131,51 @@ labels = [
|
||||
0x822C5BD8,
|
||||
]
|
||||
|
||||
# bctr 0x822c7734 in 0x822c7718: absolute table at 0x822c7738, index r6, 10 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C7728
|
||||
r = 6
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
0x822C7788,
|
||||
0x822C779C,
|
||||
0x822C77B0,
|
||||
0x822C77C4,
|
||||
0x822C77D8,
|
||||
0x822C77EC,
|
||||
0x822C7800,
|
||||
0x822C7814,
|
||||
]
|
||||
|
||||
# bctr 0x822c8a0c in 0x822c89f4: absolute table at 0x822c8a10, index r3, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C8A00
|
||||
r = 3
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
0x822C8A5C,
|
||||
0x822C8A3C,
|
||||
0x822C8A44,
|
||||
0x822C8A4C,
|
||||
0x822C8A54,
|
||||
]
|
||||
|
||||
# bctr 0x822e7cf4 in 0x822e7cdc: absolute table at 0x822e7cf8, index r4, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E7CE8
|
||||
r = 4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
0x822E7D20,
|
||||
0x822E7D2C,
|
||||
0x822E7D38,
|
||||
0x822E7D44,
|
||||
0x822E7D50,
|
||||
0x822E7D5C,
|
||||
]
|
||||
|
||||
# bctr 0x822e9a30 in 0x822e9a00: absolute table at 0x822e9a34, index r5, 56 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E9A24
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,20 +18,28 @@
|
||||
// a step boundary, frames alternate between no step and two.
|
||||
//
|
||||
// Each guest vblank is made one step instead (NFSMW_GAME_TIME=0 turns this
|
||||
// off). A frame is given k/60 s for the k guest vblanks that fired during it
|
||||
// (counted from one frame's start to the next, 1 to 6), as the console's
|
||||
// 60 Hz vblank would have given it; the stepper's elapsed time, then k steps
|
||||
// give or take a tick, is rounded to k. The main loop starts a frame when D3D
|
||||
// lets it, after the vblank that released the last one, so its start can
|
||||
// jitter by most of a vblank without changing the count; a frame slower than
|
||||
// the vblank starts anywhere between two, and its count is its length
|
||||
// rounded either way. A frame no vblank fired in keeps its measured time,
|
||||
// which later frames give back (game/whole_vblanks.h): game time adds up to
|
||||
// the vblanks that fired. It runs at the display's rate over 60: 0.044% slow
|
||||
// on a 59.97 Hz monitor, at most 2.5% (the lock's range). Frames of more
|
||||
// than 6 vblanks (loading) keep their measured time. At Frame Rate 30
|
||||
// (video::GuestVblankDivider) a guest vblank comes every other refresh and
|
||||
// is given 2/60 s: two steps a frame, the game at its speed.
|
||||
// off). A frame is given whole vblanks of 1/60 s, as the console's 60 Hz
|
||||
// vblank would have given it; the stepper's elapsed time, then k steps give
|
||||
// or take a tick, is rounded to k. The main loop runs ahead of the display:
|
||||
// the game's render thread swaps (VdSwap) during the loop's next frame, and
|
||||
// the command processor lets each swap go at a vblank about two frames after
|
||||
// that, so the loop's frames drift against the vblanks (counted from one
|
||||
// frame's start to the next) while the display shows one frame a vblank from
|
||||
// the queue: a frame the loop is slow with counts 2 or 3, the next ones none.
|
||||
// Each is given one vblank while game time stays within a few vblanks of the
|
||||
// vblanks that fired (game/whole_vblanks.h; given their counts, the world
|
||||
// ran 2-3 steps in one displayed frame and none in the next: GitHub issue
|
||||
// #9). Further behind, the queue ran dry and the display waited, and the
|
||||
// frame gets the rest; a frame slower than the vblank (GPU-bound) gets its
|
||||
// count, its length rounded either way. Game time adds up to the vblanks
|
||||
// that fired, to within those few. It runs at the display's rate over 60:
|
||||
// 0.044% slow on a 59.97 Hz monitor, at most 2.5% (the lock's range). Frames
|
||||
// of more than 6 vblanks (a hitch, loading) keep their measured time; below
|
||||
// 20 fps, where they are the frame rate's own, only those over half a second
|
||||
// do, the rest get 6 (the stepper's own cap) and later frames give back what
|
||||
// that leaves owed, so game time keeps up down to 10 fps. At Frame Rate 30
|
||||
// (video::GuestVblankDivider) a guest vblank comes every other refresh and is
|
||||
// given 2/60 s: two steps a frame, the game at its speed.
|
||||
#include <stdafx.h>
|
||||
#include "frame_time.h"
|
||||
#include "whole_vblanks.h"
|
||||
@@ -50,6 +58,8 @@ namespace
|
||||
return on;
|
||||
}();
|
||||
std::atomic<uint64_t> s_frames{ 0 }, s_snapped{ 0 }, s_noStep{ 0 }, s_multiStep{ 0 };
|
||||
// Game time handed to the frames so far (game::FrameClockMs), ms in 16.16.
|
||||
std::atomic<uint64_t> s_clock{ 0 };
|
||||
// NFSMW_GAME_TIME_LOG=<file>: one line per frame, written when the next
|
||||
// one starts: its measured ms, guest vblanks, whether it was given whole
|
||||
// vblanks, the world steps it ran (-1: no world), how long after the last
|
||||
@@ -86,12 +96,23 @@ namespace game
|
||||
return { s_frames.load(std::memory_order_relaxed), s_noStep.load(std::memory_order_relaxed),
|
||||
s_multiStep.load(std::memory_order_relaxed), s_snapped.load(std::memory_order_relaxed) };
|
||||
}
|
||||
|
||||
double FrameClockMs()
|
||||
{
|
||||
return double(s_clock.load(std::memory_order_relaxed)) / 65536.0;
|
||||
}
|
||||
}
|
||||
|
||||
// GameFrameTime, before `bl 0x823AFE20` at 0x823B0148 in the main loop: r3 is
|
||||
// the frame's measured time, milliseconds in 16.16 fixed point.
|
||||
void GameFrameTime(PPCRegister& r3)
|
||||
{
|
||||
// Whatever the frame is given below (or its measured time), on every path.
|
||||
struct AddToClock
|
||||
{
|
||||
PPCRegister& r3;
|
||||
~AddToClock() { s_clock.fetch_add(uint64_t(std::max(int32_t(r3.u32), 0)), std::memory_order_relaxed); }
|
||||
} addToClock{ r3 };
|
||||
static uint64_t last = 0;
|
||||
uint64_t now = video::GuestVblanks(), vblanks = now - last;
|
||||
last = now;
|
||||
|
||||
@@ -15,4 +15,10 @@ namespace game
|
||||
uint64_t frames, noStep, multiStep, snapped;
|
||||
};
|
||||
FrameTimeStats GetFrameTimeStats();
|
||||
|
||||
// The game time the main loop has handed its frames since launch, in ms:
|
||||
// each frame's measured time, or the whole vblanks GameFrameTime gives it
|
||||
// (1/60 s each). It moves once a frame, at the frame's start, paused or
|
||||
// not; the game draws the frame after (the nitrous trails, game/light_trails.h).
|
||||
double FrameClockMs();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The front end's matrix and the game's cache of it, after the window
|
||||
// changes shape (game/ultrawide.cpp's UltrawideFrontEnd).
|
||||
//
|
||||
// The front-end builder (sub_82440D80) makes the HUD and menu matrix on its
|
||||
// stack (x 0.92 x 0.75, y 0.92 for the 16:9 frame; the hook narrows them by
|
||||
// the view scales) and hands it to sub_82453768 with the view entry
|
||||
// (0x82C3F590) and an object. That function keeps the three pointers of its
|
||||
// last call at 0x82A2D190 (view), +4 (matrix) and +8 (object) and returns
|
||||
// at once when a call brings the same three: on the 360 the front end's
|
||||
// matrix never changes, so the same address means the same matrix. In menus
|
||||
// and races the game's other views go through it every frame, so the front
|
||||
// end's call never repeats the last one; through the boot movies (the EA
|
||||
// logos, the PSA, the attract movie) nothing else does. So when the window
|
||||
// changed shape during them (the green button mid-movie), the front end kept
|
||||
// the matrix narrowed for the old shape: the renderer, which recognises
|
||||
// front-end draws by the matrix the hook made last, left them alone, and what
|
||||
// the game parks just above the 16:9 frame (an "EVENT HANDLER" label at
|
||||
// 1.04..1.09 of the frame's height, right of centre) showed in the rows
|
||||
// Vert+ adds above it.
|
||||
//
|
||||
// The fix: when the hook leaves a matrix other than the one it left last, it
|
||||
// clears the cache's matrix pointer (a real pointer is never 0), so the call
|
||||
// right after it uploads the new matrix. Once per change; a session that
|
||||
// never changes shape uploads exactly as before, past the first frames.
|
||||
//
|
||||
// Header only (tests/front_end_matrix_test.cpp).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace game::front_end
|
||||
{
|
||||
// sub_82453768's cache of its last call: view, matrix, object pointers.
|
||||
constexpr uint32_t kMatrixCache = 0x82A2D190;
|
||||
constexpr uint32_t kMatrixCachePointer = kMatrixCache + 4;
|
||||
|
||||
// The matrix scales the front-end hook left last (its thread only).
|
||||
class MatrixTracker
|
||||
{
|
||||
public:
|
||||
// Whether the matrix (x, y scales) differs from the one left last,
|
||||
// and the first time: the game must upload it again.
|
||||
bool Changed(float x, float y)
|
||||
{
|
||||
const bool changed = !m_seen || x != m_x || y != m_y;
|
||||
m_seen = true;
|
||||
m_x = x;
|
||||
m_y = y;
|
||||
return changed;
|
||||
}
|
||||
|
||||
private:
|
||||
bool m_seen = false;
|
||||
float m_x = 0.0f, m_y = 0.0f;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See light_trails.h.
|
||||
//
|
||||
// One function override (the generated functions are weak; no ppc/
|
||||
// regeneration): sub_82134608, the game's 4x4 matrix copy (r3 = destination,
|
||||
// r4 = source), called from many places. Called from RenderFlares' ring
|
||||
// write (ctx.lr = kRingWriteReturn), it first records the car's new entry
|
||||
// and fills the ring's other two entries with the car 2/60 and 4/60 s of
|
||||
// game time ago; then the copy runs as always.
|
||||
// NFSMW_LIGHT_TRAILS=0 leaves the ring as the game writes it (an A/B switch:
|
||||
// trails half the 360's length at 60 fps).
|
||||
#include <stdafx.h>
|
||||
#include "light_trails.h"
|
||||
#include "frame_time.h"
|
||||
|
||||
#include <kernel/function.h>
|
||||
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
extern "C" PPC_FUNC(__imp__sub_82134608);
|
||||
|
||||
namespace
|
||||
{
|
||||
using namespace game::trails;
|
||||
|
||||
const bool s_enabled = [] {
|
||||
const char* v = std::getenv("NFSMW_LIGHT_TRAILS");
|
||||
const bool on = !(v && v[0] == '0');
|
||||
if (!on)
|
||||
fprintf(stderr, "[trails] NFSMW_LIGHT_TRAILS=0: nitrous light trails as the game draws them at this frame rate "
|
||||
"(the last two frames: half the 360's length at 60 fps)\n");
|
||||
return on;
|
||||
}();
|
||||
|
||||
std::mutex s_mutex;
|
||||
// Per CarRenderInfo (guest address).
|
||||
std::unordered_map<uint32_t, History> s_cars;
|
||||
double s_lastPruneMs = 0.0;
|
||||
|
||||
float LoadFloat(uint8_t* base, uint32_t address)
|
||||
{
|
||||
const uint32_t bits = PPC_LOAD_U32(address);
|
||||
float f;
|
||||
memcpy(&f, &bits, sizeof(f));
|
||||
return f;
|
||||
}
|
||||
|
||||
void StoreFloat(uint8_t* base, uint32_t address, float f)
|
||||
{
|
||||
uint32_t bits;
|
||||
memcpy(&bits, &f, sizeof(bits));
|
||||
PPC_STORE_U32(address, bits);
|
||||
}
|
||||
|
||||
void StoreEntry(uint8_t* base, uint32_t info, uint32_t slot, const Entry& e)
|
||||
{
|
||||
const uint32_t pos = info + kPositions + slot * kPositionBytes;
|
||||
for (uint32_t k = 0; k < 3; k++)
|
||||
StoreFloat(base, pos + k * 4, e.pos[k]);
|
||||
memcpy(base + info + kMatrices + slot * kMatrixBytes, e.matrix.data(), kMatrixBytes);
|
||||
}
|
||||
|
||||
void RingWrite(PPCContext& ctx, uint8_t* base)
|
||||
{
|
||||
const uint32_t info = ctx.r30.u32;
|
||||
const int32_t newest = int32_t(PPC_LOAD_U32(info + kNewestIndex));
|
||||
// The game's code as read (sub_824E6220): anything else, leave it be.
|
||||
if (newest < 0 || newest > 2 || ctx.r3.u32 != info + kMatrices + uint32_t(newest) * kMatrixBytes)
|
||||
{
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [&] {
|
||||
fprintf(stderr, "[trails] unexpected ring write (info %08X index %d destination %08X): trails left as the game draws them\n",
|
||||
info, newest, ctx.r3.u32);
|
||||
});
|
||||
return;
|
||||
}
|
||||
Entry now;
|
||||
const uint32_t stack = ctx.r1.u32 + kNewPositionOnStack;
|
||||
for (uint32_t k = 0; k < 3; k++)
|
||||
now.pos[k] = LoadFloat(base, stack + k * 4);
|
||||
memcpy(now.matrix.data(), base + ctx.r4.u32, kMatrixBytes);
|
||||
|
||||
const double ms = game::FrameClockMs();
|
||||
Fill fill;
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
// Cars gone for a second are forgotten (CarRenderInfos are freed
|
||||
// and reused).
|
||||
if (ms - s_lastPruneMs > 1000.0 || ms < s_lastPruneMs)
|
||||
{
|
||||
std::erase_if(s_cars, [&](const auto& kv) { return ms - kv.second.NewestMs() > 1000.0; });
|
||||
s_lastPruneMs = ms;
|
||||
}
|
||||
History& h = s_cars[info];
|
||||
h.Add(ms, now);
|
||||
fill = FillFor(h, ms);
|
||||
}
|
||||
const uint32_t slot = uint32_t(newest);
|
||||
if (fill.hasMiddle)
|
||||
StoreEntry(base, info, MiddleSlot(slot), fill.middle);
|
||||
if (fill.hasOldest)
|
||||
StoreEntry(base, info, OldestSlot(slot), fill.oldest);
|
||||
if (fill.hasMiddle && fill.hasOldest)
|
||||
{
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [] {
|
||||
fprintf(stderr, "[trails] nitrous light trails at the 360's length: the car's ring holds it now and %.1f and %.1f ms "
|
||||
"of game time ago, as at the console's 30 fps (NFSMW_LIGHT_TRAILS=0: the game's last two frames)\n",
|
||||
kConsoleFrameMs, 2.0 * kConsoleFrameMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PPC_FUNC(sub_82134608)
|
||||
{
|
||||
if (s_enabled && static_cast<uint32_t>(ctx.lr) == kRingWriteReturn)
|
||||
RingWrite(ctx, base);
|
||||
__imp__sub_82134608(ctx, base);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The nitrous light trails at the length the Xbox 360 draws them, at any
|
||||
// frame rate (game/light_trails.cpp hooks the game; tests/light_trails_test.cpp
|
||||
// checks this header alone). GitHub issue #10.
|
||||
//
|
||||
// The trail is not an effect on the picture: VehicleRenderConn::RenderFlares
|
||||
// (sub_824E6220, called per view) keeps, for every car it draws in a player
|
||||
// view (the view's id at +4 is 1 or 2) and not in a reflection, the car's
|
||||
// render matrix and position in a ring of three in its CarRenderInfo (the
|
||||
// connection's +68): the matrices at +4512 (64 bytes each), the positions at
|
||||
// +4704 (16 bytes each, x y z used), the newest one's index at +4752. It
|
||||
// advances the index and writes the newest entry once a call, so once a
|
||||
// frame. Then, when the car's nitrous is on (CarRenderInfo +4492) and it
|
||||
// moves (faster than 0.2 in the decompilation), it draws the car's light
|
||||
// flares 8 times along each of the three segments between the entries
|
||||
// (oldest to middle, newest to oldest, middle to newest; sub_824DF658 with
|
||||
// flags 8 and 2), each with the matrix of the segment's start. So the trail
|
||||
// is where the car was over the last two frames, 24 flares.
|
||||
//
|
||||
// A real Xbox 360 runs the race at 30 fps (a 60 fps capture of one shows every
|
||||
// race frame twice, now and then three or four times), so its trail covers
|
||||
// 4/60 s of driving. At SpeedBreaker's 60 fps it covered 2/60 s: half as long,
|
||||
// with the same 24 flares packed into half the distance, so brighter.
|
||||
//
|
||||
// The fix keeps the game's code and its ring, and changes what is in the two
|
||||
// entries the game doesn't write: at the ring write (the matrix copy
|
||||
// sub_82134608 called from 0x824E63D0, after the index has moved and before
|
||||
// any flare is drawn), the middle entry gets the car's position and matrix of
|
||||
// 2/60 s of game time ago and the oldest those of 4/60 s ago, from a history
|
||||
// of what the game wrote; the game then writes the newest as before. Game
|
||||
// time is the time the main loop hands its frames (game::FrameClockMs: whole
|
||||
// vblanks, so host timing jitter doesn't move it), and a paused game
|
||||
// collapses the trail onto the car as a 360 does. At 30 fps those times are
|
||||
// the frames the game wrote last and the one before (nothing changes); at 60
|
||||
// fps the frames 2 and 4 before this one. When the history doesn't reach back
|
||||
// that far (a car just drawn for the first time, or again after a gap), the
|
||||
// game's own entry stays.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace game::trails
|
||||
{
|
||||
// ctx.lr when RenderFlares calls the matrix copy that writes the newest
|
||||
// entry (bl 0x82134608 at 0x824E63D0; r3 = the entry, r4 = the matrix,
|
||||
// r30 = the CarRenderInfo). The position is stored right after it, from
|
||||
// the caller's r1 + 96.
|
||||
constexpr uint32_t kRingWriteReturn = 0x824E63D4;
|
||||
constexpr uint32_t kNewPositionOnStack = 96;
|
||||
// CarRenderInfo's ring.
|
||||
constexpr uint32_t kMatrices = 4512, kMatrixBytes = 64;
|
||||
constexpr uint32_t kPositions = 4704, kPositionBytes = 16;
|
||||
constexpr uint32_t kNewestIndex = 4752;
|
||||
|
||||
// One vblank of game time, a world step; the 360's race frame is two.
|
||||
constexpr double kVblankMs = 1000.0 / 60.0;
|
||||
constexpr double kConsoleFrameMs = 2.0 * kVblankMs;
|
||||
// A sample this close to the wanted time is that time: game time is
|
||||
// summed from 16.16 ms values, each rounded by at most 1/131072 ms.
|
||||
constexpr double kSnapMs = 0.01;
|
||||
// A car not drawn for this long starts its history over (a new car in a
|
||||
// reused CarRenderInfo, or one back after a while off screen).
|
||||
constexpr double kGapMs = 250.0;
|
||||
|
||||
// The ring entries around the newest (the index the game just moved to).
|
||||
constexpr uint32_t MiddleSlot(uint32_t newest) { return (newest + 2) % 3; } // the console's last frame
|
||||
constexpr uint32_t OldestSlot(uint32_t newest) { return (newest + 1) % 3; } // the one before
|
||||
|
||||
// One ring entry, the matrix as the game's bytes (copied, never read).
|
||||
struct Entry
|
||||
{
|
||||
float pos[3] = {};
|
||||
std::array<uint8_t, kMatrixBytes> matrix{};
|
||||
};
|
||||
|
||||
// What the game wrote for one car, at the game times it wrote it.
|
||||
class History
|
||||
{
|
||||
public:
|
||||
static constexpr size_t kCapacity = 32; // reaches 4/60 s back at up to 465 fps
|
||||
|
||||
bool Empty() const { return count_ == 0; }
|
||||
size_t Size() const { return count_; }
|
||||
double NewestMs() const { return count_ ? At(count_ - 1).ms : 0.0; }
|
||||
|
||||
void Clear() { count_ = 0; }
|
||||
|
||||
// The entry the game wrote at `ms`. A second write at the same time
|
||||
// (the same frame) replaces the first; time going back, or a gap of
|
||||
// kGapMs, starts over.
|
||||
void Add(double ms, const Entry& e)
|
||||
{
|
||||
if (count_)
|
||||
{
|
||||
const double newest = NewestMs();
|
||||
if (std::abs(ms - newest) <= 1e-6)
|
||||
{
|
||||
Mutable(count_ - 1) = { ms, e };
|
||||
return;
|
||||
}
|
||||
if (ms < newest || ms - newest > kGapMs)
|
||||
count_ = 0;
|
||||
}
|
||||
if (count_ == kCapacity)
|
||||
{
|
||||
first_ = (first_ + 1) % kCapacity;
|
||||
count_--;
|
||||
}
|
||||
Mutable(count_++) = { ms, e };
|
||||
}
|
||||
|
||||
// The car at game time `ms`: a sample within kSnapMs of it as it was
|
||||
// written, else the position between the two samples around it and
|
||||
// the nearer one's matrix. False when the history doesn't reach back
|
||||
// to `ms` (or `ms` is past the newest sample).
|
||||
bool Get(double ms, Entry& out) const
|
||||
{
|
||||
if (!count_ || ms < At(0).ms - kSnapMs || ms > NewestMs() + kSnapMs)
|
||||
return false;
|
||||
for (size_t i = count_; i-- > 0;)
|
||||
{
|
||||
const Sample& s = At(i);
|
||||
if (std::abs(s.ms - ms) <= kSnapMs)
|
||||
{
|
||||
out = s.e;
|
||||
return true;
|
||||
}
|
||||
if (s.ms < ms && i + 1 < count_)
|
||||
{
|
||||
const Sample& n = At(i + 1);
|
||||
const double f = (ms - s.ms) / (n.ms - s.ms);
|
||||
for (int k = 0; k < 3; k++)
|
||||
out.pos[k] = float(double(s.e.pos[k]) + (double(n.e.pos[k]) - double(s.e.pos[k])) * f);
|
||||
out.matrix = f < 0.5 ? s.e.matrix : n.e.matrix;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Sample
|
||||
{
|
||||
double ms = 0.0;
|
||||
Entry e;
|
||||
};
|
||||
const Sample& At(size_t i) const { return samples_[(first_ + i) % kCapacity]; }
|
||||
Sample& Mutable(size_t i) { return samples_[(first_ + i) % kCapacity]; }
|
||||
|
||||
std::array<Sample, kCapacity> samples_{};
|
||||
size_t first_ = 0, count_ = 0;
|
||||
};
|
||||
|
||||
// What to put in the two entries the game doesn't write, given the car's
|
||||
// history (with the newest entry already added at `nowMs`): the middle
|
||||
// one 2/60 s back, the oldest 4/60 s back. Each `has` is false where the
|
||||
// history doesn't reach (keep the game's entry).
|
||||
struct Fill
|
||||
{
|
||||
bool hasMiddle = false, hasOldest = false;
|
||||
Entry middle, oldest;
|
||||
};
|
||||
inline Fill FillFor(const History& h, double nowMs)
|
||||
{
|
||||
Fill f;
|
||||
f.hasMiddle = h.Get(nowMs - kConsoleFrameMs, f.middle);
|
||||
f.hasOldest = h.Get(nowMs - 2.0 * kConsoleFrameMs, f.oldest);
|
||||
return f;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@
|
||||
// widescreen case of the front-end matrix (sub_82440D80): f13 is its x
|
||||
// scale (0.92 safe area x 0.75, a 4:3 canvas centred in 16:9) and f0 its
|
||||
// y scale (0.92), stored at m00 and m11 (shader constants c0 and c5).
|
||||
// When the matrix it leaves differs from the last (the window changed
|
||||
// shape), it also clears the game's cached matrix pointer, so the new
|
||||
// matrix is uploaded even when nothing else used the cache since
|
||||
// (game/front_end_matrix.h: the boot movies).
|
||||
// - UltrawideMirror, at the branch at 0x8245CED8 that ends the widescreen
|
||||
// case of the rear-view mirror draw (sub_8245CDF0). The mirror is not a
|
||||
// view: its picture is one quad whose pixel shader (ps_40d7f669a326708d)
|
||||
@@ -41,6 +45,8 @@
|
||||
// of the build failing on an undefined symbol.
|
||||
#include <stdafx.h>
|
||||
#include "ultrawide.h"
|
||||
#include "front_end_matrix.h"
|
||||
#include "glare.h"
|
||||
|
||||
#include <kernel/function.h>
|
||||
|
||||
@@ -88,11 +94,12 @@ namespace
|
||||
fprintf(stderr, "[uw] view %u: target %ux%u%s\n", id, w, h, widened ? ", widened" : "");
|
||||
}
|
||||
|
||||
// The view at `view` (guest address) is one the game widens for 16:9.
|
||||
// The view at `view` (guest address) is one the game widens for 16:9
|
||||
// (game/glare.h keeps the set: the sun's glow is fitted in the same views).
|
||||
bool WidenedView(uint32_t view)
|
||||
{
|
||||
uint32_t id = ByteSwap(*static_cast<const uint32_t*>(g_memory.Translate(view + 4)));
|
||||
bool widen = id == 1 || id == 2 || id == 4 || id == 5 || id == 17;
|
||||
bool widen = game::glare::WidenedViewId(id);
|
||||
LogView(id, widen);
|
||||
return widen;
|
||||
}
|
||||
@@ -108,6 +115,30 @@ namespace
|
||||
{
|
||||
f.f64 = double(0.5f + (float(f.f64) - 0.5f) * scale);
|
||||
}
|
||||
|
||||
// The front-end matrix the hook left last (the game's render thread),
|
||||
// and NFSMW_FE_RESEND=0 to leave the game's matrix cache alone (the
|
||||
// matrix then stays narrowed for an old window shape through the boot
|
||||
// movies, as before; an A/B switch).
|
||||
game::front_end::MatrixTracker s_frontEndMatrix;
|
||||
const bool s_frontEndResend = [] {
|
||||
const char* v = std::getenv("NFSMW_FE_RESEND");
|
||||
return !(v && v[0] == '0');
|
||||
}();
|
||||
|
||||
// The hook changed the front end's matrix (a new window shape, or the
|
||||
// first frames): clear sub_82453768's cached matrix pointer so the call
|
||||
// right after the hook uploads it (game/front_end_matrix.h).
|
||||
void ResendIfChanged(float x, float y)
|
||||
{
|
||||
if (!s_frontEndMatrix.Changed(x, y) || !s_frontEndResend)
|
||||
return;
|
||||
*static_cast<uint32_t*>(g_memory.Translate(game::front_end::kMatrixCachePointer)) = 0;
|
||||
static uint32_t logged = 0;
|
||||
if (++logged <= 16)
|
||||
fprintf(stderr, "[uw] front-end matrix x %.4f y %.4f: sent to the game again%s\n", x, y,
|
||||
logged == 16 ? " (no more of these lines)" : "");
|
||||
}
|
||||
}
|
||||
|
||||
namespace game
|
||||
@@ -186,6 +217,7 @@ void UltrawideFrontEnd(PPCRegister& f13, PPCRegister& f0)
|
||||
ScaleRegister(f0, scale.y);
|
||||
s_frontEndX.store(float(f13.f64), std::memory_order_relaxed);
|
||||
s_frontEndY.store(float(f0.f64), std::memory_order_relaxed);
|
||||
ResendIfChanged(float(f13.f64), float(f0.f64));
|
||||
}
|
||||
|
||||
void UltrawideMirror(PPCRegister& f1, PPCRegister& f2, PPCRegister& f3, PPCRegister& f4)
|
||||
@@ -221,6 +253,7 @@ void UltrawideFrontEnd(PPCRegister& f13)
|
||||
if (scale != 1.0f)
|
||||
ScaleRegister(f13, scale);
|
||||
s_frontEndX.store(float(f13.f64), std::memory_order_relaxed);
|
||||
ResendIfChanged(float(f13.f64), 0.0f);
|
||||
}
|
||||
|
||||
void UltrawideMirror(PPCRegister& f1, PPCRegister& f3)
|
||||
|
||||
@@ -10,19 +10,47 @@
|
||||
namespace game
|
||||
{
|
||||
// Game time follows the guest vblanks that fire: over any run of frames
|
||||
// it adds up to them, to within a vblank, and each frame that saw any
|
||||
// gets whole ones (1 to 6), so the fixed 1/60 s world step runs a whole
|
||||
// number of times a frame.
|
||||
// of up to 6 vblanks it adds up to them, to within kSlack vblanks (below
|
||||
// 20 fps, frames of up to kLoadVblanks and kSlack + kMaxOwed), and each
|
||||
// frame that saw any gets whole ones (1 to 6), so the fixed 1/60 s world
|
||||
// step runs a whole number of times a frame.
|
||||
// - A frame the vblank paces (60 fps held, or 30 at Frame Rate 30) saw
|
||||
// one and gets one.
|
||||
// - A frame it doesn't (GPU-bound) starts anywhere between two, so its
|
||||
// count is its length rounded down or up: a 44 ms frame (2.64
|
||||
// refreshes) sees 2 or 3, and gets them.
|
||||
// - A frame no vblank fired in (a vblank thread waking late, as the
|
||||
// Mac's does several ms at a time when its GPU is busy; or two frames
|
||||
// between two vblanks) keeps its measured time, and is then ahead of
|
||||
// the vblanks by that much: the next frames give it back, one whole
|
||||
// vblank fewer when it adds up to one, never fewer than one.
|
||||
// - The main loop runs up to a few frames ahead of the display (its
|
||||
// render thread and the GPU ring queue them), so its own frames
|
||||
// don't line up with the vblanks: a frame the loop is slow with
|
||||
// (streaming, a file read, a page fault) spans 2 vblanks and the next
|
||||
// one, catching up, none, while the display still shows one frame a
|
||||
// vblank from the queue. Each of those frames gets one: game time may
|
||||
// run up to kSlack vblanks behind or ahead of the vblanks that fired.
|
||||
// Until 2026-10-11 they got their own counts (2, then the measured
|
||||
// few ms): the world ran 2 steps in a frame the display showed for
|
||||
// one vblank and none in the next, a hitch every few seconds while
|
||||
// driving that no frame-time graph shows (GitHub issue #9).
|
||||
// - Behind by more than that, the queue ran dry and the display waited
|
||||
// (a frame shown twice): the frame gets the vblanks beyond kSlack,
|
||||
// at most 6, so game time keeps up with the display.
|
||||
// - A frame it doesn't pace (GPU-bound) starts anywhere between two,
|
||||
// so its count is its length rounded down or up: a 44 ms frame (2.64
|
||||
// refreshes) sees 2 or 3, and once game time is kSlack behind (the
|
||||
// first such frame), gets them.
|
||||
// - Ahead by kSlack (more frames than vblanks for a while: the queue
|
||||
// filling after a load), a frame no vblank fired in keeps its
|
||||
// measured time.
|
||||
// - A frame of more than 6 (a hitch, or a load's) keeps its measured
|
||||
// time, and game time starts over (the game's stepper runs at most 6
|
||||
// steps a frame and drops the rest).
|
||||
// - Below 20 fps (the frames before averaging 50 ms or more) long
|
||||
// frames are the frame rate's own: at 12 fps (5 vblanks a frame) one
|
||||
// of 7-8 comes every second or so. Starting over after each, kSlack
|
||||
// behind again, the frame after it got kSlack fewer than fired, and
|
||||
// game time ran 4% slow (until 2026-10-12; an Intel laptop GPU at
|
||||
// 1.5x). There a frame of up to kLoadVblanks (half a second; longer:
|
||||
// a load, as above) gets 6, the cap leaves the rest owed, up to
|
||||
// kMaxOwed beyond kSlack, and later frames give it back, each at most
|
||||
// doubling its own count (a frame of 5 gets 6, one of 1 gets 2).
|
||||
// Frames over 6 on average (below 10 fps) owe more than comes back:
|
||||
// the game runs slow there, as its stepper made it on the console.
|
||||
// The rule until 2026-10-07 gave a frame its count only when its
|
||||
// measured time was within half a vblank of it, else the measured time:
|
||||
// GPU-bound 44 ms frames got 3 vblanks or their own 2.64, so the game ran
|
||||
@@ -32,35 +60,78 @@ namespace game
|
||||
// 6-9% slow.
|
||||
struct WholeVblanks
|
||||
{
|
||||
// Game time given beyond the vblanks that fired, in vblanks: at most
|
||||
// kMaxAhead (a held vblank, as a GPU-only suspension's, isn't
|
||||
// replayed: what can't be given back is forgotten).
|
||||
// Game time given beyond the vblanks that fired, in vblanks (below 0:
|
||||
// behind), within +-kSlack: what can't be given back is forgotten (a
|
||||
// held vblank, as a GPU-only suspension's, isn't replayed). Below
|
||||
// 20 fps it may be down to -(kSlack + kMaxOwed).
|
||||
double ahead = 0.0;
|
||||
static constexpr double kMaxAhead = 6.0;
|
||||
// The recent frames' length, ms on average (a frame counted as its
|
||||
// vblanks, up to kMaxGive of them): 16.7 at 60 fps, 83 at 12.
|
||||
double frameMs = kHighRateMs;
|
||||
// How far the main loop's frames may drift from the vblanks without
|
||||
// the display showing it: the frames queued between the loop and the
|
||||
// display (2-3, measured on the Steam Machine).
|
||||
static constexpr double kSlack = 3.0;
|
||||
// The most a frame gets: the game's stepper runs at most 6 world
|
||||
// steps a frame (sub_823A20C0) and drops the rest. (At Frame Rate 30
|
||||
// a guest vblank is two steps, so 3 of them would be the cap there;
|
||||
// a frame still gets up to 6, as before.)
|
||||
static constexpr int64_t kMaxGive = 6;
|
||||
// Frames this long on average and longer: below 20 fps, where a
|
||||
// frame of up to kLoadVblanks gets kMaxGive and what the cap leaves
|
||||
// stays owed, up to kMaxOwed beyond kSlack (half a second). Longer
|
||||
// frames are loads (half a second: nobody drives at 2 fps).
|
||||
static constexpr double kLowRateMs = 50.0;
|
||||
static constexpr double kMaxOwed = 30.0;
|
||||
static constexpr uint64_t kLoadVblanks = 30;
|
||||
static constexpr double kHighRateMs = 1000.0 / 60.0;
|
||||
|
||||
// A frame the main loop measured at `measuredMs`, during which
|
||||
// `vblanks` guest vblanks fired (from its start to the next frame's),
|
||||
// each `vblankMs` long: the vblanks to give it, or 0 to keep its
|
||||
// measured time. More than 6 (loading) keeps it too, and starts over.
|
||||
// measured time. More than 6 (a hitch, loading) keeps it too, and
|
||||
// starts over; below 20 fps only more than kLoadVblanks does.
|
||||
uint64_t Give(double measuredMs, uint64_t vblanks, double vblankMs)
|
||||
{
|
||||
if (vblanks > 6 || !(vblankMs > 0.0))
|
||||
if (vblanks > kLoadVblanks || !(vblankMs > 0.0))
|
||||
{
|
||||
Reset();
|
||||
return 0;
|
||||
}
|
||||
// Judged by the frames before this one: a long frame at 60 fps
|
||||
// is a hitch, wherever it takes the average.
|
||||
const bool low = frameMs >= kLowRateMs;
|
||||
frameMs += (double(std::min<uint64_t>(vblanks, kMaxGive)) * vblankMs - frameMs) / 4.0;
|
||||
if (!low && vblanks > uint64_t(kMaxGive))
|
||||
{
|
||||
ahead = 0.0;
|
||||
return 0;
|
||||
}
|
||||
if (vblanks == 0)
|
||||
if (!low)
|
||||
ahead = std::max(ahead, -kSlack); // back above 20 fps: owed no more
|
||||
ahead -= double(vblanks);
|
||||
if (ahead + 1.0 > kSlack)
|
||||
{
|
||||
ahead = std::min(ahead + std::max(measuredMs, 0.0) / vblankMs, kMaxAhead);
|
||||
ahead = std::min(ahead + std::max(measuredMs, 0.0) / vblankMs, kSlack);
|
||||
return 0;
|
||||
}
|
||||
double owed = double(vblanks) - ahead;
|
||||
int64_t give = std::clamp<int64_t>(std::llround(owed), 1, 6);
|
||||
ahead = double(give) - owed;
|
||||
int64_t give = 1;
|
||||
if (ahead + 1.0 < -kSlack)
|
||||
{
|
||||
// Owed beyond its own count (only after a frame the cap held
|
||||
// back): at most as much again.
|
||||
give = std::min<int64_t>(std::llround(-kSlack - ahead), std::max<int64_t>(2 * int64_t(vblanks), 1));
|
||||
give = std::clamp<int64_t>(give, 1, kMaxGive);
|
||||
}
|
||||
ahead = std::max(ahead + double(give), -kSlack - (low ? kMaxOwed : 0.0));
|
||||
return uint64_t(give);
|
||||
}
|
||||
|
||||
// Game time measured again (NFSMW_GAME_TIME=0, a rate far from 60).
|
||||
void Reset() { ahead = 0.0; }
|
||||
void Reset()
|
||||
{
|
||||
ahead = 0.0;
|
||||
frameMs = kHighRateMs;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
#include <video/presenter.h>
|
||||
#include <video/frame_rate.h>
|
||||
#include <video/vblank_lock.h>
|
||||
#include <video/vblank_timer.h>
|
||||
#include <video/display_gamma.h>
|
||||
#include <platform/thermal.h>
|
||||
#include <game/frame_time.h>
|
||||
#include "renderer.h"
|
||||
@@ -259,13 +261,19 @@ namespace gpu
|
||||
{
|
||||
s_regs[index] |= 0x80000000u;
|
||||
}
|
||||
else if (video::display_gamma::IsLutRegister(index))
|
||||
{
|
||||
// The display gamma ramp (video/display_gamma.h): stored as
|
||||
// any register, and followed for the presenter.
|
||||
video::display_gamma::OnRegisterWrite(index, value);
|
||||
}
|
||||
if (index != REG_CP_RB_WPTR) // the game owns that one
|
||||
*MmioRegister(index) = s_regs[index];
|
||||
}
|
||||
|
||||
// A run of registers WriteRegister has nothing special for (it treats
|
||||
// only the scratch fences, COHER_STATUS_HOST and the write pointer
|
||||
// specially) can be written in bulk.
|
||||
// only the scratch fences, COHER_STATUS_HOST, the write pointer and
|
||||
// the display gamma ramp's registers specially) can be written in bulk.
|
||||
bool BulkSafe(uint32_t base, uint32_t count)
|
||||
{
|
||||
uint32_t end = base + count;
|
||||
@@ -273,7 +281,8 @@ namespace gpu
|
||||
return false;
|
||||
auto hits = [&](uint32_t lo, uint32_t hi) { return base <= hi && end > lo; };
|
||||
return !hits(REG_CP_RB_WPTR, REG_CP_RB_WPTR) && !hits(REG_SCRATCH_REG0, REG_SCRATCH_REG7) &&
|
||||
!hits(REG_COHER_STATUS_HOST, REG_COHER_STATUS_HOST);
|
||||
!hits(REG_COHER_STATUS_HOST, REG_COHER_STATUS_HOST) &&
|
||||
!hits(video::display_gamma::kRegRwMode, video::display_gamma::kRegLutaControl);
|
||||
}
|
||||
|
||||
// WriteRegister for `count` registers from `src`, big-endian dwords
|
||||
@@ -1111,29 +1120,41 @@ namespace gpu
|
||||
}
|
||||
}
|
||||
|
||||
// One line for the vblank lock's state (on changes, and each minute).
|
||||
void LogVblank(const video::VblankLock::Status& s)
|
||||
// One line for the vblank lock's state (on changes, and each minute),
|
||||
// and how late the last minute's vblanks fired.
|
||||
void LogVblank(const video::VblankLock::Status& s, const video::VblankTiming& timing, const char* timer)
|
||||
{
|
||||
const video::RefreshEstimate& d = s.display;
|
||||
std::string line;
|
||||
if (s.locked)
|
||||
fprintf(stderr, "[vblank] locked to the display: %.4f Hz (fit %u presents over %.1f s, rms %.2f ms, %u set aside); "
|
||||
"vblank %.2f ms before its refresh, error %+.3f ms; vblank to screen %.1f ms (median), to present %.1f ms (99%%); %llu slips\n",
|
||||
line = std::format("locked to the display: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside); "
|
||||
"vblank {:.2f} ms before its refresh, error {:+.3f} ms; vblank to screen {:.1f} ms (median), to present {:.1f} ms (99%); {} slips",
|
||||
1e9 / d.periodNs, d.samples, d.spanNs / 1e9, d.rmsNs / 1e6, d.rejected, s.leadNs / 1e6, s.errorNs / 1e6,
|
||||
s.latencyNs / 1e6, s.readyNs / 1e6, (unsigned long long)s.slips);
|
||||
s.latencyNs / 1e6, s.readyNs / 1e6, s.slips);
|
||||
else if (s.follows)
|
||||
fprintf(stderr, "[vblank] free-running every %.3f ms: the display follows the vblank (variable refresh?), "
|
||||
"so there is no refresh to lock to\n", s.periodNs / 1e6);
|
||||
line = std::format("free-running every {:.3f} ms: the display follows the vblank (variable refresh?), "
|
||||
"so there is no refresh to lock to", s.periodNs / 1e6);
|
||||
else if (d.samples)
|
||||
fprintf(stderr, "[vblank] free-running every %.3f ms; display %s: %.4f Hz (fit %u presents over %.1f s, rms %.2f ms, %u set aside)\n",
|
||||
line = std::format("free-running every {:.3f} ms; display {}: {:.4f} Hz (fit {} presents over {:.1f} s, rms {:.2f} ms, {} set aside)",
|
||||
s.periodNs / 1e6, d.valid ? "measured (lock off)" : "not accepted", 1e9 / d.periodNs, d.samples, d.spanNs / 1e9,
|
||||
d.rmsNs / 1e6, d.rejected);
|
||||
else
|
||||
fprintf(stderr, "[vblank] free-running every %.3f ms\n", s.periodNs / 1e6);
|
||||
line = std::format("free-running every {:.3f} ms", s.periodNs / 1e6);
|
||||
if (video::VblankTiming::Summary t = timing.Summarize(); t.count)
|
||||
line += std::format("; timer {}: vblanks late {:.2f} ms (median), {:.2f} ms (99%), {:.2f} ms at most, handler {:.2f} ms (99%)",
|
||||
timer, t.lateMedianNs / 1e6, t.late99Ns / 1e6, t.lateMaxNs / 1e6, t.handler99Ns / 1e6);
|
||||
fprintf(stderr, "[vblank] %s\n", line.c_str());
|
||||
}
|
||||
|
||||
void Vsync()
|
||||
{
|
||||
SetHostThreadName("nfsmw-vsync"); // (Linux would give it its creator's name, nfsmw-main)
|
||||
// Woken on time (video/vblank_timer.h): on macOS an ordinary
|
||||
// thread's sleeps ran up to 9 ms late.
|
||||
const video::VblankTimer timer = video::ChosenVblankTimer();
|
||||
const bool realtime = video::PrepareVblankThread(timer, 16'666'667);
|
||||
const char* timerName = video::VblankTimerName(timer, realtime);
|
||||
video::VblankTiming timing;
|
||||
GuestThreadContext thread(2);
|
||||
using namespace std::chrono;
|
||||
video::VblankLock& clock = video::GuestVblank();
|
||||
@@ -1173,14 +1194,16 @@ namespace gpu
|
||||
paused = nowPaused;
|
||||
clock.Resumed();
|
||||
}
|
||||
video::NoteGuestVblank(now());
|
||||
const int64_t fired = now();
|
||||
video::NoteGuestVblank(fired);
|
||||
s_counter++;
|
||||
s_vblanks++;
|
||||
DispatchInterrupt(thread.ppcContext, 0, 2);
|
||||
timing.Add(fired - next, now() - fired);
|
||||
auto status = clock.GetStatus();
|
||||
if (status.locked != locked || status.slips != slips || ++count % 3600 == 0)
|
||||
{
|
||||
LogVblank(status);
|
||||
LogVblank(status, timing, timerName);
|
||||
locked = status.locked;
|
||||
slips = status.slips;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Draws in depth-only EDRAM mode (RB_MODECONTROL edram_mode kDepthOnly) run
|
||||
// no pixel shader on Xenos: only the rasterised depth and stencil reach EDRAM
|
||||
// (xenos/xenos.h, EdramMode::kDepthOnly; Xenia draws them without one). The
|
||||
// pixel shader still bound then is whatever an earlier draw left, and running
|
||||
// it gives the draw that shader's kills, alpha test and depth export.
|
||||
//
|
||||
// NFSMW's sun shadow pass draws its alpha-tested casters (trees, poles,
|
||||
// signs) in colour+depth mode with a pixel shader that kills texels whose
|
||||
// alpha is 0.2 or less. Its other casters are depth-only, drawn with that
|
||||
// pixel shader still bound and vertex shaders that export no texture
|
||||
// coordinates: the solid occluders (walls, concrete barriers, tunnel roofs
|
||||
// and the hills over them) and, after the world's map is resolved to
|
||||
// 03A8A000, the cars (the map with them is 0444E000). Run, the bound shader
|
||||
// read texel (0,0)'s alpha for all of them and killed every pixel, so up to
|
||||
// v0.1.2 no car or wall cast a sun shadow and tunnels let the sun through
|
||||
// (GitHub issue #3).
|
||||
#pragma once
|
||||
#include "xenos/xenos.h"
|
||||
|
||||
namespace gpu
|
||||
{
|
||||
// Whether a draw in this EDRAM mode runs the guest's bound pixel shader.
|
||||
// guestOverride (NFSMW_DEPTH_ONLY_PS=1, for A/B only): always, as up to
|
||||
// v0.1.2.
|
||||
constexpr bool RunsGuestPixelShader(xe::gpu::xenos::EdramMode mode, bool guestOverride = false)
|
||||
{
|
||||
return mode != xe::gpu::xenos::EdramMode::kDepthOnly || guestOverride;
|
||||
}
|
||||
|
||||
// What a depth-only draw runs instead: no inputs, outputs, kill or
|
||||
// resources, so it fits every pipeline layout and attachment set.
|
||||
inline constexpr char kEmptyPixelShaderGlsl[] = "#version 460\nvoid main()\n{\n}\n";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -634,6 +634,13 @@ namespace hid
|
||||
rt = 1.0f;
|
||||
if (k[SDL_SCANCODE_S])
|
||||
lt = 1.0f;
|
||||
// The right stick (the game shifts gears with it: drag races
|
||||
// need it). While either key of an axis is held the keyboard
|
||||
// has that axis; both keys centre it.
|
||||
if (k[SDL_SCANCODE_O] || k[SDL_SCANCODE_L])
|
||||
ry = float(k[SDL_SCANCODE_O]) - float(k[SDL_SCANCODE_L]);
|
||||
if (k[SDL_SCANCODE_K] || k[SDL_SCANCODE_SEMICOLON])
|
||||
rx = float(k[SDL_SCANCODE_SEMICOLON]) - float(k[SDL_SCANCODE_K]);
|
||||
}
|
||||
p.lx = ToShort(lx);
|
||||
p.ly = ToShort(ly);
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
// (NFSMW_TRIGGER_DEADZONE, default 0.02).
|
||||
// - Keyboard: arrows = D-pad + left stick, W/S = gas/brake (RT/LT), A/D =
|
||||
// steer, Space = A, Backspace = B, Left Shift = X, Left Ctrl = Y,
|
||||
// Q/E = LB/RB, Enter = START, Esc = BACK, C = right stick click.
|
||||
// Q/E = LB/RB, Enter = START, Esc = BACK, O/L and K/; = right stick
|
||||
// (up/down, left/right), C = right stick click.
|
||||
// - NFSMW_INPUT_SCRIPT="12:START,15.5:A,30-90/4:DOWN,100~160:RT": taps
|
||||
// (150 ms) at seconds since launch, taps every `step` seconds over a
|
||||
// range, or inputs held over a range; RT/LT (triggers) and LL/LR/LU/LD
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// When the mouse cursor shows over the game (ui.cpp, every frame). The game
|
||||
// takes no mouse input, so while it plays the cursor hides once the mouse has
|
||||
// been still for kHideAfterNs, and shows again the moment it moves, clicks or
|
||||
// scrolls. The settings menu and the installer's screens (any modal page)
|
||||
// take the mouse: while one is open it always shows. The cursor hides only
|
||||
// over the game's window (SDL_HideCursor: on macOS the window's cursor
|
||||
// rectangle, on X11 and Wayland the window's cursor), so in a window it still
|
||||
// shows over the desktop, and nothing is captured or confined (no relative
|
||||
// mode). Touch moves no cursor: the mouse SDL makes of a finger (and of a
|
||||
// pen) doesn't count as movement, so a tap on the Steam Deck's screen
|
||||
// doesn't bring a cursor back. Not used on iOS, where the system hides an
|
||||
// iPad's pointer by itself.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace ui
|
||||
{
|
||||
struct CursorIdle
|
||||
{
|
||||
static constexpr uint64_t kHideAfterNs = 2'000'000'000;
|
||||
|
||||
// When the mouse last moved (SDL's clock, ns). The first frame counts:
|
||||
// the cursor shows for kHideAfterNs after launch wherever it is.
|
||||
uint64_t movedNs = 0;
|
||||
bool started = false;
|
||||
|
||||
// A mouse event (SDL's): `motion`, or a button or the wheel; whether
|
||||
// SDL made it of a finger or a pen (SDL_TOUCH_MOUSEID, SDL_PEN_MOUSEID);
|
||||
// a motion's distance. True: a real mouse moved, clicked or scrolled.
|
||||
static bool Activity(bool motion, bool touchOrPen, float dx, float dy)
|
||||
{
|
||||
return !touchOrPen && (!motion || dx != 0.0f || dy != 0.0f);
|
||||
}
|
||||
|
||||
// A real mouse moved, clicked or scrolled at `t`.
|
||||
void Moved(uint64_t t)
|
||||
{
|
||||
if (!started || t > movedNs)
|
||||
movedNs = t;
|
||||
started = true;
|
||||
}
|
||||
|
||||
// Whether the cursor shows at `now`: a menu or modal page has the
|
||||
// mouse, or it moved within kHideAfterNs.
|
||||
bool Shows(uint64_t now, bool menu)
|
||||
{
|
||||
if (!started)
|
||||
{
|
||||
movedNs = now;
|
||||
started = true;
|
||||
}
|
||||
return menu || now < movedNs + kHideAfterNs;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,154 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Quit Game (settings_menu.cpp): where it is offered, and the rules
|
||||
// of its confirmation, apart from ImGui so tests/quit_game_test.cpp can run
|
||||
// them. The button is the last of the menu's footer (Reset page, Close, Quit
|
||||
// Game), on every page: the D-pad's (or the arrow keys') Down past the last
|
||||
// row lands on the footer's first button (Reset page, or Close while the page
|
||||
// has nothing to reset), and Right goes on to Quit Game. The mouse clicks it.
|
||||
// - Offered where an app may quit itself: macOS, Linux and SteamOS (x86-64
|
||||
// and arm64), Windows. Not on iPhone or iPad, where the player closes an
|
||||
// app from the app switcher and Apple expects apps not to quit
|
||||
// themselves: there the button and its prompt are compiled out
|
||||
// (SB_QUIT_GAME 0), as is the startup error page's Quit.
|
||||
// - Two steps: Quit Game asks "Quit SpeedBreaker? Progress since your last
|
||||
// save will be lost." with Quit and Cancel, and only Quit quits. The
|
||||
// focus starts on Cancel, so the same button pressed again (A, Enter)
|
||||
// cancels, and a second click lands outside the prompt, which ignores
|
||||
// it. Quit takes a press only once the prompt has been up a frame: never
|
||||
// the one that opened it.
|
||||
// - B, Escape and Cancel cancel; so does the menu closing under it (F1,
|
||||
// Start or Back alone) or opening again.
|
||||
// - Quitting is an SDL_EVENT_QUIT (Cmd+Q's event), which the presenter
|
||||
// takes as it takes the window's close button, so the game leaves the one
|
||||
// way it always does (main.cpp: settings saved, the renderer, UI,
|
||||
// controllers and window shut down, the log flushed, exit code 0). Once
|
||||
// quitting, nothing in the prompt acts again.
|
||||
#pragma once
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include <TargetConditionals.h> // TARGET_OS_IPHONE: __APPLE__ alone means macOS or iOS
|
||||
#endif
|
||||
|
||||
// iPhone and iPad (and Apple's other device systems): no Quit Game.
|
||||
#if defined(__APPLE__) && TARGET_OS_IPHONE
|
||||
#define SB_QUIT_GAME 0
|
||||
#else
|
||||
#define SB_QUIT_GAME 1
|
||||
#endif
|
||||
|
||||
namespace ui::quit
|
||||
{
|
||||
enum class System { Mac, IPhoneIPad, Linux, Windows, Other };
|
||||
|
||||
constexpr System kSystem =
|
||||
#if defined(__APPLE__) && TARGET_OS_IPHONE
|
||||
System::IPhoneIPad;
|
||||
#elif defined(__APPLE__)
|
||||
System::Mac;
|
||||
#elif defined(_WIN32)
|
||||
System::Windows;
|
||||
#elif defined(__linux__)
|
||||
System::Linux;
|
||||
#else
|
||||
System::Other;
|
||||
#endif
|
||||
|
||||
// Whether the app may quit itself there (Quit Game, the startup error
|
||||
// page's Quit).
|
||||
constexpr bool OfferedOn(System system)
|
||||
{
|
||||
return system != System::IPhoneIPad;
|
||||
}
|
||||
|
||||
constexpr bool kOffered = SB_QUIT_GAME != 0;
|
||||
static_assert(kOffered == OfferedOn(kSystem), "SB_QUIT_GAME and kSystem disagree");
|
||||
|
||||
enum class Button { Quit, Cancel };
|
||||
|
||||
// What a step did: the menu logs it, and quits on Quit.
|
||||
enum class Outcome { None, Asking, Cancelled, Quit };
|
||||
|
||||
class Prompt
|
||||
{
|
||||
public:
|
||||
bool Asking() const { return m_step == Step::Asking; }
|
||||
bool Quitting() const { return m_step == Step::Quitting; }
|
||||
// Where the focus starts when the prompt opens.
|
||||
static constexpr Button kDefaultFocus = Button::Cancel;
|
||||
|
||||
// Quit Game was pressed: ask.
|
||||
Outcome Choose()
|
||||
{
|
||||
if (m_step != Step::Idle)
|
||||
return Outcome::None;
|
||||
m_step = Step::Asking;
|
||||
m_framesShown = 0;
|
||||
m_focusPending = true;
|
||||
return Outcome::Asking;
|
||||
}
|
||||
|
||||
// Each frame the prompt is drawn, before its buttons.
|
||||
void Shown()
|
||||
{
|
||||
if (m_step == Step::Asking && m_framesShown < kFramesBeforeQuit)
|
||||
m_framesShown++;
|
||||
}
|
||||
|
||||
// True once, on the prompt's first frame: focus kDefaultFocus.
|
||||
bool TakeFocus()
|
||||
{
|
||||
const bool focus = m_focusPending && m_step == Step::Asking;
|
||||
m_focusPending = false;
|
||||
return focus;
|
||||
}
|
||||
|
||||
// One of the prompt's buttons was pressed.
|
||||
Outcome Press(Button button)
|
||||
{
|
||||
if (m_step != Step::Asking)
|
||||
return Outcome::None;
|
||||
if (button == Button::Cancel)
|
||||
return Cancel();
|
||||
if (m_framesShown < kFramesBeforeQuit)
|
||||
return Outcome::None; // the prompt's first frame: not a press of Quit's
|
||||
m_step = Step::Quitting;
|
||||
return Outcome::Quit;
|
||||
}
|
||||
|
||||
// B or Escape.
|
||||
Outcome Back()
|
||||
{
|
||||
return m_step == Step::Asking ? Cancel() : Outcome::None;
|
||||
}
|
||||
|
||||
// The prompt went away unanswered (the menu closed, or opened again).
|
||||
Outcome Dismiss()
|
||||
{
|
||||
return m_step == Step::Asking ? Cancel() : Outcome::None;
|
||||
}
|
||||
|
||||
// The quit couldn't be sent (SDL's event queue refused it): the
|
||||
// game carries on, and Quit Game can be chosen again.
|
||||
void QuitFailed()
|
||||
{
|
||||
if (m_step == Step::Quitting)
|
||||
m_step = Step::Idle;
|
||||
}
|
||||
|
||||
private:
|
||||
enum class Step { Idle, Asking, Quitting };
|
||||
static constexpr int kFramesBeforeQuit = 2; // the frame it opened, and one more
|
||||
|
||||
Outcome Cancel()
|
||||
{
|
||||
m_step = Step::Idle;
|
||||
m_focusPending = false;
|
||||
return Outcome::Cancelled;
|
||||
}
|
||||
|
||||
Step m_step = Step::Idle;
|
||||
int m_framesShown = 0;
|
||||
bool m_focusPending = false;
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,11 @@
|
||||
// pages), and touch as the mouse (on iOS a drag scrolls the rows: ui.cpp).
|
||||
// Advanced also has Save Bug Report (report/bug_report.cpp), Check for
|
||||
// Updates (update_dialog.cpp), the build's identity (version and commit) and
|
||||
// the legal notice.
|
||||
// the legal notice. The footer's last button is Quit Game, which asks first
|
||||
// (quit_game.h; not on iPhone or iPad).
|
||||
#include <stdafx.h>
|
||||
#include "ui.h"
|
||||
#include "quit_game.h"
|
||||
|
||||
#include <report/report.h>
|
||||
#include <user/settings.h>
|
||||
@@ -19,6 +21,10 @@
|
||||
#include <imgui.h>
|
||||
#include <imgui_internal.h> // ClosePopupToLevel: a dialog left open when the menu closed
|
||||
|
||||
#include <SDL3/SDL_error.h>
|
||||
#include <SDL3/SDL_events.h>
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
namespace ui
|
||||
{
|
||||
namespace
|
||||
@@ -36,6 +42,11 @@ namespace ui
|
||||
|
||||
constexpr int kCategories = int(Category::Count);
|
||||
|
||||
#if SB_QUIT_GAME
|
||||
constexpr const char* kQuitPopup = "quit";
|
||||
quit::Prompt s_quit;
|
||||
#endif
|
||||
|
||||
bool Pressed(ImGuiKey key)
|
||||
{
|
||||
return ImGui::IsKeyPressed(key, true);
|
||||
@@ -176,6 +187,10 @@ namespace ui
|
||||
marker("ENV", ImGui::GetColorU32(ImGuiCol_TextDisabled));
|
||||
if (settings::RestartPending(o.id))
|
||||
marker("RESTART", ImGui::GetColorU32(ImGuiCol_PlotLines));
|
||||
// The row is the saved choice; the system (the green button)
|
||||
// changed the window this session: say what it is now.
|
||||
if (o.id == Id::Fullscreen && !locked && video::FullscreenWindowDiffers())
|
||||
marker(video::WindowFullscreen() ? "NOW FULLSCREEN" : "NOW WINDOWED", ImGui::GetColorU32(ImGuiCol_TextDisabled));
|
||||
}
|
||||
|
||||
int delta = 0;
|
||||
@@ -288,6 +303,84 @@ namespace ui
|
||||
OpenUpdateDialog();
|
||||
}
|
||||
|
||||
bool QuitPromptOpen()
|
||||
{
|
||||
#if SB_QUIT_GAME
|
||||
return ImGui::IsPopupOpen(kQuitPopup);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if SB_QUIT_GAME
|
||||
// Quit Game's prompt (quit_game.h), at the settings window's top
|
||||
// level like the other dialogs. True while it is open: B is its, not
|
||||
// the menu's.
|
||||
bool DrawQuitPrompt()
|
||||
{
|
||||
if (!ImGui::IsPopupOpen(kQuitPopup))
|
||||
{
|
||||
if (s_quit.Dismiss() == quit::Outcome::Cancelled)
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (the prompt closed)\n");
|
||||
return false;
|
||||
}
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
if (!ImGui::BeginPopupModal(kQuitPopup, nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar))
|
||||
{
|
||||
if (s_quit.Dismiss() == quit::Outcome::Cancelled)
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (the prompt closed)\n");
|
||||
return false;
|
||||
}
|
||||
s_quit.Shown();
|
||||
ImGui::TextUnformatted("Quit SpeedBreaker? Progress since your last save will be lost.");
|
||||
ImGui::Spacing();
|
||||
const bool quitPressed = ImGui::Button("Quit");
|
||||
ImGui::SameLine();
|
||||
if (s_quit.TakeFocus())
|
||||
ImGui::SetKeyboardFocusHere(); // Cancel (quit::Prompt::kDefaultFocus)
|
||||
const bool cancelPressed = ImGui::Button("Cancel");
|
||||
ImGui::SetItemDefaultFocus();
|
||||
quit::Outcome outcome = quit::Outcome::None;
|
||||
const char* how = "";
|
||||
if (cancelPressed)
|
||||
{
|
||||
outcome = s_quit.Press(quit::Button::Cancel);
|
||||
how = "Cancel";
|
||||
}
|
||||
else if (quitPressed)
|
||||
outcome = s_quit.Press(quit::Button::Quit);
|
||||
else if (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false))
|
||||
{
|
||||
outcome = s_quit.Back();
|
||||
how = "B or Escape";
|
||||
}
|
||||
if (outcome == quit::Outcome::Cancelled)
|
||||
{
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (%s)\n", how);
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
else if (outcome == quit::Outcome::Quit)
|
||||
{
|
||||
// The way out a window close takes: RunFrame returns false on
|
||||
// its next poll and main() shuts the game down.
|
||||
fprintf(stderr, "[ui] Quit Game: quitting (as closing the window does)\n");
|
||||
ImGui::CloseCurrentPopup();
|
||||
SDL_Event e{};
|
||||
e.type = SDL_EVENT_QUIT;
|
||||
e.quit.timestamp = SDL_GetTicksNS();
|
||||
if (!SDL_PushEvent(&e))
|
||||
{
|
||||
fprintf(stderr, "[ui] Quit Game: couldn't queue the quit: %s\n", SDL_GetError());
|
||||
Toast("Couldn't quit. Close the game's window instead.");
|
||||
s_quit.QuitFailed();
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
void HelpLine()
|
||||
{
|
||||
if (s_describedUpdate)
|
||||
@@ -340,6 +433,14 @@ namespace ui
|
||||
ImGui::TextDisabled("Always on in Game Mode: Steam sets the resolution (Properties > Game Resolution).");
|
||||
return;
|
||||
}
|
||||
// The window's own fullscreen button (or the system) changed it for
|
||||
// this session; the setting still decides how the game starts.
|
||||
if (o.id == Id::Fullscreen && video::FullscreenWindowDiffers() && !settings::EnvOverride(o.id))
|
||||
{
|
||||
ImGui::TextDisabled("The window is %s for now. The game starts %s, as set here.",
|
||||
video::WindowFullscreen() ? "fullscreen" : "windowed", settings::GetBool(Id::Fullscreen) ? "fullscreen" : "windowed");
|
||||
return;
|
||||
}
|
||||
// The experiment's finer scale wins over Screen Resolution's.
|
||||
if (float scale = video::PresentScaleOverride(); o.id == Id::PresentScale && scale > 0.0f)
|
||||
ImGui::TextDisabled("NFSMW_PRESENT_SCALE sets %.2f for this run.", double(scale));
|
||||
@@ -396,7 +497,7 @@ namespace ui
|
||||
#endif
|
||||
|
||||
// Pages: LB/RB (Q/E) or the tabs; not under a dialog, which keeps them.
|
||||
const bool dialog = ImGui::IsPopupOpen("reset") || UpdateDialogOpen();
|
||||
const bool dialog = ImGui::IsPopupOpen("reset") || UpdateDialogOpen() || QuitPromptOpen();
|
||||
if (int delta = dialog ? 0 : PageDelta())
|
||||
{
|
||||
s_category = Category((int(s_category) + delta + kCategories) % kCategories);
|
||||
@@ -514,6 +615,16 @@ namespace ui
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close"))
|
||||
CloseSettings();
|
||||
#if SB_QUIT_GAME
|
||||
// Last, after Close: Down from the rows lands on the footer's first
|
||||
// button, so Quit Game takes a Right more, and then asks.
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Quit Game") && s_quit.Choose() == quit::Outcome::Asking)
|
||||
{
|
||||
fprintf(stderr, "[ui] Quit Game: asking\n");
|
||||
ImGui::OpenPopup(kQuitPopup);
|
||||
}
|
||||
#endif
|
||||
ImGui::SameLine();
|
||||
{
|
||||
const char* legend = "D-pad: choose, change LB RB: page B: close";
|
||||
@@ -546,6 +657,10 @@ namespace ui
|
||||
}
|
||||
if (DrawUpdateDialog())
|
||||
popup = true;
|
||||
#if SB_QUIT_GAME
|
||||
if (DrawQuitPrompt())
|
||||
popup = true;
|
||||
#endif
|
||||
if (!popup && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && !ImGui::IsAnyItemActive() &&
|
||||
(ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)))
|
||||
CloseSettings();
|
||||
@@ -561,4 +676,12 @@ namespace ui
|
||||
ImGui::ClosePopupToLevel(0, false);
|
||||
ForgetUpdateDialog();
|
||||
}
|
||||
|
||||
void ForgetQuitPrompt()
|
||||
{
|
||||
#if SB_QUIT_GAME
|
||||
if (s_quit.Dismiss() == quit::Outcome::Cancelled)
|
||||
fprintf(stderr, "[ui] Quit Game: cancelled (the menu closed)\n");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ui.h.
|
||||
//
|
||||
// The page shown instead of the game when it can't start: today, when the
|
||||
// guest's 4 GB of address space couldn't be reserved (kernel/memory_status.h:
|
||||
// on iOS, an app signed without Extended Virtual Addressing). Before the
|
||||
// game, like the installer: nothing of the game runs behind it. On iOS it
|
||||
// stays up (an app doesn't quit itself; the player swipes it away); elsewhere
|
||||
// OK, A, B, Start or Esc closes it, and the window's close button too.
|
||||
#include <stdafx.h>
|
||||
#include "ui.h"
|
||||
#include "quit_game.h"
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
namespace ui
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr bool kCanQuit = quit::kOffered; // not on iPhone or iPad
|
||||
|
||||
struct ErrorPage
|
||||
{
|
||||
std::string title;
|
||||
std::vector<std::string> paragraphs;
|
||||
std::string details;
|
||||
bool firstFrame = true;
|
||||
bool quit = false;
|
||||
};
|
||||
|
||||
void Draw(ErrorPage& page)
|
||||
{
|
||||
float s = Scale();
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
float width = std::min(viewport->WorkSize.x - 2 * kSpaceL * s, 760.0f * s);
|
||||
float maxHeight = viewport->WorkSize.y - 2 * kSpaceL * s;
|
||||
ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
ImGui::SetNextWindowSizeConstraints(ImVec2(width, 0), ImVec2(width, maxHeight));
|
||||
constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize;
|
||||
if (page.firstFrame || !ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow))
|
||||
ImGui::SetNextWindowFocus();
|
||||
if (!ImGui::Begin("Can't start", nullptr, flags))
|
||||
{
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
TitleText(page.title.c_str());
|
||||
ImGui::Spacing();
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
for (size_t i = 0; i < page.paragraphs.size(); i++)
|
||||
{
|
||||
if (i > 0)
|
||||
ImGui::Dummy(ImVec2(0, kSpaceS * s));
|
||||
ImGui::TextWrapped("%s", page.paragraphs[i].c_str());
|
||||
}
|
||||
if (!page.details.empty())
|
||||
{
|
||||
ImGui::Dummy(ImVec2(0, kSpaceM * s));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
|
||||
ImGui::TextWrapped("%s", page.details.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
ImGui::PopTextWrapPos();
|
||||
if (kCanQuit)
|
||||
{
|
||||
ImGui::Dummy(ImVec2(0, kSpaceS * s));
|
||||
if (page.firstFrame)
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
bool quit = ImGui::Button("Quit");
|
||||
if (quit || ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)
|
||||
|| ImGui::IsKeyPressed(ImGuiKey_GamepadStart, false))
|
||||
page.quit = true;
|
||||
}
|
||||
page.firstFrame = false;
|
||||
ImGui::End();
|
||||
}
|
||||
}
|
||||
|
||||
void RunStartupError(const std::string& title, const std::vector<std::string>& paragraphs, const std::string& details,
|
||||
const std::function<bool()>& runFrame)
|
||||
{
|
||||
if (!Ready())
|
||||
return;
|
||||
ErrorPage page{ title, paragraphs, details };
|
||||
SetModal([&page] { Draw(page); });
|
||||
fprintf(stderr, "[ui] showing \"%s\" (%s)\n", title.c_str(),
|
||||
kCanQuit ? "until it is closed" : "until the app is closed");
|
||||
while (!page.quit && runFrame())
|
||||
;
|
||||
SetModal(nullptr);
|
||||
fprintf(stderr, "[ui] \"%s\" closed\n", title.c_str());
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ui.h.
|
||||
#include <stdafx.h>
|
||||
#include "ui.h"
|
||||
#include "cursor_idle.h"
|
||||
|
||||
#include <kernel/xam.h>
|
||||
#include <report/report.h>
|
||||
#include <user/settings.h>
|
||||
#include <video/swapchain_policy.h>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <imgui_impl_sdl3.h>
|
||||
@@ -106,6 +108,7 @@ namespace ui
|
||||
|
||||
float s_appliedScale = 0.0f;
|
||||
uint32_t s_framebufferW = 0, s_framebufferH = 0; // SetFramebufferSize
|
||||
bool s_displayFromSwapchain = false; // the layout follows the swapchain, not the window (FitUiDisplay)
|
||||
ImGuiStyle s_baseStyle;
|
||||
|
||||
// Back + Start on one controller toggles the settings menu; both must
|
||||
@@ -467,6 +470,63 @@ namespace ui
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
// The mouse cursor over the game (cursor_idle.h). Main thread.
|
||||
CursorIdle s_cursorIdle;
|
||||
bool s_cursorShown = true; // as SDL starts
|
||||
int s_cursorChanges = 0; // logged, the first few
|
||||
|
||||
// A real mouse's movement, click or scroll: not the mouse SDL makes
|
||||
// of a finger or a pen, nor a motion event that moved nothing.
|
||||
bool MouseActivity(const SDL_Event& e)
|
||||
{
|
||||
auto touchOrPen = [](SDL_MouseID which) { return which == SDL_TOUCH_MOUSEID || which == SDL_PEN_MOUSEID; };
|
||||
switch (e.type)
|
||||
{
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
return CursorIdle::Activity(true, touchOrPen(e.motion.which), e.motion.xrel, e.motion.yrel);
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
return CursorIdle::Activity(false, touchOrPen(e.button.which), 0.0f, 0.0f);
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
return CursorIdle::Activity(false, touchOrPen(e.wheel.which), 0.0f, 0.0f);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Shows or hides the cursor for now (each frame, and as the menu
|
||||
// opens or closes). While a menu or modal page has the mouse, ImGui's
|
||||
// backend shows it and picks its shape; otherwise it must keep its
|
||||
// hands off, or it shows it again every frame the performance
|
||||
// overlay or a toast is drawn.
|
||||
void UpdateCursor()
|
||||
{
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
const bool menu = s_ready && s_capturing.load(std::memory_order_relaxed);
|
||||
if (s_ready)
|
||||
{
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (menu)
|
||||
io.ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange;
|
||||
else
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
|
||||
}
|
||||
const bool show = s_cursorIdle.Shows(SDL_GetTicksNS(), menu);
|
||||
if (show == s_cursorShown)
|
||||
return;
|
||||
s_cursorShown = show;
|
||||
show ? SDL_ShowCursor() : SDL_HideCursor();
|
||||
if (++s_cursorChanges <= 6)
|
||||
fprintf(stderr, "[ui] mouse cursor %s\n", show ? (menu ? "shown (a menu has the mouse)" : "shown (the mouse moved)")
|
||||
: "hidden (still for 2 s over the game)");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
bool Initialize(SDL_Window* window, const VulkanTarget& target)
|
||||
{
|
||||
if (s_ready)
|
||||
@@ -553,6 +613,14 @@ namespace ui
|
||||
|
||||
void ProcessEvent(const SDL_Event& e)
|
||||
{
|
||||
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
||||
if (MouseActivity(e))
|
||||
{
|
||||
// Shown at once, not at the next frame's check.
|
||||
s_cursorIdle.Moved(e.common.timestamp);
|
||||
UpdateCursor();
|
||||
}
|
||||
#endif
|
||||
if (!s_ready)
|
||||
return;
|
||||
bool forward = s_capturing.load(std::memory_order_relaxed);
|
||||
@@ -645,6 +713,7 @@ namespace ui
|
||||
void Tick()
|
||||
{
|
||||
s_pollAt = SDL_GetTicksNS();
|
||||
UpdateCursor();
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS
|
||||
// Every frame: SDL's text input or the Files picker can have the
|
||||
// first responder for a while (ios_ui.h). Cheap when nothing changed.
|
||||
@@ -659,9 +728,13 @@ namespace ui
|
||||
// the next presented frame as build/ui_shot_<n>.ppm: on the keys'
|
||||
// own clock, so a capture lands between two keys (the
|
||||
// NFSMW_CHECK_PRESENT_SEC clock starts at the first frame, a second
|
||||
// or more later).
|
||||
// or more later). MouseMove moves the pointer to the window's middle
|
||||
// and 40 points either side in turn, for the cursor's hiding:
|
||||
// SDL_WarpMouseInWindow moves the real pointer (SDL reports a warp as
|
||||
// a motion of no distance, which isn't the mouse moving), then the
|
||||
// motion a mouse moving there makes is posted.
|
||||
constexpr SDL_Keycode kFocusLost = SDLK_EXTENDED_MASK | 0x7F01, kFocusGained = SDLK_EXTENDED_MASK | 0x7F02,
|
||||
kShot = SDLK_EXTENDED_MASK | 0x7F03;
|
||||
kShot = SDLK_EXTENDED_MASK | 0x7F03, kMouseMove = SDLK_EXTENDED_MASK | 0x7F04;
|
||||
struct Key { double time; SDL_Keycode key; };
|
||||
static const auto start = std::chrono::steady_clock::now();
|
||||
static std::vector<Key> keys = [] {
|
||||
@@ -678,6 +751,7 @@ namespace ui
|
||||
std::string name = item.substr(colon + 1);
|
||||
SDL_Keycode key = name == "FocusLost" ? kFocusLost : name == "FocusGained" ? kFocusGained
|
||||
: name == "Shot" ? kShot
|
||||
: name == "MouseMove" ? kMouseMove
|
||||
: SDL_GetKeyFromName(name.c_str());
|
||||
if (key != SDLK_UNKNOWN)
|
||||
parsed.push_back({ std::atof(item.c_str()), key });
|
||||
@@ -715,6 +789,28 @@ namespace ui
|
||||
shotDue = keys[next++].time;
|
||||
return;
|
||||
}
|
||||
if (keys[next].key == kMouseMove)
|
||||
{
|
||||
static int moves = 0;
|
||||
int w = 0, h = 0;
|
||||
SDL_GetWindowSize(s_window, &w, &h);
|
||||
const float x = float(w) * 0.5f + float((moves++ % 3) - 1) * 40.0f, y = float(h) * 0.5f;
|
||||
float fromX = x, fromY = y;
|
||||
SDL_GetMouseState(&fromX, &fromY);
|
||||
SDL_WarpMouseInWindow(s_window, x, y);
|
||||
SDL_Event e{};
|
||||
e.type = SDL_EVENT_MOUSE_MOTION;
|
||||
e.motion.timestamp = SDL_GetTicksNS();
|
||||
e.motion.windowID = SDL_GetWindowID(s_window);
|
||||
e.motion.which = 1; // a mouse (not SDL_TOUCH_MOUSEID or SDL_PEN_MOUSEID)
|
||||
e.motion.x = x;
|
||||
e.motion.y = y;
|
||||
e.motion.xrel = x - fromX;
|
||||
e.motion.yrel = y - fromY;
|
||||
SDL_PushEvent(&e);
|
||||
fprintf(stderr, "[ui] NFSMW_UI_KEYS: mouse to %.0f,%.0f\n", x, y);
|
||||
continue;
|
||||
}
|
||||
if (keys[next].key == kFocusLost || keys[next].key == kFocusGained)
|
||||
{
|
||||
SDL_Event e{};
|
||||
@@ -804,6 +900,7 @@ namespace ui
|
||||
s_frameBuilt = false;
|
||||
if (!s_ready)
|
||||
return false;
|
||||
UpdateCursor(); // a menu opened or closed by this frame's events
|
||||
// A touch press held back for a three-finger tap goes to ImGui once
|
||||
// a second finger can no longer land in time (a finger resting on a
|
||||
// row, say), without waiting for another touch event. Judged here,
|
||||
@@ -826,21 +923,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();
|
||||
@@ -922,6 +1043,7 @@ namespace ui
|
||||
s_settingsOpen = false;
|
||||
UpdateCapture();
|
||||
ForgetUpdateDialog();
|
||||
ForgetQuitPrompt();
|
||||
report::NoteMenuClosed();
|
||||
XamNotifySystemUi(false);
|
||||
if (settings::Dirty() && !settings::Save())
|
||||
|
||||
@@ -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();
|
||||
@@ -137,6 +147,9 @@ namespace ui
|
||||
// The menu closed or reopened: the dialog is gone (a check still running
|
||||
// then ends in a toast).
|
||||
void ForgetUpdateDialog();
|
||||
// The menu closed: Quit Game's prompt (settings_menu.cpp, quit_game.h),
|
||||
// if it was asking, is cancelled.
|
||||
void ForgetQuitPrompt();
|
||||
|
||||
// Shared look (ui.cpp).
|
||||
SDL_Window* GetWindow();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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[] = {
|
||||
@@ -132,6 +136,15 @@ namespace settings
|
||||
.table = "video", .key = "aspect_ratio", .label = "Aspect Ratio",
|
||||
.description = "Fill other screen shapes: a wider view on an ultrawide, a taller one on a Steam Deck or a 4:3 iPad, the HUD at 16:9. Or 16:9 with bars.",
|
||||
.env = "NFSMW_ASPECT", .defaultValue = double(AspectMode::Auto), .entries = kAspectEntries },
|
||||
// Brightness: a power curve on the game's picture, -5..+5 (video/display_tone.h).
|
||||
{ .id = Id::Brightness, .category = Category::Display, .type = Type::Int, .apply = Apply::Live,
|
||||
.table = "video", .key = "brightness", .label = "Brightness",
|
||||
.description = "Lighter or darker shadows and mid-tones, a gentle step at a time, as a TV's brightness. Black and white stay put. 0 is the original picture.",
|
||||
.env = "NFSMW_BRIGHTNESS", .defaultValue = 0, .min = -5, .max = 5, .step = 1 },
|
||||
{ .id = Id::DisplayGamma, .category = Category::Display, .type = Type::Bool, .apply = Apply::Live,
|
||||
.table = "video", .key = "xbox_display_gamma", .label = "Xbox 360 Display Gamma",
|
||||
.description = "The gamma ramp the game itself sets for a real Xbox 360's TV output: a little deeper shadows and brighter mid-tones, as on the console. Off: the flatter picture of SpeedBreaker 0.1.3 and earlier.",
|
||||
.env = "NFSMW_DISPLAY_GAMMA", .defaultValue = 1 },
|
||||
{ .id = Id::Sharpening, .category = Category::Graphics, .type = Type::Float, .apply = Apply::Live,
|
||||
.table = "video", .key = "sharpening", .label = "Sharpening",
|
||||
.description = "Extra sharpness for bicubic upscaling. 0 turns it off.",
|
||||
@@ -146,7 +159,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",
|
||||
@@ -1086,6 +1099,9 @@ namespace settings
|
||||
int64_t n = std::llround(value);
|
||||
if (unit == "%")
|
||||
return std::format("{}%", n);
|
||||
// A range around zero (Brightness) shows its sign: -2, 0, +2.
|
||||
if (o.min < 0 && n > 0)
|
||||
return unit.empty() ? std::format("+{}", n) : std::format("+{} {}", n, unit);
|
||||
return unit.empty() ? std::format("{}", n) : std::format("{} {}", n, unit);
|
||||
}
|
||||
if (unit == "%")
|
||||
|
||||
@@ -46,6 +46,8 @@ namespace settings
|
||||
AntiAliasing,
|
||||
Scaling,
|
||||
AspectRatio,
|
||||
Brightness, // the game's picture (video/display_tone.h)
|
||||
DisplayGamma, // the 360's display gamma ramp (video/display_gamma.h)
|
||||
Sharpening,
|
||||
Anisotropy,
|
||||
Mipmaps,
|
||||
@@ -76,8 +78,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,91 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See display_gamma.h.
|
||||
#include "display_gamma.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace video::display_gamma
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// Command processor thread only.
|
||||
Decoder s_decoder;
|
||||
bool s_pwlLogged = false;
|
||||
|
||||
// The last complete table, for the presenter.
|
||||
std::mutex s_mutex;
|
||||
Table s_published;
|
||||
std::atomic<uint64_t> s_generation{ 0 };
|
||||
|
||||
// Since the process started (static initialisation), for the log.
|
||||
const auto s_launch = std::chrono::steady_clock::now();
|
||||
double Seconds()
|
||||
{
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - s_launch).count();
|
||||
}
|
||||
|
||||
// "8->5.7 16->12.5 ...": what a few framebuffer values become, in
|
||||
// 8-bit levels (r=g=b), or per channel when they differ.
|
||||
std::string Describe(const Table& t)
|
||||
{
|
||||
std::string out;
|
||||
bool grey = true;
|
||||
for (const auto& e : t.entry)
|
||||
grey = grey && e[0] == e[1] && e[1] == e[2];
|
||||
char buf[64];
|
||||
for (uint32_t k : { 8u, 16u, 32u, 64u, 96u, 128u, 160u, 192u, 224u, 255u })
|
||||
{
|
||||
const auto& e = t.entry[k];
|
||||
if (grey)
|
||||
snprintf(buf, sizeof(buf), " %u->%.1f", k, e[0] * 255.0 / 1023.0);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), " %u->%.1f/%.1f/%.1f", k, e[0] * 255.0 / 1023.0, e[1] * 255.0 / 1023.0,
|
||||
e[2] * 255.0 / 1023.0);
|
||||
out += buf;
|
||||
}
|
||||
return std::string(grey ? "r=g=b:" : "r/g/b:") + out + (t == Table::Identity() ? " (identity)" : "");
|
||||
}
|
||||
|
||||
void Publish(const Table& t)
|
||||
{
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
if (s_generation.load(std::memory_order_relaxed) != 0 && t == s_published)
|
||||
return; // the same ramp written again
|
||||
s_published = t;
|
||||
}
|
||||
uint64_t n = s_generation.fetch_add(1, std::memory_order_acq_rel) + 1;
|
||||
fprintf(stderr, "[video] display gamma ramp #%llu from the game at %.1f s: %s\n", (unsigned long long)n, Seconds(),
|
||||
Describe(t).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void OnRegisterWrite(uint32_t index, uint32_t value)
|
||||
{
|
||||
if (s_decoder.Write(index, value))
|
||||
{
|
||||
if (s_decoder.TableMode())
|
||||
Publish(s_decoder.table());
|
||||
}
|
||||
if (s_decoder.PwlWrites() && !s_pwlLogged)
|
||||
{
|
||||
s_pwlLogged = true;
|
||||
fprintf(stderr, "[video] display gamma: the game writes a PWL ramp (DC_LUT_PWL_DATA); only the 256-entry table is applied\n");
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t Generation()
|
||||
{
|
||||
return s_generation.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
uint64_t Latest(Table& out)
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
out = s_published;
|
||||
return s_generation.load(std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The display gamma ramp a game programs into the Xbox 360's display
|
||||
// controller: a 256-entry table (10 bits per channel) that the 360 applies to
|
||||
// every pixel at scan-out, after the GPU is done with the frame. Direct3D
|
||||
// writes it through the GPU's DC_LUT_* registers (PM4 type-0 register
|
||||
// writes), from the ramp the game passes to SetGammaRamp, adjusted for the
|
||||
// display by the kernel's VdGetCurrentDisplayGamma (gpu/imports_video.cpp).
|
||||
// Most Wanted and Carbon pass a power curve from their own brightness value
|
||||
// (Most Wanted's 360 menus never change it from the one it sets at boot).
|
||||
//
|
||||
// The command processor feeds every write of those registers to a Decoder
|
||||
// (video/display_gamma.cpp keeps the one in use); a complete table is handed
|
||||
// to the presenter, which applies it while the "Xbox 360 Display Gamma"
|
||||
// setting is on, as it is by default (video/display_tone.h).
|
||||
//
|
||||
// Register protocol after Xenia (src/xenia/gpu/register_table.inc and
|
||||
// CommandProcessor::WriteRegister, BSD license), which follows the M56
|
||||
// (ATI/AMD R5xx) display controller's documentation:
|
||||
// DC_LUT_RW_MODE (0x1921) bit 0: 0 = the 256-entry table, 1 = PWL
|
||||
// DC_LUT_RW_INDEX (0x1922) the entry the next access goes to (bits 0:7);
|
||||
// a write also restarts DC_LUT_SEQ_COLOR at red
|
||||
// DC_LUT_SEQ_COLOR (0x1923) one 10-bit component per write (bits 6:15):
|
||||
// red, green, blue, then the next entry
|
||||
// DC_LUT_PWL_DATA (0x1924) a PWL segment (base 6:15, delta 22:31); three
|
||||
// writes (R, G, B) an entry, 128 entries
|
||||
// DC_LUT_30_COLOR (0x1925) a whole entry: blue 0:9, green 10:19, red
|
||||
// 20:29; then the index moves on by one
|
||||
// DC_LUT_WRITE_EN_MASK (0x1927) which components a write changes: bit 0
|
||||
// blue, 1 green, 2 red
|
||||
// DC_LUTA_CONTROL (0x1930) 0: the table; other values select PWL
|
||||
// Direct3D writes RW_MODE 0, RW_INDEX 0, WRITE_EN_MASK 7, then for each entry
|
||||
// DC_LUT_30_COLOR and RW_INDEX = the next index (a COND_WRITE once the color
|
||||
// register reads back), ending with RW_INDEX = 0x100.
|
||||
//
|
||||
// Header only, no runtime dependencies (tests/display_tone_test.cpp).
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <bitset>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::display_gamma
|
||||
{
|
||||
constexpr uint32_t kRegRwMode = 0x1921;
|
||||
constexpr uint32_t kRegRwIndex = 0x1922;
|
||||
constexpr uint32_t kRegSeqColor = 0x1923;
|
||||
constexpr uint32_t kRegPwlData = 0x1924;
|
||||
constexpr uint32_t kRegColor30 = 0x1925;
|
||||
constexpr uint32_t kRegWriteEnMask = 0x1927;
|
||||
constexpr uint32_t kRegLutaControl = 0x1930;
|
||||
|
||||
// The registers the Decoder follows (the command processor's cheap test).
|
||||
constexpr bool IsLutRegister(uint32_t index)
|
||||
{
|
||||
return index - kRegRwMode <= kRegLutaControl - kRegRwMode;
|
||||
}
|
||||
|
||||
// 256 entries of 10-bit red, green, blue: what the display shows for a
|
||||
// framebuffer value k is entry[k] / 1023.
|
||||
struct Table
|
||||
{
|
||||
std::array<std::array<uint16_t, 3>, 256> entry{};
|
||||
|
||||
bool operator==(const Table&) const = default;
|
||||
|
||||
// The ramp that changes nothing: k * 1023 / 255, rounded.
|
||||
static constexpr Table Identity()
|
||||
{
|
||||
Table t;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
t.entry[k] = { uint16_t((k * 1023 + 127) / 255), uint16_t((k * 1023 + 127) / 255), uint16_t((k * 1023 + 127) / 255) };
|
||||
return t;
|
||||
}
|
||||
};
|
||||
|
||||
class Decoder
|
||||
{
|
||||
public:
|
||||
// One register write, in command-stream order. True when it completed
|
||||
// the table: every entry written since the last completion (Direct3D
|
||||
// rewrites all 256 each time). Writes outside the LUT registers are
|
||||
// ignored.
|
||||
bool Write(uint32_t index, uint32_t value)
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case kRegRwMode:
|
||||
mode_ = value & 1;
|
||||
return false;
|
||||
case kRegRwIndex:
|
||||
index_ = value & 0xFF;
|
||||
component_ = 0;
|
||||
return false;
|
||||
case kRegWriteEnMask:
|
||||
mask_ = value & 7;
|
||||
return false;
|
||||
case kRegLutaControl:
|
||||
lutaControl_ = value;
|
||||
return false;
|
||||
case kRegColor30:
|
||||
{
|
||||
if (mode_ != 0)
|
||||
return false; // the PWL table is written through DC_LUT_PWL_DATA
|
||||
uint32_t k = index_;
|
||||
if (mask_ & 4) Set(k, 0, (value >> 20) & 0x3FF);
|
||||
if (mask_ & 2) Set(k, 1, (value >> 10) & 0x3FF);
|
||||
if (mask_ & 1) Set(k, 2, value & 0x3FF);
|
||||
if (mask_)
|
||||
written_.set(k);
|
||||
component_ = 0;
|
||||
index_ = (index_ + 1) & 0xFF;
|
||||
return Completed();
|
||||
}
|
||||
case kRegSeqColor:
|
||||
{
|
||||
if (mode_ != 0)
|
||||
return false;
|
||||
uint32_t k = index_;
|
||||
// Components in red, green, blue order; the mask's bits are
|
||||
// blue, green, red. Bits 0:5 are hardwired to zero.
|
||||
bool enabled = (mask_ >> (2 - component_)) & 1;
|
||||
if (enabled)
|
||||
{
|
||||
Set(k, component_, (value >> 6) & 0x3FF);
|
||||
seqWritten_[component_] = true;
|
||||
}
|
||||
if (++component_ == 3)
|
||||
{
|
||||
component_ = 0;
|
||||
if (seqWritten_[0] || seqWritten_[1] || seqWritten_[2])
|
||||
written_.set(k);
|
||||
seqWritten_ = {};
|
||||
index_ = (index_ + 1) & 0xFF;
|
||||
return Completed();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case kRegPwlData:
|
||||
pwlWrites_++;
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const Table& table() const { return table_; }
|
||||
// The table is what the display uses (not the PWL ramp).
|
||||
bool TableMode() const { return (lutaControl_ & 0xF) == 0; }
|
||||
// DC_LUT_PWL_DATA writes seen (a game that uses the PWL ramp: not
|
||||
// applied, logged).
|
||||
uint32_t PwlWrites() const { return pwlWrites_; }
|
||||
// Entries written since the last completed table.
|
||||
size_t Pending() const { return written_.count(); }
|
||||
|
||||
private:
|
||||
void Set(uint32_t k, uint32_t c, uint32_t v) { table_.entry[k][c] = uint16_t(v); }
|
||||
|
||||
bool Completed()
|
||||
{
|
||||
if (!written_.all())
|
||||
return false;
|
||||
written_.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
Table table_ = Table::Identity();
|
||||
std::bitset<256> written_;
|
||||
std::array<bool, 3> seqWritten_{};
|
||||
uint32_t mode_ = 0, index_ = 0, component_ = 0, mask_ = 7, lutaControl_ = 0, pwlWrites_ = 0;
|
||||
};
|
||||
|
||||
// The runtime's ramp (video/display_gamma.cpp). Command processor thread:
|
||||
// every write of a LUT register (IsLutRegister), in stream order; a
|
||||
// completed table that differs from the last is published and logged.
|
||||
void OnRegisterWrite(uint32_t index, uint32_t value);
|
||||
// Any thread: bumped by each published table; 0 while the game hasn't
|
||||
// written one.
|
||||
uint64_t Generation();
|
||||
// The last published table and its generation (0: none, `out` is then
|
||||
// the identity).
|
||||
uint64_t Latest(Table& out);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The picture's tone curve in the presenter's last pass (video/presenter.cpp):
|
||||
// Settings > Display > Brightness, and "Xbox 360 Display Gamma" (on by
|
||||
// default: the ramp the game programs into the 360's display output,
|
||||
// video/display_gamma.h). Both only touch the game's picture, never the menus
|
||||
// drawn over it, and with Brightness 0 and the ramp off nothing is applied at
|
||||
// all: the presenter draws with the same pipeline as before (Neutral()).
|
||||
//
|
||||
// Brightness is a power curve on the picture's values, as a console's
|
||||
// brightness option (and the game's own) works: black stays black, white
|
||||
// stays white, the shadows and mid-tones move. Each step multiplies the
|
||||
// exponent by kStepRatio: mid-grey moves about 6 levels of 255 a step, and
|
||||
// the ends (-5, +5) reach about as far as the games' own brightness range
|
||||
// does from its default (their ramp's exponent runs 1.06 .. 0.57 around
|
||||
// 0.815: x1.30 .. x0.70; here x1.40 .. x0.71).
|
||||
//
|
||||
// The 360's ramp, when on, comes first (it is the console's output, before
|
||||
// the TV), then Brightness.
|
||||
//
|
||||
// The lookup table is 256 entries per channel, for framebuffer values k/255;
|
||||
// the shader samples it with linear filtering at (v * 255 + 0.5) / 256, so a
|
||||
// value between two entries (a scaled pixel) is interpolated.
|
||||
//
|
||||
// Header only, no runtime dependencies (tests/display_tone_test.cpp).
|
||||
#pragma once
|
||||
#include "display_gamma.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace video::tone
|
||||
{
|
||||
constexpr int32_t kBrightnessMin = -5;
|
||||
constexpr int32_t kBrightnessMax = 5;
|
||||
constexpr int32_t kBrightnessDefault = 0;
|
||||
// The exponent's factor per step (brighter: smaller exponent).
|
||||
constexpr double kStepRatio = 0.935;
|
||||
|
||||
// The power applied to the picture's values at a Brightness step
|
||||
// (clamped to the range): 1 at 0.
|
||||
inline double BrightnessExponent(int32_t step)
|
||||
{
|
||||
step = std::clamp(step, kBrightnessMin, kBrightnessMax);
|
||||
return step == 0 ? 1.0 : std::pow(kStepRatio, double(step));
|
||||
}
|
||||
|
||||
// Brightness alone on one value in 0..1.
|
||||
inline double ApplyBrightness(double v, int32_t step)
|
||||
{
|
||||
v = std::clamp(v, 0.0, 1.0);
|
||||
double e = BrightnessExponent(step);
|
||||
return e == 1.0 ? v : std::pow(v, e);
|
||||
}
|
||||
|
||||
// Nothing to apply: the presenter keeps its plain pipeline.
|
||||
inline bool Neutral(int32_t brightness, bool useRamp)
|
||||
{
|
||||
return std::clamp(brightness, kBrightnessMin, kBrightnessMax) == 0 && !useRamp;
|
||||
}
|
||||
|
||||
// out[k][c]: what framebuffer value k/255 of channel c (red, green, blue)
|
||||
// becomes, 0..1.
|
||||
struct Lut
|
||||
{
|
||||
std::array<std::array<float, 3>, 256> entry{};
|
||||
};
|
||||
|
||||
// The table for a Brightness step and, when `ramp` is given, the 360's
|
||||
// display ramp before it.
|
||||
inline Lut BuildLut(int32_t brightness, const display_gamma::Table* ramp)
|
||||
{
|
||||
Lut lut;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
{
|
||||
double v = ramp ? ramp->entry[k][c] / 1023.0 : k / 255.0;
|
||||
lut.entry[k][c] = float(ApplyBrightness(v, brightness));
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
// IEEE half (the LUT image's R16G16B16A16_SFLOAT) from a float in 0..1,
|
||||
// rounded to nearest even. Values outside 0..1 are clamped.
|
||||
inline uint16_t ToHalf(float f)
|
||||
{
|
||||
f = std::clamp(f, 0.0f, 1.0f);
|
||||
if (f == 0.0f)
|
||||
return 0;
|
||||
int e = 0;
|
||||
float m = std::frexp(f, &e); // f = m * 2^e, m in [0.5, 1)
|
||||
// Half: value = (1 + frac/1024) * 2^(exp - 15), exp 1..30 (normal),
|
||||
// or frac/1024 * 2^-14 (subnormal, exp 0).
|
||||
int exp = e - 1 + 15;
|
||||
if (exp <= 0)
|
||||
{
|
||||
// Subnormal: frac = f * 2^24.
|
||||
double frac = std::nearbyint(double(f) * 16777216.0);
|
||||
return uint16_t(frac);
|
||||
}
|
||||
double frac = (double(m) * 2.0 - 1.0) * 1024.0;
|
||||
double r = std::nearbyint(frac); // ties to even (default rounding mode)
|
||||
if (r >= 1024.0)
|
||||
{
|
||||
r = 0.0;
|
||||
exp++;
|
||||
}
|
||||
return uint16_t((exp << 10) | int(r));
|
||||
}
|
||||
|
||||
inline float FromHalf(uint16_t h)
|
||||
{
|
||||
int exp = (h >> 10) & 0x1F, frac = h & 0x3FF;
|
||||
if (exp == 0)
|
||||
return std::ldexp(float(frac), -24);
|
||||
return std::ldexp(1.0f + frac / 1024.0f, exp - 15);
|
||||
}
|
||||
|
||||
// The LUT image's texels: 256 x RGBA16F (alpha 1).
|
||||
inline std::array<uint16_t, 256 * 4> ToHalfTexels(const Lut& lut)
|
||||
{
|
||||
std::array<uint16_t, 256 * 4> texels{};
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
{
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
texels[k * 4 + c] = ToHalf(lut.entry[k][c]);
|
||||
texels[k * 4 + 3] = ToHalf(1.0f);
|
||||
}
|
||||
return texels;
|
||||
}
|
||||
|
||||
// What the shader does with the table (kToneGlsl in presenter.cpp), for
|
||||
// one channel value v in 0..1: linear between the two entries around
|
||||
// v * 255.
|
||||
inline double Sample(const Lut& lut, uint32_t channel, double v)
|
||||
{
|
||||
double x = std::clamp(v, 0.0, 1.0) * 255.0;
|
||||
uint32_t i = std::min(uint32_t(x), 254u);
|
||||
double t = x - i;
|
||||
return lut.entry[i][channel] * (1.0 - t) + lut.entry[i + 1][channel] * t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Display > Fullscreen and the window's own fullscreen state.
|
||||
//
|
||||
// The setting is the player's choice and the only thing that decides how the
|
||||
// game starts. Changing it in the menu (or Reset page) also puts the window
|
||||
// in that state now. The system can change the window too: the green button
|
||||
// or Window > Enter Full Screen (Ctrl+Cmd+F) on a Mac, a window manager's
|
||||
// fullscreen key on a Linux desktop, or a macOS fullscreen Space that fails
|
||||
// to open at launch (SDL then reports the window leaving fullscreen). Those
|
||||
// change the window for this session only and never the setting: until
|
||||
// v0.1.3 every such change was written into the setting, so the next launch
|
||||
// was fullscreen or not depending on how the window happened to be when the
|
||||
// game last saved (a quit from a green-button fullscreen started fullscreen;
|
||||
// a failed Space at launch turned the setting off).
|
||||
//
|
||||
// The menu shows the saved choice; when the window differs from it, the row
|
||||
// says what the window is now ("NOW FULLSCREEN" / "NOW WINDOWED") and its
|
||||
// help line which way the game starts (ui/settings_menu.cpp).
|
||||
//
|
||||
// Main thread. Header only (tests/fullscreen_policy_test.cpp).
|
||||
#pragma once
|
||||
#include <optional>
|
||||
|
||||
namespace video
|
||||
{
|
||||
class FullscreenPolicy
|
||||
{
|
||||
public:
|
||||
// Who changed the window, for its log line.
|
||||
enum class Change
|
||||
{
|
||||
Requested, // the game asked for it (launch, the setting): nothing to do
|
||||
System, // the system did: the window only, the setting stays
|
||||
};
|
||||
|
||||
// At launch: whether to create the window fullscreen. `setting` is
|
||||
// the effective value (NFSMW_FULLSCREEN or settings.toml); `locked`:
|
||||
// always fullscreen (SteamOS Game Mode, iOS).
|
||||
bool Launch(bool setting, bool locked)
|
||||
{
|
||||
m_locked = locked;
|
||||
m_setting = setting;
|
||||
m_window = m_requested = locked || setting;
|
||||
return m_window;
|
||||
}
|
||||
|
||||
// The setting's effective value now (checked whenever the settings
|
||||
// change: the menu, Reset page). When it changed, the window is to be
|
||||
// put in that state: the value to ask SDL for, unless the window is
|
||||
// there already or locked.
|
||||
std::optional<bool> SettingIs(bool setting)
|
||||
{
|
||||
if (m_locked || setting == m_setting)
|
||||
return std::nullopt;
|
||||
m_setting = setting;
|
||||
if (setting == m_window)
|
||||
return std::nullopt;
|
||||
m_window = m_requested = setting;
|
||||
return setting;
|
||||
}
|
||||
|
||||
// SDL reported the window entering (true) or leaving fullscreen. The
|
||||
// setting is never touched: a change the game didn't ask for is the
|
||||
// system's, for this session only.
|
||||
Change WindowIs(bool fullscreen)
|
||||
{
|
||||
m_window = fullscreen;
|
||||
if (fullscreen == m_requested)
|
||||
return Change::Requested;
|
||||
m_requested = fullscreen;
|
||||
return Change::System;
|
||||
}
|
||||
|
||||
bool Window() const { return m_window; } // the window's state
|
||||
bool Setting() const { return m_setting; } // the choice as last applied
|
||||
// The window isn't as the setting says (a system change this session).
|
||||
bool WindowDiffers() const { return !m_locked && m_window != m_setting; }
|
||||
|
||||
private:
|
||||
bool m_locked = false;
|
||||
bool m_setting = false;
|
||||
bool m_window = false;
|
||||
bool m_requested = false; // what the game last asked for (or the system last did)
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// When a new frame is shown at a set time after its guest vblank (macOS:
|
||||
// VK_GOOGLE_display_timing's desiredPresentTime, video/presenter.cpp), and
|
||||
// the watchdog that turns that off for the session when such frames don't
|
||||
// reach the screen.
|
||||
//
|
||||
// It helps where it was measured to: a MacBook Pro's 120 Hz ProMotion panel
|
||||
// in fullscreen, which shows each frame when its GPU work is done (on a 240 Hz
|
||||
// timing grid), so frames shown when ready came 8-29 ms apart while driving,
|
||||
// and 99.9% of them 16.67 ms apart when shown at a time. On a 60 Hz display
|
||||
// the guest vblank locks to the refresh grid instead (VblankLock), and frames
|
||||
// are shown when ready, as in v0.1.3: in a macOS 15 VM (Apple's paravirtual
|
||||
// display, 60 Hz) frames shown at a time left the fullscreen picture black
|
||||
// while the game ran at 60 fps, and spoiled the present-wait times the lock
|
||||
// fits (rms 1.3-4.3 ms against its 0.5 ms limit, where v0.1.3 had 0.3-0.4),
|
||||
// so it never locked. There Metal didn't keep the time asked for: from ~10 s
|
||||
// in, 97% of the frames were reported shown 9 ms (median) BEFORE it, right
|
||||
// after their present, while the screen stayed black (no time-base mix-up:
|
||||
// the steady clock and Metal's host time agreed there to the microsecond).
|
||||
// On the ProMotion panel no frame of 46,000 was reported more than 1.2 ms
|
||||
// before its time. So, with no NFSMW_PRESENT_AT (Auto):
|
||||
// - only on a display whose mode is a 120 Hz one (117-123 Hz: twice the
|
||||
// lock's 60 Hz range), and only while the vblank isn't locked to it (a
|
||||
// ProMotion panel has no refresh grid to lock to; should the lock take
|
||||
// it, frames are shown when ready until it lets go);
|
||||
// - never once the watchdog has tripped.
|
||||
// NFSMW_PRESENT_AT=<ms> > 0 shows them at a time on any display (On, still
|
||||
// watched); NFSMW_PRESENT_AT=0 never (Off).
|
||||
//
|
||||
// The watchdog reads the driver's report on each frame shown at a time
|
||||
// (VK_GOOGLE_display_timing's past presentation timing: Metal's presentedTime,
|
||||
// 0 when the drawable was never shown) and trips when, of the last kWindow
|
||||
// such frames reported, kBadOfWindow or more were shown more than kEarlyNs
|
||||
// before their time (the time not kept: the VM's black screen), never shown,
|
||||
// or shown more than kLateNs after it; or when kUnreported frames shown at a
|
||||
// time in a row have been presented without any report coming back. One
|
||||
// frame shown late now and then (its GPU work not done in time) is normal:
|
||||
// on the ProMotion panel up to 21 ms. In the VM the first seconds' frames were
|
||||
// each reported ~100 ms after their present (88 ms after their time), and
|
||||
// then, from ~10 s, the display froze on its last picture for the rest of the
|
||||
// run, frames reported early (above); the freeze outlasted the trip, so the
|
||||
// trip must come before it.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace video
|
||||
{
|
||||
class PresentSchedule
|
||||
{
|
||||
public:
|
||||
enum class Mode
|
||||
{
|
||||
Auto, // NFSMW_PRESENT_AT unset
|
||||
On, // NFSMW_PRESENT_AT=<ms> > 0
|
||||
Off, // NFSMW_PRESENT_AT=0
|
||||
};
|
||||
static constexpr double kMinFastHz = 117.0, kMaxFastHz = 123.0;
|
||||
static constexpr uint32_t kWindow = 120; // 2 s of frames
|
||||
static constexpr uint32_t kBadOfWindow = 60;
|
||||
static constexpr int64_t kEarlyNs = 2'000'000;
|
||||
static constexpr int64_t kLateNs = 50'000'000;
|
||||
static constexpr uint32_t kUnreported = 180; // 3 s of frames
|
||||
|
||||
explicit PresentSchedule(Mode mode = Mode::Auto) : m_mode(mode) {}
|
||||
|
||||
Mode GetMode() const { return m_mode; }
|
||||
// The display's mode, at each swapchain and display change (0: unknown).
|
||||
void SetDisplayHz(double hz) { m_fastDisplay = hz >= kMinFastHz && hz <= kMaxFastHz; }
|
||||
bool FastDisplay() const { return m_fastDisplay; }
|
||||
|
||||
// A new frame, just before its present: show it at its vblank's
|
||||
// time? `locked`: the guest vblank is locked to the display.
|
||||
bool Wanted(bool locked) const
|
||||
{
|
||||
if (m_tripped || m_mode == Mode::Off)
|
||||
return false;
|
||||
return m_mode == Mode::On || (m_fastDisplay && !locked);
|
||||
}
|
||||
|
||||
// A frame shown at a time was presented (its present wait returned).
|
||||
// True: this trips the watchdog (no report for kUnreported of them).
|
||||
bool Presented()
|
||||
{
|
||||
if (m_tripped)
|
||||
return false;
|
||||
if (++m_unreported < kUnreported)
|
||||
return false;
|
||||
m_tripped = true;
|
||||
m_reason = Reason::Unreported;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The driver's report on a frame shown at a time: whether it was
|
||||
// shown, and how long after its time (ns; below 0: before). True:
|
||||
// this trips the watchdog.
|
||||
bool Reported(bool shown, int64_t lateNs)
|
||||
{
|
||||
m_unreported = 0;
|
||||
if (m_tripped)
|
||||
return false;
|
||||
const bool bad = !shown || lateNs < -kEarlyNs || lateNs > kLateNs;
|
||||
m_early += shown && lateNs < -kEarlyNs;
|
||||
if (m_count == kWindow)
|
||||
m_bad -= m_window[m_next];
|
||||
else
|
||||
m_count++;
|
||||
m_window[m_next] = bad;
|
||||
m_bad += bad;
|
||||
m_next = (m_next + 1) % kWindow;
|
||||
m_notShown += !shown;
|
||||
if (m_bad < kBadOfWindow)
|
||||
return false;
|
||||
m_tripped = true;
|
||||
m_reason = Reason::NotKept;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Tripped() const { return m_tripped; }
|
||||
enum class Reason
|
||||
{
|
||||
None,
|
||||
NotKept, // kBadOfWindow of the last kWindow shown early, never, or far late
|
||||
Unreported, // kUnreported presented without a report
|
||||
};
|
||||
Reason GetReason() const { return m_reason; }
|
||||
uint32_t BadInWindow() const { return m_bad; }
|
||||
uint32_t ReportedInWindow() const { return m_count; }
|
||||
uint64_t NotShown() const { return m_notShown; }
|
||||
uint64_t Early() const { return m_early; }
|
||||
|
||||
private:
|
||||
Mode m_mode;
|
||||
bool m_fastDisplay = false;
|
||||
bool m_tripped = false;
|
||||
Reason m_reason = Reason::None;
|
||||
bool m_window[kWindow] = {};
|
||||
uint32_t m_next = 0, m_count = 0, m_bad = 0;
|
||||
uint32_t m_unreported = 0;
|
||||
uint64_t m_notShown = 0, m_early = 0;
|
||||
};
|
||||
}
|
||||
@@ -153,6 +153,12 @@ namespace video
|
||||
// there.
|
||||
bool FullscreenLocked();
|
||||
|
||||
// Main thread: whether the window is fullscreen now, and whether that
|
||||
// differs from Settings > Display > Fullscreen because the system changed
|
||||
// it this session (the green button; video/fullscreen_policy.h).
|
||||
bool WindowFullscreen();
|
||||
bool FullscreenWindowDiffers();
|
||||
|
||||
// SteamOS Game Mode (gamescope; NFSMW_GAME_MODE=1|0 says otherwise), as
|
||||
// detected when the window was made. No desktop is on screen there, so a
|
||||
// system file dialog can't be seen either.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See vblank_timer.h.
|
||||
#include "vblank_timer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <string_view>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
// macOS only: iOS keeps the ordinary thread (its vblank timing hasn't been measured).
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS
|
||||
#define SB_REALTIME_VBLANK 1
|
||||
#include <mach/mach.h>
|
||||
#include <mach/mach_time.h>
|
||||
#include <mach/thread_policy.h>
|
||||
#include <pthread.h>
|
||||
#endif
|
||||
|
||||
namespace video
|
||||
{
|
||||
namespace
|
||||
{
|
||||
#ifdef SB_REALTIME_VBLANK
|
||||
uint32_t ToMach(int64_t ns)
|
||||
{
|
||||
static const mach_timebase_info_data_t timebase = [] {
|
||||
mach_timebase_info_data_t tb{};
|
||||
mach_timebase_info(&tb);
|
||||
return tb;
|
||||
}();
|
||||
return uint32_t(double(std::max<int64_t>(ns, 0)) * double(timebase.denom) / double(timebase.numer));
|
||||
}
|
||||
#endif
|
||||
|
||||
int64_t Percentile(std::vector<int64_t> v, size_t permille)
|
||||
{
|
||||
if (v.empty())
|
||||
return 0;
|
||||
size_t k = std::min(v.size() - 1, v.size() * permille / 1000);
|
||||
std::nth_element(v.begin(), v.begin() + ptrdiff_t(k), v.end());
|
||||
return v[k];
|
||||
}
|
||||
}
|
||||
|
||||
VblankTimer ChosenVblankTimer()
|
||||
{
|
||||
const char* v = std::getenv("NFSMW_VBLANK_TIMER");
|
||||
return v && std::string_view(v) == "sleep" ? VblankTimer::Ordinary : VblankTimer::RealTime;
|
||||
}
|
||||
|
||||
bool PrepareVblankThread(VblankTimer timer, int64_t periodNs)
|
||||
{
|
||||
#ifdef SB_REALTIME_VBLANK
|
||||
if (timer != VblankTimer::RealTime)
|
||||
return false;
|
||||
// Woken at its time, ahead of the game's own threads: it runs the
|
||||
// guest's vblank handler (microseconds) and sleeps again.
|
||||
thread_time_constraint_policy_data_t policy{};
|
||||
policy.period = ToMach(periodNs);
|
||||
policy.computation = ToMach(1'000'000);
|
||||
policy.constraint = ToMach(2'000'000);
|
||||
policy.preemptible = 1;
|
||||
return thread_policy_set(pthread_mach_thread_np(pthread_self()), THREAD_TIME_CONSTRAINT_POLICY,
|
||||
reinterpret_cast<thread_policy_t>(&policy), THREAD_TIME_CONSTRAINT_POLICY_COUNT) == KERN_SUCCESS;
|
||||
#else
|
||||
(void)timer;
|
||||
(void)periodNs;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
const char* VblankTimerName(VblankTimer timer, bool realtime)
|
||||
{
|
||||
#ifdef SB_REALTIME_VBLANK
|
||||
if (timer == VblankTimer::RealTime)
|
||||
return realtime ? "real-time" : "ordinary (real-time refused)";
|
||||
return "ordinary (NFSMW_VBLANK_TIMER=sleep)";
|
||||
#else
|
||||
(void)timer;
|
||||
(void)realtime;
|
||||
return "ordinary";
|
||||
#endif
|
||||
}
|
||||
|
||||
void VblankTiming::Add(int64_t lateNs, int64_t handlerNs)
|
||||
{
|
||||
if (m_late.size() < kCount)
|
||||
{
|
||||
m_late.push_back(lateNs);
|
||||
m_handler.push_back(handlerNs);
|
||||
return;
|
||||
}
|
||||
m_late[m_next] = lateNs;
|
||||
m_handler[m_next] = handlerNs;
|
||||
m_next = (m_next + 1) % kCount;
|
||||
}
|
||||
|
||||
VblankTiming::Summary VblankTiming::Summarize() const
|
||||
{
|
||||
Summary s;
|
||||
s.count = uint32_t(m_late.size());
|
||||
if (m_late.empty())
|
||||
return s;
|
||||
s.lateMedianNs = double(Percentile(m_late, 500));
|
||||
s.late99Ns = double(Percentile(m_late, 990));
|
||||
s.lateMaxNs = double(*std::max_element(m_late.begin(), m_late.end()));
|
||||
s.handler99Ns = double(Percentile(m_handler, 990));
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The guest vblank's timer (gpu/command_processor.cpp's Vsync thread): how
|
||||
// that thread is scheduled, and how late the vblanks fire.
|
||||
//
|
||||
// macOS: the thread slept with std::this_thread::sleep_until as an ordinary
|
||||
// thread, a timer macOS lets fire late (coalescing): on a MacBook Pro its
|
||||
// vblanks came 3.2 ms late at the median and up to 9 ms (8.5-25 ms apart
|
||||
// instead of 16.667), idle or not, and the game's frames left for the display
|
||||
// that unevenly. At the user-interactive class it was the same (3.3 ms), and
|
||||
// mach_wait_until didn't help either: what does is real-time scheduling, the
|
||||
// time-constraint policy Core Audio's threads run under, which the thread now
|
||||
// asks for (0.01 ms late at the median, 0.02 in 99%). NFSMW_VBLANK_TIMER=sleep
|
||||
// keeps the ordinary thread (an A/B switch). Elsewhere nothing changes: on the
|
||||
// Steam Machine (Linux's high-resolution timers) the vblanks were already
|
||||
// within 0.06 ms of their time.
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace video
|
||||
{
|
||||
enum class VblankTimer
|
||||
{
|
||||
Ordinary, // the thread as created
|
||||
RealTime, // macOS: the time-constraint policy; elsewhere (iOS too) Ordinary
|
||||
};
|
||||
|
||||
// NFSMW_VBLANK_TIMER=sleep: Ordinary; otherwise RealTime.
|
||||
VblankTimer ChosenVblankTimer();
|
||||
// The calling thread, once, before its first sleep: its scheduling for
|
||||
// vblanks `periodNs` apart. True: real-time scheduling was granted.
|
||||
bool PrepareVblankThread(VblankTimer timer, int64_t periodNs);
|
||||
// For the log: "real-time", "ordinary (NFSMW_VBLANK_TIMER=sleep)",
|
||||
// "ordinary (real-time refused)" or, off macOS, "ordinary".
|
||||
const char* VblankTimerName(VblankTimer timer, bool realtime);
|
||||
|
||||
// The last minute of vblanks: how late each fired after its time (the
|
||||
// timer's wake-up, or a guest interrupt handler before it that ran past
|
||||
// it) and how long the guest's interrupt handler ran. One thread.
|
||||
class VblankTiming
|
||||
{
|
||||
public:
|
||||
static constexpr size_t kCount = 3600;
|
||||
|
||||
void Add(int64_t lateNs, int64_t handlerNs);
|
||||
struct Summary
|
||||
{
|
||||
uint32_t count = 0;
|
||||
double lateMedianNs = 0, late99Ns = 0, lateMaxNs = 0;
|
||||
double handler99Ns = 0;
|
||||
};
|
||||
Summary Summarize() const;
|
||||
|
||||
private:
|
||||
std::vector<int64_t> m_late, m_handler;
|
||||
size_t m_next = 0;
|
||||
};
|
||||
}
|
||||
@@ -1,30 +1,173 @@
|
||||
# Building SpeedBreaker for iPhone and iPad
|
||||
# SpeedBreaker on iPhone and iPad
|
||||
|
||||
There's no download for iPhone and iPad, and SpeedBreaker isn't on the App Store. You build it on
|
||||
a Mac and install it on your own device, signed with your own Apple developer account. Nothing
|
||||
here is distributed: the app you build is yours.
|
||||
SpeedBreaker isn't on the App Store. There are two ways to get it on your iPhone or iPad:
|
||||
|
||||
## What you need
|
||||
- **Sideload the IPA** (experimental, from v0.1.2): a sideloading tool signs it with your own
|
||||
Apple Account and installs it. Read [the memory caveat](#the-memory-caveat) first: with a free
|
||||
Apple Account it may not start, depending on the tool.
|
||||
- **Build it yourself** on a Mac with Xcode, signed with your own Apple developer account: see
|
||||
[Building it yourself](#building-it-yourself). This is how it was developed and tested.
|
||||
|
||||
- **A Mac** with Apple silicon and **Xcode** (built with Xcode 26.4), plus everything the Mac
|
||||
build needs: see [Building from source](../../README.md#building-from-source), step 1.
|
||||
- **Your own disc image** of the USA Xbox 360 disc, for step 2 of that guide (the game's code is
|
||||
translated from it on your Mac) and later for the installer on the device.
|
||||
## What every device needs
|
||||
|
||||
- **iOS or iPadOS 17 or later** and an **A15-class chip or newer**: iPhone 13 and later, iPads
|
||||
with an M-series chip. It was tested on an iPhone 15 Pro Max, an iPhone 18 Pro Max and an iPad
|
||||
Pro 12.9-inch (M2), on version 27. Devices with 6 GB of memory or more are recommended; 4 GB
|
||||
ones (iPhone 13 and 13 mini, iPhone SE 3rd generation, iPad mini 6th generation) are untested
|
||||
and likely too small.
|
||||
- **A controller, strongly recommended**: an Xbox or PlayStation controller over Bluetooth, or a
|
||||
Backbone or similar. There are no touch controls for driving (a keyboard works too). A
|
||||
three-finger tap opens the settings.
|
||||
- **Your own disc image** of the USA Xbox 360 disc (an `.iso`, about 7.8 GB), and room for it plus
|
||||
the installed game (about 7.1 GB) while it installs. You can delete the image afterwards.
|
||||
|
||||
## Sideloading the IPA (experimental)
|
||||
|
||||
The IPA, `SpeedBreaker-<version>-ios.ipa`, is signed by nobody: it carries only the entitlements
|
||||
the game asks for, and the tool you sideload it with signs it with your Apple Account. Check the
|
||||
download against its `.sha256` file (`shasum -a 256 -c SpeedBreaker-<version>-ios.ipa.sha256`).
|
||||
|
||||
### The memory caveat
|
||||
|
||||
SpeedBreaker lays out the Xbox 360's memory in one block of 4 GB of address space, needs about 3 GB
|
||||
more address space besides, and at its busiest uses about 2.5 GB of memory ([Memory](#memory)).
|
||||
iOS doesn't give an app that much by default, so the app asks for two entitlements:
|
||||
|
||||
- **Extended Virtual Addressing** lifts iOS's address-space limit. Only a paid Apple Developer
|
||||
Program account (or an enterprise one) can sign it: Apple's
|
||||
[capability table](https://developer.apple.com/help/account/reference/supported-capabilities-ios)
|
||||
lists it for those memberships and not for a free Apple Account, Xcode refuses it for a free
|
||||
Personal Team, and the sideloading tools drop it for free accounts.
|
||||
- **Increased Memory Limit** raises the memory limit, from about 3.3 GB to about 6 GB on an 8 GB
|
||||
iPhone (about 2.9 GB without it on a 6 GB one). On iOS 16 and later, on devices that support
|
||||
it, it also lifts the address-space limit as Extended Virtual Addressing does
|
||||
([xnu, kern_exec.c](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_exec.c)).
|
||||
Apple gives it to free Apple Accounts too, but not every tool asks for it.
|
||||
|
||||
What that means for each way of signing (as of October 2026). The IPA itself hasn't been installed on a
|
||||
device yet: the tested iPhones and iPad ran the same app built and signed with Xcode and a paid account.
|
||||
|
||||
| Signed with | Entitlements the app gets | Expected |
|
||||
|---|---|---|
|
||||
| A paid Apple Developer account, Xcode (building it yourself) | both | works, as tested |
|
||||
| A paid Apple Developer account, a sideloading tool | depends on the tool (AltStore keeps only Increased Memory Limit) | likely works; untested |
|
||||
| A free Apple Account, [AltStore Classic](https://altstore.io) 2.2 or later | Increased Memory Limit | likely works on devices that support it |
|
||||
| A free Apple Account, Xcode Personal Team (building it yourself) | Increased Memory Limit, after removing the other one | likely works on devices that support it |
|
||||
| A free Apple Account, [SideStore](https://sidestore.io) 0.7 | neither ([SideStore #1616](https://github.com/SideStore/SideStore/issues/1616)) | may start; iOS may close it in busy scenes |
|
||||
| A free Apple Account, [Sideloadly](https://sideloadly.io) | unknown | unknown until tested |
|
||||
|
||||
Without either entitlement iOS gives an app 17 GB of address space, about 7 GB of it usable: the
|
||||
4 GB can still fit on a device with more than 3 GB of memory (about 5 GB is free in one piece above
|
||||
the system's shared region, and the game reserves its 4 GB first thing), but everything else then
|
||||
has to fit in the 3 GB or so left, and the game in iOS's default memory limit. When the 4 GB
|
||||
doesn't fit, the game says so instead of closing: a page titled **This copy of SpeedBreaker can't
|
||||
start**, whose Details line names the entitlements this copy was signed with. When it starts with
|
||||
iOS's default address space, or a memory limit below about 3 GB, a notice says iOS may close the
|
||||
game in busy scenes.
|
||||
|
||||
Coming from a build you made yourself, the IPA installs as a separate app (the tools give it
|
||||
another bundle identifier), with its own storage: install the game again from your disc image.
|
||||
Saves made in the old app stay with it.
|
||||
|
||||
### Free Apple Account limits
|
||||
|
||||
These are Apple's, whatever the tool ([AltStore's FAQ](https://faq.altstore.io/altstore-classic/your-altstore),
|
||||
[error codes](https://faq.altstore.io/altstore-world/error-codes)):
|
||||
|
||||
- **7 days.** An app signed by a free account stops opening after 7 days. AltStore and SideStore
|
||||
refresh it in the background or when you tap Refresh; with Sideloadly you sideload it again.
|
||||
Your installed game, saves and settings stay as long as the app keeps its bundle identifier
|
||||
(the same tool and the same Apple Account).
|
||||
- **3 apps.** At most 3 sideloaded apps can be active on a device at once (AltStore and SideStore
|
||||
count themselves), and an account can register at most 10 App IDs in 7 days.
|
||||
- **Developer Mode** must be on: Settings > Privacy & Security > Developer Mode (the device
|
||||
restarts).
|
||||
|
||||
A paid account signs for a year, without the 3-app limit.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Download the IPA and its `.sha256` from the release page, and check it.
|
||||
2. Sideload it with your tool of choice, signing in to it with your Apple Account:
|
||||
- **AltStore Classic** (AltServer on a Mac or PC, then AltStore on the device): My Apps, +, and
|
||||
pick the IPA.
|
||||
- **SideStore**: +, and pick the IPA.
|
||||
- **Sideloadly** (Mac or PC, with a cable): drag the IPA in, choose the device, enter your Apple
|
||||
Account and press Start.
|
||||
3. On the device, trust the developer: Settings > General > VPN & Device Management, your Apple
|
||||
Account, Trust. Turn on Developer Mode if iOS asks.
|
||||
4. Put your disc image in **Files > On My iPhone (or iPad) > SpeedBreaker**: AirDrop it to Files,
|
||||
drag it into the device's Files tab in Finder over a cable, or download it from iCloud Drive
|
||||
first (the installer can't read a file still in the cloud).
|
||||
5. Start SpeedBreaker: the installer finds the image, checks it against the disc and installs the
|
||||
game. See [iPhone and iPad](../../README.md#iphone-and-ipad) in the main README for the rest.
|
||||
|
||||
If it doesn't work: the page's Details line, or Settings > Advanced > Save Bug Report (a zip in
|
||||
the Files app's SpeedBreaker folder) after iOS closed the game, says what happened; the log's
|
||||
`[memory]` lines give the entitlements, the address space and the memory limit, and once a minute
|
||||
how close the game came to it. iOS's own record of closing an app for memory is in Settings >
|
||||
Privacy & Security > Analytics & Improvements > Analytics Data, as a JetsamEvent report.
|
||||
|
||||
### Making the IPA
|
||||
|
||||
```sh
|
||||
export IOS_DEPS=~/speedbreaker-ios-deps # step 2 of Building it yourself
|
||||
scripts/ios/make_ipa.sh
|
||||
```
|
||||
|
||||
This builds the app without signing it (`IOS_UNSIGNED=1 scripts/ios/build.sh`: no team, no
|
||||
Apple Account, no provisioning profile) and packages `build/ios/ipa/SpeedBreaker-<version>-ios.ipa`
|
||||
(`Payload/SpeedBreaker.app`, zipped), its `.sha256`, and the entitlements beside it. The version
|
||||
is `project()`'s in the top-level CMakeLists.txt. `--app <SpeedBreaker.app>` packages a Release
|
||||
build you already have instead, signed or not: any signature and `embedded.mobileprovision` are
|
||||
removed. The app gets a generic bundle identifier (the tools give it one of yours) and an ad-hoc
|
||||
signature with no certificate or team that holds only `SpeedBreaker/SpeedBreaker.entitlements`
|
||||
(or `--entitlements <plist>`'s, to test with fewer), which is where the sideloading tools read the
|
||||
entitlements an app asks for. The executable's debug map, which names the folders it was built in,
|
||||
is stripped; its full symbols go to `build/ios/ipa/symbols/SpeedBreaker-<version>-<commit>-ios.dSYM`,
|
||||
to keep and not to publish. The license notices go in `SpeedBreaker.app/licenses` (`COPYING`,
|
||||
`NOTICE`, `THIRD_PARTY_NOTICES.md` and `LICENSES/`), as in the other downloads. Before writing the IPA it checks that no home-folder path, team,
|
||||
provisioning profile or device ID is left in it; `IPA_SCAN_EXTRA="A1B2C3D4E5|your name"` adds
|
||||
strings of your own to look for. The same app always gives the same IPA.
|
||||
|
||||
## Memory
|
||||
|
||||
What the game needs, measured on a Mac (macOS 27, Apple silicon) in a scripted run into the first
|
||||
race, with the same code as the iOS app:
|
||||
|
||||
- **Address space:** one block of 4 GB for the guest (no smaller layout exists: the recompiled code
|
||||
addresses guest memory as one base plus a 32-bit address, and the game's memory runs to
|
||||
0xFFFFFFFF), plus about 3 GB for everything else at 1x internal resolution, the iPhone's
|
||||
default (the graphics driver's memory, the game's heaps, thread stacks), 3.8 GB at 2x2.
|
||||
- **Memory footprint, peak:** 2.2 GB at 1x, 2.8 GB at 2x2 (1440p). The graphics memory in that
|
||||
race was 1.18 GB at 1x; on an iPhone 15 Pro Max in free roam it was 1.46 GB, so expect about
|
||||
2.5 GB there.
|
||||
- On iOS the log's `[memory]` lines give the real figures: the address space's top (0xfc0000000
|
||||
or more with an entitlement, 0x458000000 without), the memory limit, and once a minute the
|
||||
footprint, its peak and what is left.
|
||||
|
||||
## Building it yourself
|
||||
|
||||
### What you need
|
||||
|
||||
- **A Mac** with Apple silicon and **Xcode** (built with Xcode 26.4 and 27.0), plus everything the
|
||||
Mac build needs: see [Building from source](../../README.md#building-from-source), step 1.
|
||||
- **Your own disc image**, for step 2 of that guide (the game's code is translated from it on your
|
||||
Mac) and later for the installer on the device.
|
||||
- **An Apple developer account.** SpeedBreaker was built and tested with a paid Apple Developer
|
||||
Program membership. It asks for two memory capabilities, Extended Virtual Addressing and
|
||||
Increased Memory Limit, which a free Apple Account's Personal Team may not be able to sign;
|
||||
apps signed by a free account also stop opening after 7 days.
|
||||
- **An iPhone or iPad** with iOS or iPadOS 17 or later and an A15-class chip or newer (iPhone 13
|
||||
and later, M-series iPads), and a controller or keyboard. Tested on an iPhone 15 Pro Max and an
|
||||
iPad Pro 12.9-inch (M2), both on version 27.
|
||||
Program membership. A free Apple Account's Personal Team can't sign Extended Virtual Addressing:
|
||||
Xcode stops with "Personal development teams ... do not support the Extended Virtual Addressing
|
||||
capability". Delete that key (and its `<true/>`) from `scripts/ios/SpeedBreaker/SpeedBreaker.entitlements`
|
||||
first and keep Increased Memory Limit (see [the memory caveat](#the-memory-caveat)); apps signed
|
||||
by a free account also stop opening after 7 days.
|
||||
|
||||
## 1. The game's code
|
||||
### 1. The game's code
|
||||
|
||||
On the Mac, follow [Building from source](../../README.md#building-from-source) steps 1 and 2:
|
||||
`scripts/setup_tools.sh`, your disc's `default.xex` in `game/files`, and XenonRecomp, which
|
||||
writes `ppc/`. You don't need the Mac build itself (step 3).
|
||||
|
||||
## 2. The iOS libraries
|
||||
### 2. The iOS libraries
|
||||
|
||||
```sh
|
||||
scripts/ios/build_deps.sh ~/speedbreaker-ios-deps
|
||||
@@ -34,7 +177,7 @@ This downloads and builds SDL3, glslang and FFmpeg's XMA decoders for iOS, and u
|
||||
static library, into the folder you name (a few minutes; the sources, about 65 MB, are checked
|
||||
against their SHA-256). Nothing is installed anywhere else.
|
||||
|
||||
## 3. Signing
|
||||
### 3. Signing
|
||||
|
||||
Find your **team ID**: in Xcode, Settings > Accounts, select your account and team, or on
|
||||
developer.apple.com under Membership details. It's 10 characters, like `A1B2C3D4E5`. Pick a
|
||||
@@ -44,7 +187,7 @@ team.
|
||||
On the device, turn on **Developer Mode** (Settings > Privacy & Security > Developer Mode; it
|
||||
restarts), then connect it to the Mac with a cable and tap **Trust**.
|
||||
|
||||
## 4. Build and install
|
||||
### 4. Build and install
|
||||
|
||||
```sh
|
||||
export IOS_DEPS=~/speedbreaker-ios-deps
|
||||
@@ -64,7 +207,7 @@ device connected, set `IOS_DEVICE` to the one you want (`xcrun devicectl list de
|
||||
If the app doesn't open and iOS says the developer isn't trusted, go to Settings > General > VPN &
|
||||
Device Management, choose your developer account and trust it.
|
||||
|
||||
## 5. Play
|
||||
### 5. Play
|
||||
|
||||
Copy your `.iso` into **Files > On My iPhone (or iPad) > SpeedBreaker** and start the app: the
|
||||
installer finds it, checks it and installs the game. See
|
||||
|
||||
@@ -17,13 +17,24 @@
|
||||
# IOS_BUNDLE_ID the app's bundle identifier (unique to the team)
|
||||
# IOS_DEVICE the device to install on (`xcrun devicectl list
|
||||
# devices`; the only connected one by default)
|
||||
# IOS_UNSIGNED=1 an app nobody signed, for make_ipa.sh (an IPA that
|
||||
# players sideload): no team, no account, no profile;
|
||||
# in build/ios/xcode-unsigned. IOS_BUNDLE_ID is then
|
||||
# optional (a generic one), and "install" refused.
|
||||
# No game data: the game installs from the player's disc image on the device.
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
export DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}
|
||||
: "${IOS_DEPS:?set IOS_DEPS: the folder scripts/ios/build_deps.sh made (scripts/ios/README.md)}"
|
||||
: "${DEVELOPMENT_TEAM:?set DEVELOPMENT_TEAM: your Apple developer team ID (scripts/ios/README.md)}"
|
||||
: "${IOS_BUNDLE_ID:?set IOS_BUNDLE_ID: a bundle identifier of your own, such as com.yourname.SpeedBreaker}"
|
||||
UNSIGNED=${IOS_UNSIGNED:-0}
|
||||
if [ "$UNSIGNED" = 1 ]; then
|
||||
[ "${1:-}" = install ] && { echo "build.sh: IOS_UNSIGNED=1 makes an app nobody signed, which no device installs: make_ipa.sh packages it for sideloading" >&2; exit 2; }
|
||||
# Sideloading tools give it an identifier of the player's own anyway.
|
||||
IOS_BUNDLE_ID=${IOS_BUNDLE_ID:-io.github.speedbreakerproject.SpeedBreaker}
|
||||
else
|
||||
: "${DEVELOPMENT_TEAM:?set DEVELOPMENT_TEAM: your Apple developer team ID (scripts/ios/README.md)}"
|
||||
: "${IOS_BUNDLE_ID:?set IOS_BUNDLE_ID: a bundle identifier of your own, such as com.yourname.SpeedBreaker}"
|
||||
fi
|
||||
PREFIX=$IOS_DEPS/prefix
|
||||
MOLTENVK=$IOS_DEPS/MoltenVK
|
||||
BUILD=$ROOT/build/ios
|
||||
@@ -67,13 +78,24 @@ LIBS+=(-weak_framework CoreHaptics)
|
||||
VERSION=$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD/CMakeCache.txt")
|
||||
BUILD_NUMBER=$(git -C "$ROOT" rev-list --count HEAD 2>/dev/null || echo 1)
|
||||
|
||||
# Signed for the team (Xcode's automatic signing makes the profile), or for
|
||||
# make_ipa.sh not at all: then no account is asked anything.
|
||||
if [ "$UNSIGNED" = 1 ]; then
|
||||
DERIVED=$BUILD/xcode-unsigned
|
||||
SIGNING=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= DEVELOPMENT_TEAM=)
|
||||
else
|
||||
DERIVED=$BUILD/xcode
|
||||
SIGNING=(-allowProvisioningUpdates DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM")
|
||||
fi
|
||||
xcodebuild -project "$ROOT/scripts/ios/SpeedBreaker.xcodeproj" -scheme SpeedBreaker -configuration Release \
|
||||
-destination generic/platform=iOS -derivedDataPath "$BUILD/xcode" -allowProvisioningUpdates -quiet \
|
||||
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" PRODUCT_BUNDLE_IDENTIFIER="$IOS_BUNDLE_ID" \
|
||||
-destination generic/platform=iOS -derivedDataPath "$DERIVED" -quiet "${SIGNING[@]}" \
|
||||
PRODUCT_BUNDLE_IDENTIFIER="$IOS_BUNDLE_ID" \
|
||||
MARKETING_VERSION="${VERSION:-0.0.0}" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
|
||||
OTHER_LDFLAGS="${LIBS[*]}" build
|
||||
APP=$BUILD/xcode/Build/Products/Release-iphoneos/SpeedBreaker.app
|
||||
nm -gU "$APP/SpeedBreaker" | grep -q ' _vkGetInstanceProcAddr$' || { echo "build.sh: vkGetInstanceProcAddr was stripped" >&2; exit 1; }
|
||||
APP=$DERIVED/Build/Products/Release-iphoneos/SpeedBreaker.app
|
||||
# Counted, not grep -q: grep -q stops reading nm's long output at the first
|
||||
# match, and with pipefail nm's SIGPIPE then fails the pipe (a false alarm).
|
||||
[ "$(nm -gU "$APP/SpeedBreaker" | grep -c ' _vkGetInstanceProcAddr$')" != 0 ] || { echo "build.sh: vkGetInstanceProcAddr was stripped" >&2; exit 1; }
|
||||
echo "$APP (v${VERSION:-?}, build $BUILD_NUMBER, $(du -sh "$APP" | cut -f1))"
|
||||
|
||||
if [ "${1:-}" = install ]; then
|
||||
|
||||
@@ -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"
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# SpeedBreaker. GPL-3.0-or-later (see COPYING).
|
||||
#
|
||||
# make_app.py [build dir] [output dir] [--dmg] [--min-macos=<version>]:
|
||||
# make_app.py [build dir] [output dir] [--dmg] [--min-macos=<version>] [--test]:
|
||||
# SpeedBreaker.app for macOS (Apple silicon) from a build (default
|
||||
# build/main), in build/app:
|
||||
# Contents/MacOS/SpeedBreaker the game
|
||||
@@ -53,19 +53,49 @@
|
||||
# <output>/symbols/SpeedBreaker-<version>-<commit>-macos.dSYM, for us: never
|
||||
# published, they map a player's report back to the exact build.
|
||||
#
|
||||
# --test: an app to try a build with, named so it can't be taken for a
|
||||
# release: "SpeedBreaker (test).app", its displayed name likewise, and its
|
||||
# bundle identifier with ".test" (not with --dmg: a test app is never a
|
||||
# download). Its game, saves and settings are the release's (user/paths.h).
|
||||
#
|
||||
# --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
|
||||
TEST = '--test' 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>])')
|
||||
if a not in ('--dmg', '--test') and not a.startswith('--min-macos='):
|
||||
raise SystemExit(f'make_app.py: unknown option {a} (usage: make_app.py [build dir] [output dir] [--dmg] '
|
||||
f'[--min-macos=<version>] [--test])')
|
||||
if TEST and DMG:
|
||||
raise SystemExit('make_app.py: --test and --dmg: a test app is never a download')
|
||||
# The app's name (the bundle's, the displayed one and the menu bar's), bundle
|
||||
# identifier and symbols' name; --test's are its own.
|
||||
NAME = 'SpeedBreaker (test)' if TEST else 'SpeedBreaker'
|
||||
BUNDLE_ID = 'local.speedbreaker.SpeedBreaker' + ('.test' if TEST else '')
|
||||
FILE = 'SpeedBreaker-test' if TEST else 'SpeedBreaker'
|
||||
BUILD = os.path.abspath(ARGS[0] if len(ARGS) > 0 else os.path.join(ROOT, 'build/main'))
|
||||
OUT = os.path.abspath(ARGS[1] if len(ARGS) > 1 else os.path.join(ROOT, 'build/app'))
|
||||
BIN = os.path.join(BUILD, 'runtime/SpeedBreaker')
|
||||
@@ -332,8 +362,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 +454,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():
|
||||
@@ -374,7 +506,7 @@ def main():
|
||||
print(f'make_app.py: warning: {BUILD} is configured for macOS {minimum}, not the project\'s {default} '
|
||||
f'(CMakeLists.txt): not for release', file=sys.stderr)
|
||||
mvk_version = moltenvk_version()
|
||||
app = os.path.join(OUT, 'SpeedBreaker.app')
|
||||
app = os.path.join(OUT, NAME + '.app')
|
||||
shutil.rmtree(app, ignore_errors=True)
|
||||
contents = os.path.join(app, 'Contents')
|
||||
macos, frameworks = os.path.join(contents, 'MacOS'), os.path.join(contents, 'Frameworks')
|
||||
@@ -387,7 +519,7 @@ def main():
|
||||
# The full symbols for us, then the debug map out of the app's copy.
|
||||
symbols = os.path.join(OUT, 'symbols')
|
||||
os.makedirs(symbols, exist_ok=True)
|
||||
dsym = os.path.join(symbols, f'SpeedBreaker-{version}-{commit}{"-dirty" if dirty else ""}-macos.dSYM')
|
||||
dsym = os.path.join(symbols, f'{FILE}-{version}-{commit}{"-dirty" if dirty else ""}-macos.dSYM')
|
||||
shutil.rmtree(dsym, ignore_errors=True)
|
||||
subprocess.run(['dsymutil', BIN, '-o', dsym], check=True, capture_output=True)
|
||||
run('strip', '-S', exe)
|
||||
@@ -484,10 +616,10 @@ def main():
|
||||
'CFBundleDevelopmentRegion': 'en',
|
||||
'CFBundleExecutable': 'SpeedBreaker',
|
||||
'CFBundleIconFile': 'AppIcon',
|
||||
'CFBundleIdentifier': 'local.speedbreaker.SpeedBreaker',
|
||||
'CFBundleIdentifier': BUNDLE_ID,
|
||||
'CFBundleInfoDictionaryVersion': '6.0',
|
||||
'CFBundleName': 'SpeedBreaker',
|
||||
'CFBundleDisplayName': 'SpeedBreaker',
|
||||
'CFBundleName': NAME,
|
||||
'CFBundleDisplayName': NAME,
|
||||
'CFBundlePackageType': 'APPL',
|
||||
'CFBundleShortVersionString': version,
|
||||
'CFBundleVersion': build_number(commit),
|
||||
@@ -521,8 +653,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,59 @@ 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): from one formatted for Windows (exFAT, FAT32, NTFS) the program may
|
||||
not start (Game Mode doesn't mount such drives, and files there can lose the
|
||||
"executable" mark), though such a drive is fine for carrying a copy. 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
|
||||
|
||||
@@ -29,7 +29,8 @@ as the relaunch phases reuse the video phase's profile):
|
||||
the game's exposure adapts, so a change is judged against the two
|
||||
captures around it, and a bracket with the menu open and nothing
|
||||
changed is the control): anti-aliasing, upscaling, sharpening,
|
||||
anisotropic filtering, aspect ratio, the performance overlay;
|
||||
anisotropic filtering, aspect ratio, Brightness, Xbox 360 Display
|
||||
Gamma, the performance overlay;
|
||||
Mipmaps and Internal Resolution marked RESTART and changing
|
||||
nothing yet; V-Sync's present mode and frame rate; the deadzones
|
||||
on a virtual pad. Also the defaults of a new profile, and
|
||||
@@ -101,7 +102,8 @@ RACE = '8-26/2:START,30-130/4:DOWN,31-131/4:A,32.5-132.5/4:START,145:A'
|
||||
# The menu's pages and rows (runtime/user/settings.cpp, hidden rows left out).
|
||||
PAGES = ['Display', 'Graphics', 'Audio', 'Input', 'Advanced']
|
||||
ROWS = {
|
||||
'Display': ['video.fullscreen', 'video.vsync', 'video.frame_rate', 'video.aspect_ratio'],
|
||||
'Display': ['video.fullscreen', 'video.vsync', 'video.frame_rate', 'video.aspect_ratio', 'video.brightness',
|
||||
'video.xbox_display_gamma'],
|
||||
'Graphics': ['video.anti_aliasing', 'video.scaling', 'video.sharpening', 'video.anisotropic_filtering',
|
||||
'video.mipmaps', 'video.internal_resolution'],
|
||||
'Audio': ['audio.master_volume', 'audio.mute', 'audio.mute_in_background'],
|
||||
@@ -112,7 +114,7 @@ ROWS = {
|
||||
DEFAULTS = {
|
||||
'video.fullscreen': 'false', 'video.vsync': 'true', 'video.frame_rate': '"60"', 'video.present_scale': '"100"',
|
||||
'video.anti_aliasing': 'true', 'video.scaling': '"bicubic"',
|
||||
'video.aspect_ratio': '"auto"', 'video.sharpening': '0.3', 'video.anisotropic_filtering': '"auto"',
|
||||
'video.aspect_ratio': '"auto"', 'video.brightness': '0', 'video.xbox_display_gamma': 'true', 'video.sharpening': '0.3', 'video.anisotropic_filtering': '"auto"',
|
||||
'video.mipmaps': 'true', 'video.internal_resolution': '"auto"', 'audio.master_volume': '100', 'audio.mute': 'false',
|
||||
'audio.mute_in_background': 'false', 'input.stick_deadzone': '0.08', 'input.trigger_deadzone': '0.02',
|
||||
'input.vibration': 'true', 'advanced.async_shaders': 'true', 'advanced.performance_overlay': 'false',
|
||||
@@ -269,6 +271,8 @@ def phase_video():
|
||||
p.close()
|
||||
picture_checks(p)
|
||||
bracket(p, 'aspect_169', [('video.aspect_ratio', 1, '16:9')], [('video.aspect_ratio', -1, 'Fill Screen')])
|
||||
bracket(p, 'bright_2', [('video.brightness', 2, '+2')], [('video.brightness', -2, '0')])
|
||||
bracket(p, 'gamma_off', [('video.xbox_display_gamma', -1, 'Off')], [('video.xbox_display_gamma', 1, 'On')])
|
||||
# Restart options: changed, the picture as before; put back (the running
|
||||
# values again), as before too.
|
||||
bracket(p, 'restart', [('video.mipmaps', 1, 'Off', 'at the next launch'),
|
||||
@@ -818,6 +822,14 @@ def analyze_video(res, run, log):
|
||||
ok = side(b) < 1.0 and side(r) > 4.0 and side(back) > 4.0
|
||||
res.add('video.aspect_ratio', phase, '16:9: black bars at the sides; Fill Screen again fills them',
|
||||
'PASS' if ok else 'FAIL', f'outer {edge} px columns, mean luma: {side(r):.1f} -> {side(b):.2f} -> {side(back):.1f}')
|
||||
# Brightness +2 (exponent 0.874): about +8..+13 levels in the shadows and
|
||||
# mid-tones. The 360's display gamma (the game's ramp at its default
|
||||
# brightness: 16 -> 12.5, 128 -> 134.4) is on by default: turned off,
|
||||
# lighter shadows and darker mid-tones.
|
||||
tone_shift(res, 'video.brightness', phase, '+2: brighter shadows and mid-tones', s, 'bright_2',
|
||||
{(24, 64): (3, 15), (96, 160): (6, 16)})
|
||||
tone_shift(res, 'video.xbox_display_gamma', phase, "off: lighter shadows, darker mid-tones (the game's ramp gone)", s, 'gamma_off',
|
||||
{(6, 20): (0.5, 6), (96, 160): (-10, -2)})
|
||||
unchanged_picture(res, 'video.mipmaps', phase, 'Mipmaps and Internal Resolution changed and put back: '
|
||||
'the picture as with nothing changed', s, 'restart')
|
||||
vsync_results(res, phase, log)
|
||||
@@ -983,6 +995,40 @@ def changes_picture(res, option, phase, check, s, label, rows, strict):
|
||||
f'(1% by chance; want over 5%), or detail energy x{ratio:.3f} (want off 1 by over {margin:.3f})')
|
||||
|
||||
|
||||
def tone_shift(res, option, phase, check, s, label, want):
|
||||
"""Brightness and the 360's display gamma: per band of the captures'
|
||||
luma before the change, how far the changed capture moved from the two
|
||||
around it (interpolated to its time), over the blocks those two agree
|
||||
on. `want` maps (lo, hi) luma bands to (min, max) shifts in levels."""
|
||||
tri = bracket_shots(s, label)
|
||||
if not tri:
|
||||
res.add(option, phase, check, 'INCONCLUSIVE', f'captures for {label} missing')
|
||||
return
|
||||
r, c, b, w = tri
|
||||
keep = still_blocks(r, b)
|
||||
if keep.mean() < 0.5:
|
||||
res.add(option, phase, check, 'INCONCLUSIVE', f'the scene moved around the change ({keep.mean():.0%} still)')
|
||||
return
|
||||
h, wd = keep.shape[0] * BLOCK, keep.shape[1] * BLOCK
|
||||
mask = np.repeat(np.repeat(keep, BLOCK, axis=0), BLOCK, axis=1)
|
||||
luma = lambda x: x[:h, :wd] @ np.array([0.2126, 0.7152, 0.0722])
|
||||
lr, lc, lb = luma(r), luma(c), luma(b)
|
||||
base = lr + w * (lb - lr)
|
||||
parts, ok = [], True
|
||||
for (lo, hi), (mn, mx) in want.items():
|
||||
sel = mask & (base >= lo) & (base < hi)
|
||||
if sel.sum() < 500:
|
||||
parts.append(f'{lo}-{hi}: too few pixels')
|
||||
ok = False
|
||||
continue
|
||||
d = float(np.mean(lc[sel] - base[sel]))
|
||||
good = mn <= d <= mx
|
||||
ok = ok and good
|
||||
parts.append(f'{lo}-{hi}: {d:+.1f} (want {mn:+g}..{mx:+g})')
|
||||
back = float(np.mean(np.abs(lb - lr)[mask]))
|
||||
res.add(option, phase, check, 'PASS' if ok else 'FAIL', '; '.join(parts) + f'; put back: mean difference {back:.2f}')
|
||||
|
||||
|
||||
def picture_results(res, phase, s, strict_aniso=False):
|
||||
bracketed(res, 'video.anti_aliasing', phase, 'off: more detail energy (jagged edges)', s, 'aa_off', 'up')
|
||||
bracketed(res, 'video.scaling', phase, 'bilinear: softer than bicubic', s, 'bilinear', 'down')
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
XenonAnalyse game/files/default.xex game/survey/switch_tables.raw.toml
|
||||
python3 survey/fix_switch_tables.py # writes config/switch_tables.toml
|
||||
python3 survey/find_missing_tables.py > config/switch_tables_inline.toml
|
||||
python3 survey/fix_switch_tables.py # again, with the new inline tables
|
||||
python3 survey/leaf_switch_bounds.py --write
|
||||
|
||||
Reads game/survey/image.bin (survey/xexsurvey.cpp) for the bounds checks.
|
||||
"""
|
||||
import re
|
||||
import re, struct
|
||||
|
||||
RAW, OUT = 'game/survey/switch_tables.raw.toml', 'config/switch_tables.toml'
|
||||
|
||||
@@ -21,15 +26,73 @@ ADD = [
|
||||
0x825A4970, 0x825A49A0, 0x825A49CC, 0x825A49F8, 0x825A4A2C, 0x825A47B4]),
|
||||
]
|
||||
|
||||
IMG, IMG_BASE = 'game/survey/image.bin', 0x82000000
|
||||
img = open(IMG, 'rb').read()
|
||||
word = lambda a: struct.unpack('>I', img[a - IMG_BASE:a - IMG_BASE + 4])[0]
|
||||
pdata_starts = set()
|
||||
for a in range(0x820B1200, 0x820DD6B8, 8):
|
||||
pdata_starts.add(word(a))
|
||||
|
||||
|
||||
def is_exit(w):
|
||||
"""b/ba, blr, bctr, or a bc/bclr/bcctr that always branches (BO 1z1zz)."""
|
||||
op, bo = w >> 26, (w >> 21) & 31
|
||||
if op == 18:
|
||||
return w & 1 == 0
|
||||
if op == 16:
|
||||
return bo & 0x14 == 0x14 and w & 1 == 0
|
||||
if op == 19 and (w >> 1) & 0x3FF in (16, 528):
|
||||
return bo & 0x14 == 0x14 and w & 1 == 0
|
||||
return False
|
||||
|
||||
|
||||
def borrowed_bounds_check(base):
|
||||
"""XenonAnalyse's ScanTable takes a table's bounds check from the code
|
||||
before its `lis`: walking back up to 32 instructions, the first bgt/ble,
|
||||
then the first cmplwi on that cr gives `r`, the label count (immediate+1)
|
||||
and `default`. A table with no bounds check of its own (a leaf function
|
||||
whose caller guarantees the index) gets a compare from whatever precedes
|
||||
it, across a return and into the previous function. Returns (the cmplwi,
|
||||
the first exit or .pdata start between it and the table), or None when
|
||||
the compare reaches the table."""
|
||||
cr = None
|
||||
for i in range(32):
|
||||
a = base - 4 * i
|
||||
w = word(a)
|
||||
op, bo, bi = w >> 26, (w >> 21) & 31, (w >> 16) & 31
|
||||
if cr is None:
|
||||
if (op == 16 or (op == 19 and (w >> 1) & 0x3FF == 16)) and bi % 4 == 1 and bo & 0x1C in (0x0C, 0x04):
|
||||
cr = bi // 4
|
||||
elif op == 10 and (w >> 21) & 1 == 0 and (w >> 23) & 7 == cr: # cmplwi on that cr
|
||||
cut = [x for x in range(a + 4, base, 4) if is_exit(word(x))]
|
||||
cut += [x for x in range(a + 4, base + 4, 4) if x in pdata_starts]
|
||||
return (a, min(cut)) if cut else None
|
||||
return None
|
||||
|
||||
|
||||
text = open(RAW).read()
|
||||
head, *blocks = text.split('[[switch]]')
|
||||
kept = []
|
||||
kept, borrowed = [], []
|
||||
for b in blocks:
|
||||
m = re.search(r'base = 0x([0-9A-F]+)', b)
|
||||
if m and int(m.group(1), 16) in DROP:
|
||||
continue
|
||||
# A table whose bounds check is borrowed (see borrowed_bounds_check) has
|
||||
# the wrong index register and too few labels. XenonRecomp then emits a
|
||||
# switch on that register over those labels with an unreachable default,
|
||||
# so the function always takes its first case: every Performance Tuning
|
||||
# row got the first row's label ("Steering", GitHub issue #8). Drop it:
|
||||
# find_missing_tables.py decodes it from its dispatch, as a table with no
|
||||
# bounds check. Regenerate config/switch_tables_inline.toml after this.
|
||||
if m and 'default = ' in b:
|
||||
cut = borrowed_bounds_check(int(m.group(1), 16))
|
||||
if cut:
|
||||
borrowed.append((int(m.group(1), 16), *cut))
|
||||
continue
|
||||
kept.append(b)
|
||||
assert len(blocks) - len(kept) == len(DROP), 'a DROP entry no longer matches the analyzer output'
|
||||
assert len(blocks) - len(kept) == len(DROP) + len(borrowed), 'a DROP entry no longer matches the analyzer output'
|
||||
for base, cmp, cut in borrowed:
|
||||
print(f'borrowed bounds check: table 0x{base:08X} took cmplwi 0x{cmp:08X}, cut off at 0x{cut:08X}; dropped')
|
||||
|
||||
out = head + '[[switch]]'.join([''] + kept)
|
||||
out = out.rstrip() + '\n\n# ---- MANUAL (survey/fix_switch_tables.py) ----\n'
|
||||
@@ -42,4 +105,4 @@ for t in ADD:
|
||||
out += '# ---- INLINE (config/switch_tables_inline.toml) ----\n'
|
||||
out += open('config/switch_tables_inline.toml').read()
|
||||
open(OUT, 'w').write(out)
|
||||
print(f'{OUT}: {len(kept) + len(ADD)} tables ({len(DROP)} dropped, {len(ADD)} added)')
|
||||
print(f'{OUT}: {len(kept) + len(ADD)} tables ({len(DROP) + len(borrowed)} dropped, {len(ADD)} added)')
|
||||
@@ -129,11 +129,14 @@ block = '\n'.join(lines)
|
||||
if '--write' in sys.argv:
|
||||
MARKER = '# --- generated by survey/leaf_switch_bounds.py --write ---'
|
||||
cfg = 'config/nfsmw.toml'
|
||||
text = open(cfg).read().split('# --- generated by survey/leaf_switch_bounds.py')[0].rstrip()
|
||||
full = open(cfg).read()
|
||||
text = full.split('# --- generated by survey/leaf_switch_bounds.py')[0].rstrip()
|
||||
# Keep what follows the generated list (the hooks).
|
||||
rest = full.split(MARKER, 1)[1].split('\n]\n', 1)[1] if MARKER in full else ''
|
||||
open(cfg, 'w').write(text + '\n\n' + MARKER + '\n'
|
||||
'# Leaf functions (no .pdata) with jump tables: start = latest entry (bl target,\n'
|
||||
'# data-referenced address, or after padding); end = branch-closure extent;\n'
|
||||
'# overlaps merged. Plus MANUAL entries. Do not edit by hand; rerun.\n' + block + '\n')
|
||||
'# overlaps merged. Plus MANUAL entries. Do not edit by hand; rerun.\n' + block + '\n' + rest)
|
||||
print(f'wrote {cfg}', file=sys.stderr)
|
||||
else:
|
||||
print(block)
|
||||
@@ -22,15 +22,70 @@ 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)
|
||||
# Depth-only draws run no pixel shader (gpu/depth_only.h, header only): the
|
||||
# sun's shadow pass as the game draws it, and renderer.cpp's Draw using the
|
||||
# rule (the cars' and tunnel roofs' shadows, v0.1.3).
|
||||
sb_test(depth_only_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)
|
||||
# Settings > Display > Brightness's curve and the 360's display gamma ramp
|
||||
# (video/display_tone.h, video/display_gamma.h: the DC_LUT_* registers as
|
||||
# Direct3D writes them), header only; presenter.cpp is read to check the
|
||||
# tone shader's wrapping of the scale shaders, settings.cpp for the two
|
||||
# options' defaults (Xbox 360 Display Gamma on since v0.1.4).
|
||||
sb_test(display_tone_test ARGS ${RT}/video/presenter.cpp ${RT}/user/settings.cpp)
|
||||
sb_test(refresh_fit_test SOURCES ${RT}/video/refresh_fit.cpp)
|
||||
# How late the guest vblank's thread wakes on this machine, alone and with
|
||||
# every core busy (video/vblank_timer.cpp; checked on macOS, where its
|
||||
# scheduling changed in v0.1.4).
|
||||
sb_test(vblank_timer_test SOURCES ${RT}/video/vblank_timer.cpp)
|
||||
# When the Mac shows a new frame at a set time after its vblank, and the
|
||||
# watchdog that turns it off (video/present_schedule.h, header only).
|
||||
sb_test(present_schedule_test)
|
||||
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)
|
||||
# The front end's matrix after the window changes shape: the game's
|
||||
# pointer-keyed matrix cache and the hook's resend (game/front_end_matrix.h,
|
||||
# header only; the "EVENT HANDLER" label above the boot PSA).
|
||||
sb_test(front_end_matrix_test)
|
||||
# Settings > Display > Fullscreen against the window's own state
|
||||
# (video/fullscreen_policy.h, header only): the green button and other system
|
||||
# changes never change the saved setting, the menu does; presenter.cpp is read
|
||||
# to check its window events don't write the setting.
|
||||
sb_test(fullscreen_policy_test ARGS ${RT}/video/presenter.cpp)
|
||||
# Game time in whole vblanks (game/whole_vblanks.h), header only.
|
||||
sb_test(whole_vblanks_test)
|
||||
# The nitrous light trails' ring at the 360's 30 fps length (game/light_trails.h,
|
||||
# header only; GitHub issue #10).
|
||||
sb_test(light_trails_test)
|
||||
sb_test(guest_time_test SOURCES ${RT}/cpu/guest_time.cpp)
|
||||
# When the mouse cursor shows over the game (ui/cursor_idle.h, header only),
|
||||
# and how ui.cpp drives it.
|
||||
sb_test(cursor_idle_test ARGS ${RT}/ui/ui.cpp)
|
||||
sb_test(report_zip_test SOURCES ${RT}/report/zip.cpp ARGS ${CMAKE_CURRENT_BINARY_DIR}/report_zip_out)
|
||||
find_package(Python3 COMPONENTS Interpreter)
|
||||
if(Python3_FOUND)
|
||||
@@ -47,17 +102,54 @@ 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)
|
||||
# Check for Updates' logic: versions, GitHub's JSON, curl's output and the
|
||||
# release notes (update/check.cpp, self-contained).
|
||||
sb_test(update_check_test SOURCES ${RT}/update/check.cpp)
|
||||
# Settings > Quit Game: where it is offered (not on iPhone or iPad) and its
|
||||
# confirmation (ui/quit_game.h, header only); the runtime's code around it
|
||||
# is read: the button and prompt gated, the focus on Cancel, B and Escape
|
||||
# cancelling, and the quit taking the window close's way out.
|
||||
sb_test(quit_game_test ARGS ${RT})
|
||||
# The jump tables XenonRecomp makes switches of (config/switch_tables.toml)
|
||||
# against the leaf functions' bounds (config/nfsmw.toml): no label or default
|
||||
# outside its function, and the tables a borrowed bounds check broke (the
|
||||
# Performance Tuning labels, GitHub issue #8) as the guest dispatches them.
|
||||
sb_test(switch_tables_test ARGS ${ROOT}/config/switch_tables.toml ${ROOT}/config/nfsmw.toml)
|
||||
# The write watch around CPU write faults, on physical memory mapped as the
|
||||
# runtime maps it.
|
||||
sb_test(write_watch_test SOURCES ${RT}/kernel/write_watch.cpp ${RT}/cpu/guest_time.cpp)
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Tests for ui::CursorIdle (runtime/ui/cursor_idle.h): when the mouse cursor
|
||||
// shows over the game. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/cursor_idle_test.cpp -o build/cursor_idle_test
|
||||
// build/cursor_idle_test runtime/ui/ui.cpp
|
||||
// With ui.cpp's path it also checks how ui.cpp drives it: ImGui's backend
|
||||
// kept off the cursor outside the menus (it shows it every frame it lays
|
||||
// out: the performance overlay, a toast), SDL_HideCursor only from the
|
||||
// policy, and no relative mode.
|
||||
#include <ui/cursor_idle.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uint64_t kS = 1'000'000'000; // a second in ns
|
||||
constexpr uint64_t kStart = 5 * kS; // SDL's clock at the first frame
|
||||
|
||||
void Policy()
|
||||
{
|
||||
using ui::CursorIdle;
|
||||
CursorIdle c;
|
||||
// Launch: shown wherever the pointer is, for 2 s.
|
||||
CHECK(c.Shows(kStart, false), "the first frame: shown");
|
||||
CHECK(c.Shows(kStart + kS, false), "1 s in: shown");
|
||||
CHECK(!c.Shows(kStart + 2 * kS, false), "2 s without the mouse moving: hidden");
|
||||
CHECK(!c.Shows(kStart + 60 * kS, false), "a minute on: hidden");
|
||||
// The mouse moves: shown at once, for 2 s from its last movement.
|
||||
c.Moved(kStart + 60 * kS);
|
||||
CHECK(c.Shows(kStart + 60 * kS, false), "moved: shown");
|
||||
c.Moved(kStart + 61 * kS);
|
||||
CHECK(c.Shows(kStart + 62 * kS + kS / 2, false), "moving on: 2 s from the last movement");
|
||||
CHECK(!c.Shows(kStart + 63 * kS, false), "then hidden");
|
||||
// A menu or the installer has the mouse: shown, however still.
|
||||
CHECK(c.Shows(kStart + 70 * kS, true), "a menu open: shown");
|
||||
CHECK(c.Shows(kStart + 300 * kS, true), "a menu open for minutes: shown");
|
||||
// Closed by the controller (no mouse since): hidden at once.
|
||||
CHECK(!c.Shows(kStart + 301 * kS, false), "closed without the mouse: hidden");
|
||||
// Closed by a click: shown for 2 s from the click.
|
||||
c.Moved(kStart + 400 * kS);
|
||||
CHECK(c.Shows(kStart + 401 * kS, false) && !c.Shows(kStart + 402 * kS, false), "closed with a click: 2 s, then hidden");
|
||||
// An event stamped before a later one doesn't take the time back.
|
||||
c.Moved(kStart + 399 * kS);
|
||||
CHECK(c.Shows(kStart + 401 * kS + kS / 2, false), "an older timestamp doesn't move it back");
|
||||
// A movement before the first frame (SDL's clock from SDL_Init): counts.
|
||||
CursorIdle early;
|
||||
early.Moved(kStart - kS);
|
||||
CHECK(early.Shows(kStart, false) && !early.Shows(kStart + kS, false), "moved before the first frame: 2 s from then");
|
||||
|
||||
// What counts as the mouse.
|
||||
CHECK(CursorIdle::Activity(true, false, 3.0f, 0.0f), "a real mouse's motion");
|
||||
CHECK(CursorIdle::Activity(true, false, 0.0f, -0.5f), "a sub-point motion");
|
||||
CHECK(!CursorIdle::Activity(true, false, 0.0f, 0.0f), "a motion that moved nothing (a window entered)");
|
||||
CHECK(CursorIdle::Activity(false, false, 0.0f, 0.0f), "a click or the wheel");
|
||||
CHECK(!CursorIdle::Activity(true, true, 25.0f, 4.0f), "a finger's (or a pen's) motion: not the mouse (the Deck's screen)");
|
||||
CHECK(!CursorIdle::Activity(false, true, 0.0f, 0.0f), "a tap: not the mouse");
|
||||
}
|
||||
|
||||
size_t Count(const std::string& text, const std::string& what)
|
||||
{
|
||||
size_t n = 0;
|
||||
for (size_t at = text.find(what); at != std::string::npos; at = text.find(what, at + what.size()))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
void Source(const char* path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
if (!in)
|
||||
{
|
||||
printf(" %s: can't read\n", path);
|
||||
g_failures++;
|
||||
return;
|
||||
}
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
const std::string ui = ss.str();
|
||||
CHECK(Count(ui, "io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange") == 1 &&
|
||||
Count(ui, "io.ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange") == 1,
|
||||
"ui.cpp keeps ImGui's backend off the cursor outside the menus, and gives it back in them");
|
||||
CHECK(Count(ui, "SDL_HideCursor()") == 1 && Count(ui, "SDL_ShowCursor()") == 1, "ui.cpp shows and hides the cursor in one place");
|
||||
CHECK(Count(ui, "RelativeMouseMode") == 0, "no relative mode (it would capture the mouse)");
|
||||
CHECK(Count(ui, "s_cursorIdle.Moved(") == 1 && Count(ui, " if (MouseActivity(e))\n") == 1, "every SDL event is looked at for the mouse");
|
||||
printf(" %s: checked\n", path);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
printf("when the cursor shows:\n");
|
||||
Policy();
|
||||
if (argc > 1)
|
||||
{
|
||||
printf("ui.cpp:\n");
|
||||
Source(argv[1]);
|
||||
}
|
||||
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Tests for gpu/depth_only.h: draws in depth-only EDRAM mode run no pixel
|
||||
// shader (Xenos writes their rasterised depth and stencil only). Up to v0.1.2
|
||||
// the renderer ran the pixel shader still bound, and in the sun's shadow pass
|
||||
// that was the alpha-tested tree casters' one: with no texture coordinates
|
||||
// from the cars' (and tunnel roofs') vertex shaders it read texel (0,0)'s
|
||||
// alpha and killed every pixel, so cars cast no sun shadow and tunnels let
|
||||
// the sun through. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/depth_only_test.cpp -o build/depth_only_test
|
||||
// build/depth_only_test runtime/gpu/renderer.cpp
|
||||
// The shadow pass is modelled with the order and the state the game's
|
||||
// commands have, on a small depth map. With
|
||||
// renderer.cpp's path (ctest passes it), Draw is also checked to use the
|
||||
// rule: the empty pixel shader for depth-only draws, before its shader checks
|
||||
// and its pipeline, and no alpha test for them.
|
||||
#include <gpu/depth_only.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using xe::gpu::xenos::EdramMode;
|
||||
|
||||
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 TestRule()
|
||||
{
|
||||
CHECK(gpu::RunsGuestPixelShader(EdramMode::kColorDepth), "colour+depth draws run their pixel shader");
|
||||
CHECK(!gpu::RunsGuestPixelShader(EdramMode::kDepthOnly), "depth-only draws run none");
|
||||
CHECK(gpu::RunsGuestPixelShader(EdramMode::kDepthOnly, true), "NFSMW_DEPTH_ONLY_PS=1: the bound one, as up to v0.1.2");
|
||||
CHECK(gpu::RunsGuestPixelShader(EdramMode::kColorDepth, true), "the override changes depth-only draws only");
|
||||
}
|
||||
|
||||
static void TestEmptyShader()
|
||||
{
|
||||
const std::string g = gpu::kEmptyPixelShaderGlsl;
|
||||
CHECK(g.rfind("#version 460\n", 0) == 0, "a GLSL 4.60 shader: %s", g.c_str());
|
||||
CHECK(g.find("void main()") != std::string::npos, "has main: %s", g.c_str());
|
||||
for (const char* word : { "discard", "out ", "in ", "uniform", "layout", "buffer", "gl_FragDepth" })
|
||||
CHECK(g.find(word) == std::string::npos, "no '%s' (no kill, outputs, inputs or resources): %s", word, g.c_str());
|
||||
}
|
||||
|
||||
// The sun's shadow pass on a 32x32 map, as the game draws it each frame:
|
||||
// a depth-only clear, the alpha-tested world casters (colour+depth), the
|
||||
// depth-only world occluders (a tunnel roof), the resolve to 03A8A000 (B),
|
||||
// the depth-only car casters, the resolve to 0444E000 (A).
|
||||
namespace model
|
||||
{
|
||||
constexpr int kSize = 32;
|
||||
using Map = std::array<float, kSize * kSize>;
|
||||
|
||||
// The tree casters' pixel shader (ps_7f7cf48a3edfed26): kill when the
|
||||
// texture's alpha at the interpolated coordinate is 0.2 or less (PS c0.x,
|
||||
// with c1.x = 1 to enable it). Its texture: alpha 0 at texel (0,0) (as
|
||||
// the one bound for the cars samples), 1 over the leaves.
|
||||
float Alpha(int u, int v) { return (u == 0 && v == 0) ? 0.0f : 1.0f; }
|
||||
bool Kills(bool hasTexcoord, int u, int v)
|
||||
{
|
||||
// No texture coordinate exported by the vertex shader: the input
|
||||
// register reads 0 (as the renderer, and Xenia, give it).
|
||||
float a = hasTexcoord ? Alpha(u, v) : Alpha(0, 0);
|
||||
return a <= 0.2f;
|
||||
}
|
||||
|
||||
struct Draw
|
||||
{
|
||||
EdramMode mode;
|
||||
int x0, y0, x1, y1;
|
||||
float z;
|
||||
bool texcoord; // the vertex shader exports one
|
||||
bool always; // depth test ALWAYS (the clear), else LEQUAL
|
||||
bool treeShader; // the pixel shader bound is the tree casters' (else the clear's, which kills nothing)
|
||||
};
|
||||
|
||||
void Run(Map& depth, const Draw& d, bool guestOverride)
|
||||
{
|
||||
bool shaderRuns = gpu::RunsGuestPixelShader(d.mode, guestOverride);
|
||||
for (int y = d.y0; y < d.y1; y++)
|
||||
for (int x = d.x0; x < d.x1; x++)
|
||||
{
|
||||
if (shaderRuns && d.treeShader && Kills(d.texcoord, x + 1, y + 1))
|
||||
continue;
|
||||
float& z = depth[y * kSize + x];
|
||||
if (d.always || d.z <= z)
|
||||
z = d.z;
|
||||
}
|
||||
}
|
||||
|
||||
struct Pass { Map b, a; };
|
||||
|
||||
Pass ShadowPass(bool guestOverride)
|
||||
{
|
||||
Map depth;
|
||||
depth.fill(0.0f); // whatever EDRAM held
|
||||
Run(depth, { EdramMode::kDepthOnly, 0, 0, kSize, kSize, 1.0f, false, true, false }, guestOverride); // the clear
|
||||
Run(depth, { EdramMode::kColorDepth, 2, 2, 8, 8, 0.47f, true, false, true }, guestOverride); // a tree
|
||||
Run(depth, { EdramMode::kDepthOnly, 12, 0, 20, 32, 0.50f, false, false, true }, guestOverride); // a tunnel roof
|
||||
Pass p;
|
||||
p.b = depth; // resolve B
|
||||
Run(depth, { EdramMode::kDepthOnly, 24, 20, 28, 30, 0.52f, false, false, true }, guestOverride); // a car
|
||||
p.a = depth; // resolve A
|
||||
return p;
|
||||
}
|
||||
|
||||
int Count(const Map& m, int x0, int y0, int x1, int y1, float z)
|
||||
{
|
||||
int n = 0;
|
||||
for (int y = y0; y < y1; y++)
|
||||
for (int x = x0; x < x1; x++)
|
||||
n += m[y * kSize + x] == z;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
static void TestShadowPass()
|
||||
{
|
||||
using namespace model;
|
||||
Pass p = ShadowPass(false);
|
||||
CHECK(Count(p.b, 0, 0, kSize, kSize, 1.0f) == kSize * kSize - 36 - 8 * 32, "B: far plane where no caster is (%d)",
|
||||
Count(p.b, 0, 0, kSize, kSize, 1.0f));
|
||||
CHECK(Count(p.b, 2, 2, 8, 8, 0.47f) == 36, "B holds the tree (%d of 36)", Count(p.b, 2, 2, 8, 8, 0.47f));
|
||||
CHECK(Count(p.b, 12, 0, 20, 32, 0.50f) == 256, "B holds the tunnel roof (%d of 256)", Count(p.b, 12, 0, 20, 32, 0.50f));
|
||||
CHECK(Count(p.a, 24, 20, 28, 30, 0.52f) == 40, "A holds the car (%d of 40)", Count(p.a, 24, 20, 28, 30, 0.52f));
|
||||
int differ = 0;
|
||||
for (int i = 0; i < kSize * kSize; i++)
|
||||
differ += p.a[i] != p.b[i];
|
||||
CHECK(differ == 40, "A is B plus the car, nothing else (%d texels differ)", differ);
|
||||
|
||||
// Up to v0.1.2 (the bound pixel shader run depth-only): the tree only,
|
||||
// A == B: no car shadow, no tunnel shade. The model must show the bug.
|
||||
Pass old = ShadowPass(true);
|
||||
CHECK(Count(old.b, 2, 2, 8, 8, 0.47f) == 36, "v0.1.2 model: the tree (%d)", Count(old.b, 2, 2, 8, 8, 0.47f));
|
||||
CHECK(Count(old.b, 12, 0, 20, 32, 0.50f) == 0, "v0.1.2 model: no tunnel roof (%d)", Count(old.b, 12, 0, 20, 32, 0.50f));
|
||||
CHECK(old.a == old.b, "v0.1.2 model: A == B, no car");
|
||||
}
|
||||
|
||||
// renderer.cpp's Draw, comments left out.
|
||||
static std::string DrawBody(const std::string& source)
|
||||
{
|
||||
std::string code;
|
||||
std::istringstream in(source);
|
||||
for (std::string line; std::getline(in, line);)
|
||||
{
|
||||
size_t c = line.find("//");
|
||||
code += (c == std::string::npos ? line : line.substr(0, c)) + "\n";
|
||||
}
|
||||
size_t start = code.find("void Draw(uint32_t initiatorValue");
|
||||
if (start == std::string::npos)
|
||||
return {};
|
||||
// To the next top-level function of the namespace (Draw's closing brace
|
||||
// is followed by `void Submit()`).
|
||||
size_t end = code.find("void Submit()", start);
|
||||
return code.substr(start, end == std::string::npos ? std::string::npos : end - start);
|
||||
}
|
||||
|
||||
static std::string Squash(const std::string& s)
|
||||
{
|
||||
std::string r;
|
||||
for (char c : s)
|
||||
if (c != ' ' && c != '\n' && c != '\t')
|
||||
r += c;
|
||||
return r;
|
||||
}
|
||||
|
||||
static void TestRenderer(const char* path)
|
||||
{
|
||||
std::ifstream f(path);
|
||||
CHECK(f.good(), "cannot read %s", path);
|
||||
if (!f.good())
|
||||
return;
|
||||
std::stringstream ss;
|
||||
ss << f.rdbuf();
|
||||
const std::string source = ss.str();
|
||||
const std::string draw = Squash(DrawBody(source));
|
||||
CHECK(!draw.empty(), "Draw not found in %s", path);
|
||||
if (draw.empty())
|
||||
return;
|
||||
size_t swap = draw.find("if(!gpu::RunsGuestPixelShader(edramMode,s_depthOnlyGuestPs))s_ps=DepthOnlyPixelShader();");
|
||||
size_t restore = draw.find("~RestorePs(){s_ps=ps;}}restorePs{s_ps};");
|
||||
size_t loaded = draw.find("if(!s_vs||!s_ps)");
|
||||
size_t key = draw.find("key.ps=s_ps->hash;");
|
||||
CHECK(swap != std::string::npos, "Draw gives depth-only draws the empty pixel shader");
|
||||
CHECK(restore != std::string::npos && restore < swap, "Draw puts the guest's pixel shader back after the draw");
|
||||
CHECK(loaded != std::string::npos && swap < loaded, "the swap comes before the 'no shaders loaded' check");
|
||||
CHECK(key != std::string::npos && swap < key, "the swap comes before the pipeline key");
|
||||
CHECK(draw.find("alpha_func!=xenos::CompareFunction::kAlways&&gpu::RunsGuestPixelShader(edramMode,s_depthOnlyGuestPs);") !=
|
||||
std::string::npos, "no alpha test for depth-only draws");
|
||||
const std::string all = Squash(source);
|
||||
CHECK(all.find("slot->info.glsl=gpu::kEmptyPixelShaderGlsl;") != std::string::npos, "DepthOnlyPixelShader is the empty shader");
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
TestRule();
|
||||
TestEmptyShader();
|
||||
TestShadowPass();
|
||||
if (argc > 1)
|
||||
TestRenderer(argv[1]);
|
||||
else
|
||||
printf("(no renderer.cpp given: its checks skipped)\n");
|
||||
if (g_failures)
|
||||
{
|
||||
printf("depth_only_test: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("depth_only_test: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The picture's tone curve (video/display_tone.h: Settings > Display >
|
||||
// Brightness) and the 360's display gamma ramp as the game programs it
|
||||
// (video/display_gamma.h: the DC_LUT_* register protocol), header only. The
|
||||
// optional arguments are runtime/video/presenter.cpp (its tone shader wraps
|
||||
// the scale shaders it uses, which must each have exactly one main) and
|
||||
// runtime/user/settings.cpp (the two options' defaults: Brightness 0, Xbox 360
|
||||
// Display Gamma on since v0.1.4).
|
||||
//
|
||||
// Build by hand: c++ -std=c++20 -O2 -UNDEBUG -I runtime tests/display_tone_test.cpp
|
||||
#include <video/display_tone.h>
|
||||
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace video;
|
||||
namespace dg = video::display_gamma;
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failures = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
s_failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fprintf(stderr, "\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
double Levels(double v) { return v * 255.0; }
|
||||
|
||||
// ---- A model of Most Wanted's (and Carbon's) Direct3D ramp, from the
|
||||
// recompiled code (the constants are the XEX's):
|
||||
// the game (sub_82458C20): ramp16[k] = int(pow(k/255, g) * 65535 + 0.5),
|
||||
// g = 1.06 at its Brightness 0 .. 0.57 at 1 (0.815 at its default 0.5)
|
||||
// Direct3D (sub_825A1DA8): table[i] = int(f(i/1023) * 1023 + 0.5) for
|
||||
// the type VdGetCurrentDisplayGamma reports: 1 sRGB encode, 2 (and
|
||||
// others) the BT.709 curve, 3 a power, each of the sRGB decode;
|
||||
// ramp16' = table[ramp16 >> 6] << 6
|
||||
// the DC LUT (sub_825A20A8): DC_LUT_30_COLOR = ramp16' >> 6 per channel.
|
||||
double SrgbDecode(double x) { return x <= 0.04045 ? x * 0.07739938050508499 : std::pow((x + 0.054999999701976776) * 0.9478673338890076, 2.4000000953674316); }
|
||||
double SrgbEncode(double x) { return x <= 0.0031308 ? x * 12.92 : std::pow(x, 0.4166666567325592) * 1.0549999475479126 - 0.054999999701976776; }
|
||||
double Bt709(double x) { return x < 0.018 ? x * 4.5 : std::pow(x, 0.44999998807907104) * 1.0989999771118164 - 0.0989999994635582; }
|
||||
|
||||
std::vector<uint32_t> D3dRamp10(double g, int type)
|
||||
{
|
||||
std::vector<uint32_t> table(1024), out(256);
|
||||
for (int i = 0; i < 1024; i++)
|
||||
{
|
||||
double x = SrgbDecode(i * 0.0009775171056389809);
|
||||
double y = type == 1 ? SrgbEncode(x) : type == 3 ? std::pow(x, 1.0 / 2.22222233) : Bt709(x);
|
||||
table[i] = uint32_t(std::clamp(int(y * 1023.0 + 0.5), 0, 1023));
|
||||
}
|
||||
for (int k = 0; k < 256; k++)
|
||||
{
|
||||
uint32_t ramp16 = uint32_t(std::pow(k / 255.0, g) * 65535.0 + 0.5) & 0xFFFF;
|
||||
out[k] = ((table[ramp16 >> 6] << 6) & 0xFFFF) >> 6;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The register writes sub_825A20A8 makes for a ramp (r = g = b), as the
|
||||
// command processor hands them on.
|
||||
std::vector<std::pair<uint32_t, uint32_t>> D3dWrites(const std::vector<uint32_t>& r10, const std::vector<uint32_t>* g10 = nullptr,
|
||||
const std::vector<uint32_t>* b10 = nullptr)
|
||||
{
|
||||
std::vector<std::pair<uint32_t, uint32_t>> w = { { dg::kRegRwMode, 0 }, { dg::kRegRwIndex, 0 }, { dg::kRegWriteEnMask, 7 } };
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
{
|
||||
uint32_t r = r10[k], g = g10 ? (*g10)[k] : r10[k], b = b10 ? (*b10)[k] : r10[k];
|
||||
w.push_back({ dg::kRegColor30, (r << 20) | (g << 10) | b });
|
||||
w.push_back({ dg::kRegRwIndex, k + 1 }); // the COND_WRITE after each entry; 0x100 at the end
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
// Feeds writes; returns how many completed a table.
|
||||
int Feed(dg::Decoder& d, const std::vector<std::pair<uint32_t, uint32_t>>& writes)
|
||||
{
|
||||
int completed = 0;
|
||||
for (auto [reg, value] : writes)
|
||||
completed += d.Write(reg, value) ? 1 : 0;
|
||||
return completed;
|
||||
}
|
||||
|
||||
void Neutral()
|
||||
{
|
||||
CHECK(tone::Neutral(0, false), "Brightness 0 with the ramp off must be neutral");
|
||||
CHECK(!tone::Neutral(1, false) && !tone::Neutral(-1, false), "any other Brightness is not");
|
||||
CHECK(!tone::Neutral(0, true), "the 360's ramp on is not neutral");
|
||||
CHECK(tone::BrightnessExponent(0) == 1.0, "exponent at 0: %g", tone::BrightnessExponent(0));
|
||||
tone::Lut lut = tone::BuildLut(0, nullptr);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
CHECK(lut.entry[k][c] == float(k / 255.0), "neutral table [%u][%u] = %.9f", k, c, lut.entry[k][c]);
|
||||
// ... and what the shader reads from it at a value and between two.
|
||||
for (uint32_t k = 0; k <= 255; k++)
|
||||
CHECK(std::abs(tone::Sample(lut, 0, k / 255.0) - k / 255.0) < 1e-7, "neutral sample at %u", k);
|
||||
CHECK(std::abs(tone::Sample(lut, 1, 0.5) - 0.5) < 1e-7, "neutral sample between entries");
|
||||
}
|
||||
|
||||
void BrightnessSteps()
|
||||
{
|
||||
double prevMid = -1;
|
||||
for (int32_t s = tone::kBrightnessMin; s <= tone::kBrightnessMax; s++)
|
||||
{
|
||||
tone::Lut lut = tone::BuildLut(s, nullptr);
|
||||
CHECK(lut.entry[0][0] == 0.0f && lut.entry[255][0] == 1.0f, "step %d: black and white stay put (%g, %g)", s,
|
||||
lut.entry[0][0], lut.entry[255][0]);
|
||||
for (uint32_t k = 1; k < 256; k++)
|
||||
CHECK(lut.entry[k][0] > lut.entry[k - 1][0], "step %d: not increasing at %u", s, k);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(lut.entry[k][0] == lut.entry[k][1] && lut.entry[k][1] == lut.entry[k][2], "step %d: channels differ at %u", s, k);
|
||||
double mid = Levels(lut.entry[128][0]);
|
||||
if (s > 0)
|
||||
for (uint32_t k = 1; k < 255; k++)
|
||||
CHECK(lut.entry[k][0] > k / 255.0f, "step %d: %u not brighter", s, k);
|
||||
if (s < 0)
|
||||
for (uint32_t k = 1; k < 255; k++)
|
||||
CHECK(lut.entry[k][0] < k / 255.0f, "step %d: %u not darker", s, k);
|
||||
if (prevMid >= 0)
|
||||
{
|
||||
// Gentle: mid-grey moves 4..7 levels of 255 a step; no value
|
||||
// moves more than 9 in one step.
|
||||
CHECK(mid - prevMid >= 4.0 && mid - prevMid <= 7.0, "step %d: mid-grey moved %.2f levels", s, mid - prevMid);
|
||||
tone::Lut before = tone::BuildLut(s - 1, nullptr);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(Levels(lut.entry[k][0] - before.entry[k][0]) <= 9.0, "step %d: value %u moved %.2f levels", s, k,
|
||||
Levels(lut.entry[k][0] - before.entry[k][0]));
|
||||
}
|
||||
prevMid = mid;
|
||||
}
|
||||
// The ends reach about as far as the games' own brightness range
|
||||
// does from its default (their exponent 0.57 .. 1.06 around 0.815):
|
||||
// mid-grey 157.5 .. 104.0 levels.
|
||||
double brightest = Levels(tone::ApplyBrightness(128 / 255.0, tone::kBrightnessMax));
|
||||
double darkest = Levels(tone::ApplyBrightness(128 / 255.0, tone::kBrightnessMin));
|
||||
double gameBright = Levels(std::pow(128 / 255.0, 0.57 / 0.815)), gameDark = Levels(std::pow(128 / 255.0, 1.06 / 0.815));
|
||||
CHECK(std::abs(brightest - gameBright) <= 4.0, "+5 mid-grey %.1f, the game's brightest %.1f", brightest, gameBright);
|
||||
CHECK(std::abs(darkest - gameDark) <= 8.0, "-5 mid-grey %.1f, the game's darkest %.1f", darkest, gameDark);
|
||||
// Out of range is clamped (a hand-edited settings.toml).
|
||||
CHECK(tone::BrightnessExponent(9) == tone::BrightnessExponent(5) && tone::BrightnessExponent(-9) == tone::BrightnessExponent(-5),
|
||||
"steps clamp to -5..+5");
|
||||
printf("brightness: mid-grey (128) by step:");
|
||||
for (int32_t s = tone::kBrightnessMin; s <= tone::kBrightnessMax; s++)
|
||||
printf(" %+d:%.1f", s, Levels(tone::ApplyBrightness(128 / 255.0, s)));
|
||||
printf("; 16 -> %.1f at +5, %.1f at -5\n", Levels(tone::ApplyBrightness(16 / 255.0, 5)), Levels(tone::ApplyBrightness(16 / 255.0, -5)));
|
||||
}
|
||||
|
||||
void HalfTexels()
|
||||
{
|
||||
CHECK(tone::ToHalf(0.0f) == 0 && tone::ToHalf(1.0f) == 0x3C00 && tone::ToHalf(0.5f) == 0x3800, "exact halves");
|
||||
CHECK(tone::FromHalf(0x3C00) == 1.0f && tone::FromHalf(0x3555) > 0.333f && tone::FromHalf(0x3555) < 0.3334f, "decode");
|
||||
uint16_t prev = 0;
|
||||
for (uint32_t i = 1; i <= 100000; i++)
|
||||
{
|
||||
float x = i / 100000.0f;
|
||||
uint16_t h = tone::ToHalf(x);
|
||||
CHECK(h >= prev, "not monotonic at %g", x);
|
||||
prev = h;
|
||||
CHECK(std::abs(tone::FromHalf(h) - x) <= x * (1.0f / 2048.0f) + 1e-7f, "half of %g is %g", x, tone::FromHalf(h));
|
||||
}
|
||||
// Subnormals (shadows under 2^-14) still round to nearest.
|
||||
CHECK(std::abs(tone::FromHalf(tone::ToHalf(3e-5f)) - 3e-5f) <= std::ldexp(1.0f, -25), "subnormal");
|
||||
// Every neutral entry within an eighth of a level.
|
||||
auto texels = tone::ToHalfTexels(tone::BuildLut(0, nullptr));
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(std::abs(tone::FromHalf(texels[k * 4]) - k / 255.0f) * 255.0f < 0.125f && texels[k * 4 + 3] == 0x3C00, "texel %u", k);
|
||||
}
|
||||
|
||||
void D3dModelMatchesTheDump()
|
||||
{
|
||||
// Carbon's ramp as logged from the command processor at the game's
|
||||
// default brightness (fidelity study, 2026-10-12): 8 -> 5.484,
|
||||
// 16 -> 12.214, 128 -> 133.9, 192 -> 196.4 (8-bit levels of 10-bit
|
||||
// entries). The model gives the same entries at g = 0.815, type 2.
|
||||
auto ramp = D3dRamp10(0.815, 2);
|
||||
CHECK(std::abs(ramp[8] * 255.0 / 1023.0 - 5.484) < 0.01, "8 -> %.3f", ramp[8] * 255.0 / 1023.0);
|
||||
CHECK(std::abs(ramp[16] * 255.0 / 1023.0 - 12.214) < 0.01, "16 -> %.3f", ramp[16] * 255.0 / 1023.0);
|
||||
CHECK(std::abs(ramp[128] * 255.0 / 1023.0 - 133.9) < 0.05, "128 -> %.3f", ramp[128] * 255.0 / 1023.0);
|
||||
CHECK(std::abs(ramp[192] * 255.0 / 1023.0 - 196.4) < 0.05, "192 -> %.3f", ramp[192] * 255.0 / 1023.0);
|
||||
// With an sRGB display (type 1) Direct3D passes the game's own ramp
|
||||
// through: with the game's ramp at 1.0 that is the identity.
|
||||
auto srgb = D3dRamp10(1.0, 1);
|
||||
dg::Table identity = dg::Table::Identity();
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(std::abs(int(srgb[k]) - int(identity.entry[k][0])) <= 1, "type 1, identity ramp: %u -> %u", k, srgb[k]);
|
||||
}
|
||||
|
||||
void DecoderFollowsDirect3d()
|
||||
{
|
||||
CHECK(!dg::IsLutRegister(0x1920) && dg::IsLutRegister(0x1921) && dg::IsLutRegister(0x1925) && dg::IsLutRegister(0x1930) &&
|
||||
!dg::IsLutRegister(0x1931) && !dg::IsLutRegister(0),
|
||||
"the LUT register range");
|
||||
auto ramp = D3dRamp10(0.815, 2);
|
||||
dg::Decoder d;
|
||||
CHECK(d.table() == dg::Table::Identity(), "a new decoder holds the identity");
|
||||
// Completed by the last entry's write, once.
|
||||
auto writes = D3dWrites(ramp);
|
||||
int completed = 0, at = -1;
|
||||
for (size_t i = 0; i < writes.size(); i++)
|
||||
if (d.Write(writes[i].first, writes[i].second))
|
||||
{
|
||||
completed++;
|
||||
at = int(i);
|
||||
}
|
||||
CHECK(completed == 1 && at == int(writes.size()) - 2, "completed %d times, at write %d of %zu", completed, at, writes.size());
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(d.table().entry[k][0] == ramp[k] && d.table().entry[k][1] == ramp[k] && d.table().entry[k][2] == ramp[k], "entry %u", k);
|
||||
CHECK(d.TableMode() && d.PwlWrites() == 0 && d.Pending() == 0, "table mode, nothing pending");
|
||||
// The same ramp again completes again (the runtime drops repeats).
|
||||
CHECK(Feed(d, writes) == 1, "a second sweep completes");
|
||||
|
||||
// Channels land where DC_LUT_30_COLOR puts them (red 20:29, blue 0:9).
|
||||
std::vector<uint32_t> r(256), g(256), b(256);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
{
|
||||
r[k] = k * 4;
|
||||
g[k] = 1023 - k * 4;
|
||||
b[k] = k;
|
||||
}
|
||||
CHECK(Feed(d, D3dWrites(r, &g, &b)) == 1, "per-channel sweep");
|
||||
CHECK(d.table().entry[10][0] == 40 && d.table().entry[10][1] == 983 && d.table().entry[10][2] == 10, "channel order: %u %u %u",
|
||||
d.table().entry[10][0], d.table().entry[10][1], d.table().entry[10][2]);
|
||||
|
||||
// DC_LUT_30_COLOR moves the index on by itself (a writer that sets
|
||||
// RW_INDEX once, then 256 colors).
|
||||
dg::Decoder autoIndex;
|
||||
std::vector<std::pair<uint32_t, uint32_t>> colorsOnly = { { dg::kRegRwIndex, 0 } };
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
colorsOnly.push_back({ dg::kRegColor30, (r[k] << 20) | (g[k] << 10) | b[k] });
|
||||
CHECK(Feed(autoIndex, colorsOnly) == 1 && autoIndex.table().entry[255][0] == r[255] && autoIndex.table().entry[1][2] == b[1],
|
||||
"auto-increment");
|
||||
|
||||
// Half a sweep is not a table.
|
||||
dg::Decoder half;
|
||||
auto partial = D3dWrites(ramp);
|
||||
partial.resize(3 + 2 * 128);
|
||||
CHECK(Feed(half, partial) == 0 && half.Pending() == 128, "half a sweep: pending %zu", half.Pending());
|
||||
|
||||
// The write-enable mask: red only.
|
||||
dg::Decoder masked;
|
||||
auto redOnly = D3dWrites(ramp);
|
||||
redOnly[2].second = 4;
|
||||
CHECK(Feed(masked, redOnly) == 1, "red-only sweep completes");
|
||||
CHECK(masked.table().entry[128][0] == ramp[128] && masked.table().entry[128][1] == dg::Table::Identity().entry[128][1],
|
||||
"red only: green kept");
|
||||
// A mask of 0 writes nothing (and completes nothing).
|
||||
dg::Decoder none;
|
||||
auto noMask = D3dWrites(ramp);
|
||||
noMask[2].second = 0;
|
||||
CHECK(Feed(none, noMask) == 0 && none.table() == dg::Table::Identity(), "mask 0");
|
||||
|
||||
// DC_LUT_SEQ_COLOR: red, green, blue a write, bits 6:15.
|
||||
dg::Decoder seq;
|
||||
int seqCompleted = seq.Write(dg::kRegRwIndex, 0) ? 1 : 0;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
for (uint32_t c = 0; c < 3; c++)
|
||||
seqCompleted += seq.Write(dg::kRegSeqColor, (c == 0 ? r[k] : c == 1 ? g[k] : b[k]) << 6) ? 1 : 0;
|
||||
CHECK(seqCompleted == 1 && seq.table().entry[200][0] == r[200] && seq.table().entry[200][1] == g[200] &&
|
||||
seq.table().entry[200][2] == b[200],
|
||||
"sequential writes: %d completions", seqCompleted);
|
||||
// An index write restarts at red.
|
||||
seq.Write(dg::kRegRwIndex, 5);
|
||||
seq.Write(dg::kRegSeqColor, 100 << 6); // red of 5
|
||||
seq.Write(dg::kRegRwIndex, 7);
|
||||
seq.Write(dg::kRegSeqColor, 300 << 6); // red of 7, not green
|
||||
CHECK(seq.table().entry[5][0] == 100 && seq.table().entry[7][0] == 300 && seq.table().entry[7][1] == g[7], "index restarts at red");
|
||||
|
||||
// The PWL ramp: counted, not applied; in PWL mode DC_LUT_30_COLOR
|
||||
// writes don't touch the table.
|
||||
dg::Decoder pwl;
|
||||
pwl.Write(dg::kRegLutaControl, 3);
|
||||
pwl.Write(dg::kRegRwMode, 1);
|
||||
pwl.Write(dg::kRegPwlData, 0x02000000);
|
||||
pwl.Write(dg::kRegColor30, 0x3FFFFFFF);
|
||||
CHECK(!pwl.TableMode() && pwl.PwlWrites() == 1 && pwl.table() == dg::Table::Identity(), "PWL");
|
||||
}
|
||||
|
||||
void RampThroughTheTable()
|
||||
{
|
||||
auto ramp10 = D3dRamp10(0.815, 2);
|
||||
dg::Table ramp;
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
ramp.entry[k] = { uint16_t(ramp10[k]), uint16_t(ramp10[k]), uint16_t(ramp10[k]) };
|
||||
tone::Lut lut = tone::BuildLut(0, &ramp);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(lut.entry[k][0] == float(ramp10[k] / 1023.0), "the ramp alone, entry %u", k);
|
||||
// The 360 ramp darkens the shadows and brightens the mid-tones.
|
||||
CHECK(Levels(lut.entry[8][0]) < 7.0 && Levels(lut.entry[128][0]) > 132.0, "8 -> %.1f, 128 -> %.1f", Levels(lut.entry[8][0]),
|
||||
Levels(lut.entry[128][0]));
|
||||
// Brightness after it.
|
||||
tone::Lut both = tone::BuildLut(2, &ramp);
|
||||
for (uint32_t k = 0; k < 256; k++)
|
||||
CHECK(std::abs(both.entry[k][0] - std::pow(ramp10[k] / 1023.0, tone::BrightnessExponent(2))) < 1e-6, "ramp then +2, %u", k);
|
||||
}
|
||||
|
||||
// presenter.cpp's scale shaders: the tone variant renames their one main.
|
||||
void PresenterShaders(const char* path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
std::string src = ss.str();
|
||||
CHECK(!src.empty(), "can't read %s", path);
|
||||
for (const char* name : { "kScaleFs", "kScaleFastFs" })
|
||||
{
|
||||
std::string open = std::string("const char* ") + name + " = R\"(";
|
||||
size_t a = src.find(open);
|
||||
CHECK(a != std::string::npos, "%s not found", name);
|
||||
if (a == std::string::npos)
|
||||
continue;
|
||||
size_t b = src.find(")\";", a);
|
||||
std::string glsl = src.substr(a, b - a);
|
||||
size_t first = glsl.find("void main()");
|
||||
CHECK(first != std::string::npos && glsl.find("void main()", first + 1) == std::string::npos, "%s: one main", name);
|
||||
CHECK(glsl.find("scaleMain") == std::string::npos && glsl.find("toneTable") == std::string::npos,
|
||||
"%s: the plain shader stays as it was", name);
|
||||
}
|
||||
// The plain scale pipeline is still made from those shaders, and the
|
||||
// tone pipeline only from ToneVariant.
|
||||
CHECK(src.find("s_post.fs = PostModule(s_fastScale ? kScaleFastFs : kScaleFs, 1);") != std::string::npos, "the plain scale shader");
|
||||
CHECK(src.find("ToneVariant(s_fastScale ? kScaleFastFs : kScaleFs)") != std::string::npos, "the tone variant's source");
|
||||
CHECK(src.find("tone ? s_tone.pipeline : s_post.scale") != std::string::npos, "the scale pass picks the plain pipeline when neutral");
|
||||
}
|
||||
|
||||
// settings.cpp's option table: Xbox 360 Display Gamma is on by default
|
||||
// (v0.1.4: the picture a real 360 sends to a TV; a settings.toml without
|
||||
// the key, as every earlier version wrote it, gets it on), Brightness 0.
|
||||
void SettingsDefaults(const char* path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
std::string src = ss.str();
|
||||
CHECK(!src.empty(), "can't read %s", path);
|
||||
auto entry = [&](const char* key) {
|
||||
// From the key to the end of its option ("},").
|
||||
size_t a = src.find(std::string(".key = \"") + key + "\"");
|
||||
if (a == std::string::npos)
|
||||
return std::string();
|
||||
size_t b = src.find("},", a);
|
||||
return src.substr(a, b == std::string::npos ? std::string::npos : b - a);
|
||||
};
|
||||
std::string gamma = entry("xbox_display_gamma"), brightness = entry("brightness");
|
||||
CHECK(gamma.find(".defaultValue = 1 ") != std::string::npos, "Xbox 360 Display Gamma defaults to on: %s", gamma.c_str());
|
||||
CHECK(brightness.find(".defaultValue = 0,") != std::string::npos, "Brightness defaults to 0: %s", brightness.c_str());
|
||||
CHECK(gamma.find("Off: the picture as before") == std::string::npos, "the help text no longer calls Off the default");
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
Neutral();
|
||||
BrightnessSteps();
|
||||
HalfTexels();
|
||||
D3dModelMatchesTheDump();
|
||||
DecoderFollowsDirect3d();
|
||||
RampThroughTheTable();
|
||||
if (argc > 1)
|
||||
PresenterShaders(argv[1]);
|
||||
else
|
||||
printf("(presenter.cpp not given: its shader checks skipped)\n");
|
||||
if (argc > 2)
|
||||
SettingsDefaults(argv[2]);
|
||||
else
|
||||
printf("(settings.cpp not given: the defaults' checks skipped)\n");
|
||||
if (s_failures)
|
||||
{
|
||||
fprintf(stderr, "%d failure(s)\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("display_tone_test: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -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,242 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The front end's matrix after the window changes shape (game/
|
||||
// front_end_matrix.h): the "EVENT HANDLER" label a player saw above the
|
||||
// boot PSA after pressing the green button mid-movie (2026-10-12). A model
|
||||
// of the game's pointer-keyed cache in sub_82453768 and of the front-end
|
||||
// hook, then the renderer's front-end recognition (the matrix the hook left
|
||||
// last) and its band clip, on the label's own numbers.
|
||||
//
|
||||
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/front_end_matrix_test.cpp
|
||||
#include <game/front_end_matrix.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
|
||||
using game::front_end::MatrixTracker;
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failures = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
s_failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fprintf(stderr, "\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// The front-end builder's matrix for a 16:9 frame (sub_82440D80's
|
||||
// widescreen case): x 0.92 x 0.75, y 0.92.
|
||||
constexpr float kBaseX = 0.92f * 0.75f, kBaseY = 0.92f;
|
||||
// Its three pointers: the view entry, the builder's stack copy (the same
|
||||
// address every frame), the object.
|
||||
constexpr uint32_t kView = 0x82C3F590, kStack = 0x7000FF60, kObject = 0x40012340;
|
||||
// The "EVENT HANDLER" label, 13 quads parked above the frame: clip y
|
||||
// 1.039..1.092 under the 16:9 matrix (c5 = 0.92), x 0.298..0.647
|
||||
// (NFSMW_LOG_FRONTEND / a diagnostic log of its draw, 2026-10-12).
|
||||
constexpr float kLabelLo = 1.039f / kBaseY, kLabelHi = 1.092f / kBaseY; // front-end units
|
||||
|
||||
// sub_82453768: returns at once when called with the same view, matrix
|
||||
// and object pointers as last time (its cache at 0x82A2D190, +4, +8).
|
||||
struct GuestSetMatrix
|
||||
{
|
||||
uint32_t view = 0, matrix = 0, object = 0;
|
||||
float x = 0.0f, y = 0.0f; // what the front end's draws get (c0, c5)
|
||||
int uploads = 0;
|
||||
void Call(uint32_t v, uint32_t m, uint32_t o, float mx, float my)
|
||||
{
|
||||
const bool same = v == view && m == matrix && o == object;
|
||||
view = v;
|
||||
matrix = m;
|
||||
object = o;
|
||||
if (same)
|
||||
return;
|
||||
x = mx;
|
||||
y = my;
|
||||
uploads++;
|
||||
}
|
||||
};
|
||||
|
||||
// The game's front end for one frame: the builder, the hook (narrowing
|
||||
// by the view scales; with `tracker`, clearing the cached matrix pointer
|
||||
// when the matrix changed, as game/ultrawide.cpp does), the upload. With
|
||||
// `otherView`, a 3D view went through the cache before it (menus, races).
|
||||
struct Game
|
||||
{
|
||||
GuestSetMatrix set;
|
||||
MatrixTracker tracker;
|
||||
bool resend = true;
|
||||
float hookX = 0.0f, hookY = 0.0f; // game::FrontEndScaleX/Y
|
||||
void Frame(float scaleX, float scaleY, bool otherView = false)
|
||||
{
|
||||
if (otherView)
|
||||
set.Call(0x82C3F710, 0x7000FE00, kObject, 1.0f, 1.0f);
|
||||
hookX = kBaseX * scaleX;
|
||||
hookY = kBaseY * scaleY;
|
||||
if (resend && tracker.Changed(hookX, hookY))
|
||||
set.matrix = 0;
|
||||
set.Call(kView, kStack, kObject, hookX, hookY);
|
||||
}
|
||||
};
|
||||
|
||||
// The renderer (gpu/renderer.cpp): a front-end draw is recognised when
|
||||
// its c0 and c5 are the matrix the hook left last; then under Vert+ a
|
||||
// primitive wholly outside the band (+-scaleY) is clipped away.
|
||||
struct Outcome
|
||||
{
|
||||
bool matched;
|
||||
float labelLo, labelHi; // the label's clip y
|
||||
bool labelShows; // on screen: inside clip +-1 and not clipped
|
||||
};
|
||||
Outcome Render(const Game& g, float scaleY)
|
||||
{
|
||||
Outcome o{};
|
||||
o.matched = std::fabs(g.set.x - g.hookX) <= g.hookX * 1e-5f && std::fabs(g.set.y - g.hookY) <= g.hookY * 1e-5f;
|
||||
o.labelLo = kLabelLo * g.set.y;
|
||||
o.labelHi = kLabelHi * g.set.y;
|
||||
const bool outsideBand = o.labelLo > scaleY;
|
||||
const bool clipped = o.matched && scaleY < 1.0f && outsideBand;
|
||||
o.labelShows = !clipped && o.labelLo < 1.0f;
|
||||
return o;
|
||||
}
|
||||
|
||||
void Tracker()
|
||||
{
|
||||
MatrixTracker t;
|
||||
CHECK(t.Changed(kBaseX, kBaseY), "the first matrix is sent");
|
||||
CHECK(!t.Changed(kBaseX, kBaseY), "the same one again is not");
|
||||
CHECK(t.Changed(kBaseX, kBaseY * 0.9f), "a new y scale is");
|
||||
CHECK(!t.Changed(kBaseX, kBaseY * 0.9f), "and only once");
|
||||
CHECK(t.Changed(kBaseX * 0.744f, kBaseY * 0.9f), "a new x scale is");
|
||||
CHECK(t.Changed(kBaseX, kBaseY), "back to 16:9 is");
|
||||
}
|
||||
|
||||
// The reported case: the PSA starts in a 1512x921-point window (Vert+
|
||||
// 0.9235), the green button takes it to the 3024x1900 fullscreen Space
|
||||
// (0.8953) mid-movie; nothing else uses the cache during the movies.
|
||||
void GreenButtonMidMovie()
|
||||
{
|
||||
for (bool resend : { true, false })
|
||||
{
|
||||
Game g;
|
||||
g.resend = resend;
|
||||
for (int f = 0; f < 30; f++)
|
||||
g.Frame(1.0f, 0.9235f);
|
||||
Outcome before = Render(g, 0.9235f);
|
||||
CHECK(before.matched && !before.labelShows, "%s, before the change: matched %d, label at %.3f..%.3f shows %d",
|
||||
resend ? "fixed" : "as v0.1.3", before.matched, before.labelLo, before.labelHi, before.labelShows);
|
||||
for (int f = 0; f < 30; f++)
|
||||
g.Frame(1.0f, 0.8953f);
|
||||
Outcome after = Render(g, 0.8953f);
|
||||
if (resend)
|
||||
{
|
||||
CHECK(std::fabs(g.set.y - kBaseY * 0.8953f) < 1e-6f, "the new matrix is uploaded: c5 %.4f", g.set.y);
|
||||
CHECK(after.matched, "the front end's draws are recognised again");
|
||||
CHECK(!after.labelShows, "the label stays hidden: clip y %.3f..%.3f, clipped above the band 0.8953", after.labelLo,
|
||||
after.labelHi);
|
||||
CHECK(g.set.uploads == 2, "one upload per matrix: %d", g.set.uploads);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The model reproduces the report: the old matrix stays, the
|
||||
// draws aren't recognised, and the label lands in the screen's
|
||||
// top rows (clip 0.959..1.009, its top cut off).
|
||||
CHECK(std::fabs(g.set.y - kBaseY * 0.9235f) < 1e-6f, "as v0.1.3 the old matrix stays: c5 %.4f", g.set.y);
|
||||
CHECK(!after.matched && after.labelShows && after.labelLo > 0.95f && after.labelLo < 0.97f,
|
||||
"as v0.1.3 the label shows at clip %.3f..%.3f", after.labelLo, after.labelHi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Menus and races: a 3D view goes through the cache every frame, so the
|
||||
// front end's call is never a repeat and a new matrix arrives either way.
|
||||
void MenusRefreshAnyway()
|
||||
{
|
||||
for (bool resend : { true, false })
|
||||
{
|
||||
Game g;
|
||||
g.resend = resend;
|
||||
for (int f = 0; f < 10; f++)
|
||||
g.Frame(1.0f, 0.9235f, true);
|
||||
for (int f = 0; f < 10; f++)
|
||||
g.Frame(1.0f, 0.8953f, true);
|
||||
CHECK(Render(g, 0.8953f).matched, "%s: menus get the new matrix", resend ? "fixed" : "as v0.1.3");
|
||||
}
|
||||
}
|
||||
|
||||
// A session that never changes shape: the front end's matrix is sent
|
||||
// once, as before (the cache still skips every repeat).
|
||||
void NoChangeNoResend()
|
||||
{
|
||||
Game g;
|
||||
for (int f = 0; f < 600; f++)
|
||||
g.Frame(1.0f, 0.8953f);
|
||||
CHECK(g.set.uploads == 1, "one upload in 600 frames: %d", g.set.uploads);
|
||||
Game wide;
|
||||
for (int f = 0; f < 600; f++)
|
||||
wide.Frame(0.744f, 1.0f);
|
||||
CHECK(wide.set.uploads == 1 && Render(wide, 1.0f).matched, "Hor+ the same: %d", wide.set.uploads);
|
||||
}
|
||||
|
||||
// The other way: from a tall window to a 16:9 one mid-movie (Vert+ off,
|
||||
// so nothing is clipped): the old narrowed matrix would put the label at
|
||||
// clip 0.930..0.978, on a 16:9 screen; the new one puts it past the
|
||||
// frame's top, where a 360 has it.
|
||||
void TallTo169()
|
||||
{
|
||||
for (bool resend : { true, false })
|
||||
{
|
||||
Game g;
|
||||
g.resend = resend;
|
||||
for (int f = 0; f < 30; f++)
|
||||
g.Frame(1.0f, 0.8953f);
|
||||
for (int f = 0; f < 30; f++)
|
||||
g.Frame(1.0f, 1.0f);
|
||||
Outcome o = Render(g, 1.0f);
|
||||
if (resend)
|
||||
CHECK(!o.labelShows && o.labelLo > 1.0f, "fixed: the label past the frame (clip %.3f)", o.labelLo);
|
||||
else
|
||||
CHECK(o.labelShows, "as v0.1.3 the label shows on the 16:9 screen (clip %.3f..%.3f)", o.labelLo, o.labelHi);
|
||||
}
|
||||
}
|
||||
|
||||
// Ultrawide to 16:9 mid-movie: the x scale alone changes (Hor+), and the
|
||||
// hook's x-only overload (a ppc/ from before Vert+) passes y 0.
|
||||
void HorPlusChange()
|
||||
{
|
||||
Game g;
|
||||
for (int f = 0; f < 10; f++)
|
||||
g.Frame(0.744f, 1.0f);
|
||||
for (int f = 0; f < 10; f++)
|
||||
g.Frame(1.0f, 1.0f);
|
||||
CHECK(std::fabs(g.set.x - kBaseX) < 1e-6f && Render(g, 1.0f).matched, "the 16:9 matrix is uploaded: c0 %.4f", g.set.x);
|
||||
MatrixTracker xOnly;
|
||||
CHECK(xOnly.Changed(kBaseX * 0.744f, 0.0f) && !xOnly.Changed(kBaseX * 0.744f, 0.0f) && xOnly.Changed(kBaseX, 0.0f),
|
||||
"the x-only hook's changes");
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
Tracker();
|
||||
GreenButtonMidMovie();
|
||||
MenusRefreshAnyway();
|
||||
NoChangeNoResend();
|
||||
TallTo169();
|
||||
HorPlusChange();
|
||||
if (s_failures)
|
||||
{
|
||||
fprintf(stderr, "%d failure(s)\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("front_end_matrix_test: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// Settings > Display > Fullscreen against the window's own state
|
||||
// (video/fullscreen_policy.h): a change the system makes (the green button,
|
||||
// Window > Enter Full Screen, a window manager, a macOS fullscreen Space that
|
||||
// fails to open at launch) never changes the saved setting; the menu does,
|
||||
// and puts the window in that state. The optional argument is
|
||||
// runtime/video/presenter.cpp: its window events must not write the setting.
|
||||
//
|
||||
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/fullscreen_policy_test.cpp
|
||||
#include <video/fullscreen_policy.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
using video::FullscreenPolicy;
|
||||
using Change = FullscreenPolicy::Change;
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_failures = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
s_failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fprintf(stderr, "\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// One session as the presenter runs it: settings.toml's value (what a
|
||||
// save writes and the next launch reads), the policy, and SDL's window.
|
||||
struct Session
|
||||
{
|
||||
bool saved; // video.fullscreen in settings.toml
|
||||
bool locked = false;
|
||||
FullscreenPolicy policy;
|
||||
bool sdlWindow = false; // what SDL has the window at
|
||||
int requests = 0; // SDL_SetWindowFullscreen calls
|
||||
int systemLines = 0; // "[video] fullscreen ... by the system" lines
|
||||
|
||||
explicit Session(bool fullscreen, bool lockedScreen = false) : saved(fullscreen), locked(lockedScreen)
|
||||
{
|
||||
sdlWindow = policy.Launch(saved, locked); // SDL_CreateWindow(... SDL_WINDOW_FULLSCREEN)
|
||||
Event(sdlWindow); // SDL reports the window it made
|
||||
}
|
||||
// SDL_EVENT_WINDOW_ENTER/LEAVE_FULLSCREEN: the presenter's handler.
|
||||
void Event(bool fullscreen)
|
||||
{
|
||||
sdlWindow = fullscreen;
|
||||
if (policy.WindowIs(fullscreen) == Change::System)
|
||||
systemLines++;
|
||||
}
|
||||
// The system changes the window (the green button): SDL then reports it.
|
||||
void System(bool fullscreen) { Event(fullscreen); }
|
||||
// The menu's Fullscreen row (or Reset page): the setting changes, the
|
||||
// presenter's ApplySettings asks SDL, SDL reports.
|
||||
void Menu(bool fullscreen)
|
||||
{
|
||||
saved = fullscreen;
|
||||
if (std::optional<bool> want = policy.SettingIs(saved))
|
||||
{
|
||||
requests++;
|
||||
Event(*want);
|
||||
}
|
||||
}
|
||||
// The next launch reads what this one left in settings.toml.
|
||||
Session Next() const { return Session(saved, locked); }
|
||||
};
|
||||
|
||||
// Start Fullscreen Off, take the green button, quit: the file still says
|
||||
// off and the next launch is windowed (v0.1.3 saved On here).
|
||||
void GreenButtonOn()
|
||||
{
|
||||
Session s(false);
|
||||
s.System(true);
|
||||
CHECK(!s.saved && s.policy.Window() && s.policy.WindowDiffers(), "the window is fullscreen for now, the setting stays Off");
|
||||
CHECK(s.systemLines == 1, "one line for the system's change: %d", s.systemLines);
|
||||
Session next = s.Next();
|
||||
CHECK(!next.sdlWindow && !next.policy.Window(), "the next launch is windowed");
|
||||
}
|
||||
|
||||
// Start Fullscreen On, leave it with the green button, quit: the next
|
||||
// launch is fullscreen again.
|
||||
void GreenButtonOff()
|
||||
{
|
||||
Session s(true);
|
||||
CHECK(s.sdlWindow && s.systemLines == 0, "fullscreen from the launch, the game's own request");
|
||||
s.System(false);
|
||||
CHECK(s.saved && !s.policy.Window() && s.policy.WindowDiffers(), "windowed for now, the setting stays On");
|
||||
CHECK(s.Next().sdlWindow, "the next launch is fullscreen");
|
||||
}
|
||||
|
||||
// A macOS fullscreen Space that fails to open at launch: SDL reports the
|
||||
// window leaving fullscreen (SDL_cocoawindow.m windowDidExitFullScreen
|
||||
// before windowDidFailToEnterFullScreen). It must not turn the setting off.
|
||||
void FailedSpaceAtLaunch()
|
||||
{
|
||||
Session s(true);
|
||||
s.Event(false);
|
||||
CHECK(s.saved && s.systemLines == 1, "the setting stays On; one line");
|
||||
CHECK(s.Next().sdlWindow, "the next launch tries fullscreen again");
|
||||
}
|
||||
|
||||
// The menu saves, both ways, and moves the window.
|
||||
void MenuSaves()
|
||||
{
|
||||
Session s(false);
|
||||
s.Menu(true);
|
||||
CHECK(s.saved && s.sdlWindow && s.requests == 1 && s.systemLines == 0, "On: saved, the window goes fullscreen");
|
||||
CHECK(!s.policy.WindowDiffers(), "row and window agree");
|
||||
s.Event(true); // SDL may report it again when the Space settles
|
||||
CHECK(s.systemLines == 0, "a repeat of the requested state is not the system's");
|
||||
s.Menu(false);
|
||||
CHECK(!s.saved && !s.sdlWindow && s.requests == 2 && s.systemLines == 0, "Off: saved, the window leaves fullscreen");
|
||||
CHECK(!s.Next().sdlWindow, "the next launch follows the menu");
|
||||
}
|
||||
|
||||
// The menu while the system has the window the other way: the row shows
|
||||
// the saved choice; choosing the state the window is in already saves it
|
||||
// and moves nothing; choosing again moves the window.
|
||||
void MenuAfterSystemChange()
|
||||
{
|
||||
Session s(false);
|
||||
s.System(true); // the green button
|
||||
s.Menu(true);
|
||||
CHECK(s.saved && s.sdlWindow && s.requests == 0 && !s.policy.WindowDiffers(), "On: saved; the window was fullscreen already");
|
||||
s.Menu(false);
|
||||
CHECK(!s.saved && !s.sdlWindow && s.requests == 1, "Off: the window leaves fullscreen");
|
||||
|
||||
Session t(true);
|
||||
t.System(false); // the green button out of fullscreen
|
||||
t.Menu(false);
|
||||
CHECK(!t.saved && !t.sdlWindow && t.requests == 0 && !t.policy.WindowDiffers(), "Off: saved; the window was windowed already");
|
||||
CHECK(!t.Next().sdlWindow, "the next launch is windowed");
|
||||
}
|
||||
|
||||
// SteamOS Game Mode / iOS: always fullscreen, the setting kept as it is.
|
||||
void Locked()
|
||||
{
|
||||
Session s(false, true);
|
||||
CHECK(s.sdlWindow, "fullscreen while locked");
|
||||
s.Menu(true);
|
||||
s.Menu(false);
|
||||
CHECK(s.requests == 0 && s.sdlWindow && !s.policy.WindowDiffers(), "the menu doesn't move a locked window, and no note");
|
||||
s.System(false);
|
||||
CHECK(!s.policy.WindowDiffers(), "no note while locked");
|
||||
}
|
||||
|
||||
// presenter.cpp: the window events go through the policy and never write
|
||||
// the setting (v0.1.3 did: settings::Set(settings::Id::Fullscreen, ...)).
|
||||
void Presenter(const char* path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
const std::string src = ss.str();
|
||||
CHECK(!src.empty(), "can't read %s", path);
|
||||
CHECK(src.find("settings::Set(settings::Id::Fullscreen") == std::string::npos, "presenter.cpp writes the Fullscreen setting");
|
||||
const size_t handler = src.find("e.type == SDL_EVENT_WINDOW_ENTER_FULLSCREEN || e.type == SDL_EVENT_WINDOW_LEAVE_FULLSCREEN");
|
||||
CHECK(handler != std::string::npos, "the window's fullscreen events are handled");
|
||||
if (handler != std::string::npos)
|
||||
{
|
||||
const std::string body = src.substr(handler, src.find("\n }", handler) - handler);
|
||||
CHECK(body.find("s_fullscreen.WindowIs(") != std::string::npos && body.find("settings::Set") == std::string::npos,
|
||||
"the handler only tells the policy: %s", body.c_str());
|
||||
}
|
||||
CHECK(src.find("s_fullscreen.Launch(settings::GetBool(settings::Id::Fullscreen), FullscreenLocked())") != std::string::npos,
|
||||
"the window starts as the setting says");
|
||||
CHECK(src.find("s_fullscreen.SettingIs(settings::GetBool(settings::Id::Fullscreen))") != std::string::npos,
|
||||
"the setting moves the window");
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
GreenButtonOn();
|
||||
GreenButtonOff();
|
||||
FailedSpaceAtLaunch();
|
||||
MenuSaves();
|
||||
MenuAfterSystemChange();
|
||||
Locked();
|
||||
if (argc > 1)
|
||||
Presenter(argv[1]);
|
||||
else
|
||||
printf("(presenter.cpp not given: its checks skipped)\n");
|
||||
if (s_failures)
|
||||
{
|
||||
fprintf(stderr, "%d failure(s)\n", s_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("fullscreen_policy_test: all passed\n");
|
||||
return 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;
|
||||
}
|
||||