2 Commits
Author SHA1 Message Date
SpeedBreaker ProjectandClaude Opus 5.5 b49fc321d1 SpeedBreaker v0.1.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:53:12 +00:00
SpeedBreaker ProjectandClaude Opus 5.5 34e1f75901 Docs for v0.1.2: screenshots from v0.1.2 (sunlit roads, the round sun glare), the IPA not yet installed on a device; iOS build.sh's symbol check
The README's screenshots are new captures from v0.1.2 at the same moments.
The README, scripts/ios/README.md and the release text now say the IPA
itself hasn't been installed on a device yet (the tested iPhones and iPad
ran Xcode-signed builds), which sideloading tools keep which entitlement
with a paid account, and that an IPA installed over a self-built app is a
separate app. THIRD_PARTY_NOTICES.md names the IPA's licenses folder.

scripts/ios/build.sh counts the vkGetInstanceProcAddr match instead of
using grep -q, which with pipefail could stop a complete build with a false
"stripped" alarm (building it yourself, and make_ipa.sh without --app).
The v0.1.2 downloads were built from the previous commit; this one changes
none of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:36:48 +00:00
30 changed files with 1664 additions and 121 deletions

No files matched your search

+37
View File
@@ -4,6 +4,43 @@ What changed in each SpeedBreaker release, newest first. Each release's notes on
its entry here, and the game shows the entry once, the first time a new version starts (offline:
it's built into the game).
## v0.1.3
### What's new
- Smoother driving: no more small hitch every few seconds (the car jumped ahead, then stood
still for a frame or two), which frame-time graphs didn't show (GitHub issue #9). Thanks to
m9cy884mv6-a11y for the careful report.
- Sun shadows of cars, walls and tunnel roofs: cars cast their hard shadow in sunlight again, and
walls, barriers and tunnel roofs shade the road and the cars, as on the Xbox 360. Inside tunnels
the car no longer flickers between sunlight and tree shadows (GitHub issue #3). Thanks to the
players who reported the flicker on GitHub and the missing car shadows on Reddit.
- Nitrous light trails are as long as on the Xbox 360 again: at 60 fps they were half as long, and
looked brighter (GitHub issue #10). Thanks to jaswinprakash for the report, and to victorgbd,
whose Android port of another Most Wanted recompilation found the cause.
- Pause menu > Performance Tuning: each slider has its own name again (Steering, Handling, Brakes,
Ride Height, Aerodynamics, Nitrous, Turbo or Supercharger) instead of "Steering" on every row
(GitHub issue #8).
### More about it
- The hitch: the game's main loop runs a couple of frames ahead of the screen. When one of its
frames took longer, SpeedBreaker gave it two or three steps of game time and the next ones
none, while the screen still showed one frame per refresh. A frame the screen shows for one
refresh now runs one step. The game's speed is unchanged.
- The shadows: the game draws cars, walls and tunnel roofs into its sun shadow map as plain solid
shapes, which on the Xbox 360 run no pixel shader. SpeedBreaker ran the trees' leaf-cutting
shader on them too, and it threw them all away. Checked against real Xbox 360 footage of the
same race. Sunlit roads are as bright as in v0.1.1 and v0.1.2.
- The light trails: the game draws them from where the car was over its last two frames, and the
Xbox 360 races at 30 fps, so at 60 fps they covered half the distance. They now span the same
time as on the 360. At Frame Rate 30 nothing changes.
- The tuning labels: the recompiled code looked up each row's name in a table that had lost all
but its first entry.
- Updating keeps your saves, settings and the installed game, and needs no disc image: on a Mac,
drag the new app into Applications and choose Replace, then Open Anyway once more; on SteamOS,
unpack the new download over the old folder.
## v0.1.2
### What's new
+1 -1
View File
@@ -66,7 +66,7 @@ endif()
# apps' versions, the SteamOS bundles' VERSION file and the release tag
# (v<version>). CHANGELOG.md has an entry for each version (the in-game
# "What's new" note is built from it).
project(SpeedBreaker VERSION 0.1.2 LANGUAGES C CXX)
project(SpeedBreaker VERSION 0.1.3 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
+36 -27
View File
@@ -9,7 +9,7 @@
<b>An unofficial static recompilation of the Xbox 360 version of <i>Need for Speed: Most Wanted</i> (2005):<br>
a native port, not an emulator.<br>
Play your own copy on Steam Deck, Steam Machine and Steam Frame (SteamOS), Linux and Mac,<br>
or build it yourself for iPhone and iPad.</b>
and for iPhone and iPad, sideload the experimental IPA or build it yourself.</b>
</p>
<p align="center">
@@ -101,8 +101,9 @@ and a settings menu you can open mid-race.
</tr>
</table>
<sub>Screenshots: SpeedBreaker on a Mac Studio (M4 Max). They are unedited frames from the game's
own output, except that the licence plate in the top image is blurred. The game content in them
<sub>Screenshots: SpeedBreaker v0.1.2 on a Mac Studio (M4 Max). They are unedited frames from the
game's own output, except that the licence plates in the top image and the first race image are
blurred. The game content in them
belongs to its owners.</sub>
## Download
@@ -111,9 +112,9 @@ belongs to its owners.</sub>
|---|---|---|
| **Steam Deck** and **Steam Machine** (SteamOS) | [Download][dl-steamos-x86_64]<br>`SpeedBreaker-steamos-x86_64.tar.xz` | Steam Deck OLED (SteamOS 3.9.2). Steam Machine (SteamOS 3.8) on a 3440x1440 ultrawide. The Steam Deck LCD hasn't been tested yet. |
| **Steam Frame** (SteamOS, arm64) | [Download][dl-steamos-arm64]<br>`SpeedBreaker-steamos-arm64.tar.xz` | Steam Frame (SteamOS 0.3.0). It plays as a big flat screen in the headset; it is not a VR mode. |
| **Linux** (x86-64) | [Download][dl-steamos-x86_64] the x86-64 SteamOS build, `SpeedBreaker-steamos-x86_64.tar.xz`, or [build from source](#building-from-source) | SteamOS here. Players report it working on Fedora 44 (GeForce GTX 1080 Ti) and Ubuntu 24.04 (Intel Arc 140T). Other setups: reports welcome. |
| **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. The IPA hasn't been tested with a free Apple Account yet. |
| **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 +132,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
@@ -260,7 +262,8 @@ 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
@@ -269,9 +272,10 @@ screen. The installer puts the game in `~/.local/share/speedbreaker/game`, or on
any folder you pick with *Choose a folder...* (it needs a file chooser portal or zenity; without
one, use `--dest`, below).
On a laptop with two GPUs, SpeedBreaker uses the dedicated one (NVIDIA or AMD); with NVIDIA's
driver the launcher also turns on PRIME render offload. The log's `[video] Vulkan devices:` line
says which GPU it chose and why. To pick another, start it with `NFSMW_GPU=<its number or part of
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
@@ -305,10 +309,12 @@ SpeedBreaker isn't on the App Store. There are two ways to get it onto your iPho
on the [release page][releases]. A sideloading tool such as AltStore signs it with your own Apple
Account and installs it. The game asks iOS for more memory than most apps, and a free Apple
Account can't give all of it, so with a free account it may not start, or iOS may close it in
busy scenes. When iOS refuses the memory, the game says so on screen. It hasn't been tested with
a free Apple Account yet: read
[Sideloading the IPA](scripts/ios/README.md#sideloading-the-ipa-experimental) first, and please
report how it goes.
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.
@@ -392,7 +398,7 @@ Each page has a *Reset page* button.
| Sharpening | 0.00 to 1.00 | 0.30 (0 turns it off) |
| Anisotropic Filtering | Auto / Off / 2x / 4x / 8x / 16x / Original | Auto: 16x on dedicated graphics cards and M-series iPads, 4x elsewhere. Original uses each texture's own setting, as on the 360. |
| Mipmaps | On / Off | On, so distant textures don't shimmer |
| Internal Resolution | Auto / 1x (720p) / 1.5x (1080p) / 2x (1440p) / 3x (2160p) | Auto: enough to cover your screen, within what the device can afford (1x, 2x or 3x). On iPhone and iPad, Auto always picks 1x. |
| 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.
@@ -415,11 +421,12 @@ online, and only when you press it). The page also shows which build you're runn
let Frame Rate *Auto* fall back to a steady 30.
- **30 fps is a real option.** It's a steady 30 at full game speed, and the smooth choice in the
Steam Frame headset.
- **Between 1x and 2x,** try 1.5x (1080p): much sharper than 1x for a little over half the work
of 2x.
- **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. The iPhone 18 Pro Max mostly holds 60 fps at 1.5x, but gets hot within
about ten minutes, and Frame Rate Auto then drops to 30. Screen Resolution 75% saves more.
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.
@@ -466,9 +473,10 @@ or later on Apple silicon.
**The game stutters on my Steam Deck OLED.** Set the screen to 60 Hz (see
[Steam Deck setup](#steam-deck-and-steam-machine)).
**My laptop runs the game on the slow built-in graphics.** Since v0.1.2 SpeedBreaker picks the
dedicated GPU by itself. If it still doesn't, see the `[video] Vulkan devices:` line in
`last-run.log` and set `NFSMW_GPU` (see [Linux](#linux)).
**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.
@@ -513,7 +521,7 @@ SpeedBreaker folder instead: `data/`, `data/game` and `cache/`.
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.2-<date>_<time>.zip`.
version you were running, for example `speedbreaker-bug-report-v0.1.3-<date>_<time>.zip`.
3. Open an [issue][issues], attach the zip, and say what you were doing, what you expected, what
happened, and roughly how long into the session it happened.
@@ -723,9 +731,9 @@ research.
The x86-64 SteamOS download also carries glibc and libstdc++ (LGPL-2.1+; GPL-3.0 with the GCC
Runtime Library Exception); the Steam Frame download uses the system's. Full license texts are in
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and [LICENSES/](LICENSES/), and every download
carries them too: in `licenses/` in the SteamOS downloads, and in
carries them too: in `licenses/` in the SteamOS downloads, in
`SpeedBreaker.app/Contents/Resources/licenses` in the Mac app, with each bundled library's own
license. The source code of the LGPL and GPL parts in the downloads (FFmpeg, libmspack, and the
license, and in `SpeedBreaker.app/licenses` in the iPhone and iPad IPA. The source code of the LGPL and GPL parts in the downloads (FFmpeg, libmspack, and the
x86-64 download's glibc and GCC runtime) is published next to the downloads of each release, as
`SpeedBreaker-<version>-third-party-source.tar.xz`.
@@ -747,7 +755,8 @@ Corporation.**
"Need for Speed", "Most Wanted" and "Carbon" are trademarks of Electronic Arts Inc. Steam,
Steam Deck, Steam Machine, Steam Frame, SteamOS and their logos and icons are trademarks of
Valve Corporation. All other trademarks, including Xbox, Xbox 360, PlayStation, Apple, Mac,
macOS, iPhone, iPad, iCloud, Xcode, App Store, Backbone, Vulkan, Linux, Windows and Android,
macOS, iPhone, iPad, iCloud, Xcode, App Store, Backbone, Vulkan, Linux, Windows, Android, NVIDIA,
GeForce, AMD and Intel,
belong to their respective owners. They are used here only to describe what SpeedBreaker works
with. The game is named only to say which game SpeedBreaker runs; no logos, box art or key art
of the game are used. The game content visible in screenshots belongs to its owners.
+1 -1
View File
@@ -8,7 +8,7 @@ include, software by others under their own licenses, listed here. The license t
The downloads carry these notices too: the Mac app in `SpeedBreaker.app/Contents/Resources/licenses`,
each SteamOS download in its `licenses/` folder (with the license of every library it bundles,
collected when the bundle is made).
collected when the bundle is made), and the iPhone and iPad IPA in `SpeedBreaker.app/licenses`.
## Compiled into SpeedBreaker (every platform)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 KiB

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 294 KiB

+3 -3
View File
@@ -91,14 +91,14 @@ functions = [
{ address = 0x822C2068, size = 0xA8 },
{ address = 0x822C5AA0, size = 0x9C },
{ address = 0x822C5B40, size = 0xA8 },
{ address = 0x822C7720, size = 0x68 },
{ address = 0x822C7720, size = 0x108 },
{ address = 0x822C8630, size = 0x74 },
{ address = 0x822C89F8, size = 0x44 },
{ address = 0x822C89F8, size = 0x6C },
{ address = 0x822CDEB8, size = 0x26C },
{ address = 0x822D3B90, size = 0xDC },
{ address = 0x822D9A20, size = 0x12C },
{ address = 0x822E3D68, size = 0x304 },
{ address = 0x822E7CE0, size = 0x40 },
{ address = 0x822E7CE0, size = 0xA0 },
{ address = 0x822E7D88, size = 0x164 },
{ address = 0x822EA348, size = 0x248 },
{ address = 0x822EA590, size = 0x7C },
+45 -26
View File
@@ -2939,15 +2939,6 @@ labels = [
0x822C74F4,
]
[[switch]]
base = 0x822C7720
r = 11
default = 0x822C76E4
labels = [
0x822C7760,
0x822C7774,
]
[[switch]]
base = 0x822C8638
r = 3
@@ -2976,15 +2967,6 @@ labels = [
0x822C88D4,
]
[[switch]]
base = 0x822C89F8
r = 10
default = 0x822C89D0
labels = [
0x822C8A2C,
0x822C8A34,
]
[[switch]]
base = 0x822CC614
r = 11
@@ -3277,14 +3259,6 @@ labels = [
0x822E7CB0,
]
[[switch]]
base = 0x822E7CE0
r = 3
default = 0x822E7BC4
labels = [
0x822E7D14,
]
[[switch]]
base = 0x822E7D9C
r = 4
@@ -13320,6 +13294,51 @@ labels = [
0x822C5BD8,
]
# bctr 0x822c7734 in 0x822c7718: absolute table at 0x822c7738, index r6, 10 entries (no bounds check)
[[switch]]
base = 0x822C7728
r = 6
labels = [
0x822C7760,
0x822C7774,
0x822C7788,
0x822C779C,
0x822C77B0,
0x822C77C4,
0x822C77D8,
0x822C77EC,
0x822C7800,
0x822C7814,
]
# bctr 0x822c8a0c in 0x822c89f4: absolute table at 0x822c8a10, index r3, 7 entries (no bounds check)
[[switch]]
base = 0x822C8A00
r = 3
labels = [
0x822C8A2C,
0x822C8A34,
0x822C8A5C,
0x822C8A3C,
0x822C8A44,
0x822C8A4C,
0x822C8A54,
]
# bctr 0x822e7cf4 in 0x822e7cdc: absolute table at 0x822e7cf8, index r4, 7 entries (no bounds check)
[[switch]]
base = 0x822E7CE8
r = 4
labels = [
0x822E7D14,
0x822E7D20,
0x822E7D2C,
0x822E7D38,
0x822E7D44,
0x822E7D50,
0x822E7D5C,
]
# bctr 0x822e9a30 in 0x822e9a00: absolute table at 0x822e9a34, index r5, 56 entries (no bounds check)
[[switch]]
base = 0x822E9A24
+45
View File
@@ -131,6 +131,51 @@ labels = [
0x822C5BD8,
]
# bctr 0x822c7734 in 0x822c7718: absolute table at 0x822c7738, index r6, 10 entries (no bounds check)
[[switch]]
base = 0x822C7728
r = 6
labels = [
0x822C7760,
0x822C7774,
0x822C7788,
0x822C779C,
0x822C77B0,
0x822C77C4,
0x822C77D8,
0x822C77EC,
0x822C7800,
0x822C7814,
]
# bctr 0x822c8a0c in 0x822c89f4: absolute table at 0x822c8a10, index r3, 7 entries (no bounds check)
[[switch]]
base = 0x822C8A00
r = 3
labels = [
0x822C8A2C,
0x822C8A34,
0x822C8A5C,
0x822C8A3C,
0x822C8A44,
0x822C8A4C,
0x822C8A54,
]
# bctr 0x822e7cf4 in 0x822e7cdc: absolute table at 0x822e7cf8, index r4, 7 entries (no bounds check)
[[switch]]
base = 0x822E7CE8
r = 4
labels = [
0x822E7D14,
0x822E7D20,
0x822E7D2C,
0x822E7D38,
0x822E7D44,
0x822E7D50,
0x822E7D5C,
]
# bctr 0x822e9a30 in 0x822e9a00: absolute table at 0x822e9a34, index r5, 56 entries (no bounds check)
[[switch]]
base = 0x822E9A24
+32 -14
View File
@@ -18,20 +18,25 @@
// a step boundary, frames alternate between no step and two.
//
// Each guest vblank is made one step instead (NFSMW_GAME_TIME=0 turns this
// off). A frame is given k/60 s for the k guest vblanks that fired during it
// (counted from one frame's start to the next, 1 to 6), as the console's
// 60 Hz vblank would have given it; the stepper's elapsed time, then k steps
// give or take a tick, is rounded to k. The main loop starts a frame when D3D
// lets it, after the vblank that released the last one, so its start can
// jitter by most of a vblank without changing the count; a frame slower than
// the vblank starts anywhere between two, and its count is its length
// rounded either way. A frame no vblank fired in keeps its measured time,
// which later frames give back (game/whole_vblanks.h): game time adds up to
// the vblanks that fired. It runs at the display's rate over 60: 0.044% slow
// on a 59.97 Hz monitor, at most 2.5% (the lock's range). Frames of more
// than 6 vblanks (loading) keep their measured time. At Frame Rate 30
// (video::GuestVblankDivider) a guest vblank comes every other refresh and
// is given 2/60 s: two steps a frame, the game at its speed.
// off). A frame is given whole vblanks of 1/60 s, as the console's 60 Hz
// vblank would have given it; the stepper's elapsed time, then k steps give
// or take a tick, is rounded to k. The main loop runs ahead of the display:
// the game's render thread swaps (VdSwap) during the loop's next frame, and
// the command processor lets each swap go at a vblank about two frames after
// that, so the loop's frames drift against the vblanks (counted from one
// frame's start to the next) while the display shows one frame a vblank from
// the queue: a frame the loop is slow with counts 2 or 3, the next ones none.
// Each is given one vblank while game time stays within a few vblanks of the
// vblanks that fired (game/whole_vblanks.h; given their counts, the world
// ran 2-3 steps in one displayed frame and none in the next: GitHub issue
// #9). Further behind, the queue ran dry and the display waited, and the
// frame gets the rest; a frame slower than the vblank (GPU-bound) gets its
// count, its length rounded either way. Game time adds up to the vblanks
// that fired, to within those few. It runs at the display's rate over 60:
// 0.044% slow on a 59.97 Hz monitor, at most 2.5% (the lock's range). Frames
// of more than 6 vblanks (loading) keep their measured time. At Frame Rate
// 30 (video::GuestVblankDivider) a guest vblank comes every other refresh
// and is given 2/60 s: two steps a frame, the game at its speed.
#include <stdafx.h>
#include "frame_time.h"
#include "whole_vblanks.h"
@@ -50,6 +55,8 @@ namespace
return on;
}();
std::atomic<uint64_t> s_frames{ 0 }, s_snapped{ 0 }, s_noStep{ 0 }, s_multiStep{ 0 };
// Game time handed to the frames so far (game::FrameClockMs), ms in 16.16.
std::atomic<uint64_t> s_clock{ 0 };
// NFSMW_GAME_TIME_LOG=<file>: one line per frame, written when the next
// one starts: its measured ms, guest vblanks, whether it was given whole
// vblanks, the world steps it ran (-1: no world), how long after the last
@@ -86,12 +93,23 @@ namespace game
return { s_frames.load(std::memory_order_relaxed), s_noStep.load(std::memory_order_relaxed),
s_multiStep.load(std::memory_order_relaxed), s_snapped.load(std::memory_order_relaxed) };
}
double FrameClockMs()
{
return double(s_clock.load(std::memory_order_relaxed)) / 65536.0;
}
}
// GameFrameTime, before `bl 0x823AFE20` at 0x823B0148 in the main loop: r3 is
// the frame's measured time, milliseconds in 16.16 fixed point.
void GameFrameTime(PPCRegister& r3)
{
// Whatever the frame is given below (or its measured time), on every path.
struct AddToClock
{
PPCRegister& r3;
~AddToClock() { s_clock.fetch_add(uint64_t(std::max(int32_t(r3.u32), 0)), std::memory_order_relaxed); }
} addToClock{ r3 };
static uint64_t last = 0;
uint64_t now = video::GuestVblanks(), vblanks = now - last;
last = now;
+6
View File
@@ -15,4 +15,10 @@ namespace game
uint64_t frames, noStep, multiStep, snapped;
};
FrameTimeStats GetFrameTimeStats();
// The game time the main loop has handed its frames since launch, in ms:
// each frame's measured time, or the whole vblanks GameFrameTime gives it
// (1/60 s each). It moves once a frame, at the frame's start, paused or
// not; the game draws the frame after (the nitrous trails, game/light_trails.h).
double FrameClockMs();
}
+120
View File
@@ -0,0 +1,120 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See light_trails.h.
//
// One function override (the generated functions are weak; no ppc/
// regeneration): sub_82134608, the game's 4x4 matrix copy (r3 = destination,
// r4 = source), called from many places. Called from RenderFlares' ring
// write (ctx.lr = kRingWriteReturn), it first records the car's new entry
// and fills the ring's other two entries with the car 2/60 and 4/60 s of
// game time ago; then the copy runs as always.
// NFSMW_LIGHT_TRAILS=0 leaves the ring as the game writes it (an A/B switch:
// trails half the 360's length at 60 fps).
#include <stdafx.h>
#include "light_trails.h"
#include "frame_time.h"
#include <kernel/function.h>
#include <mutex>
#include <unordered_map>
extern "C" PPC_FUNC(__imp__sub_82134608);
namespace
{
using namespace game::trails;
const bool s_enabled = [] {
const char* v = std::getenv("NFSMW_LIGHT_TRAILS");
const bool on = !(v && v[0] == '0');
if (!on)
fprintf(stderr, "[trails] NFSMW_LIGHT_TRAILS=0: nitrous light trails as the game draws them at this frame rate "
"(the last two frames: half the 360's length at 60 fps)\n");
return on;
}();
std::mutex s_mutex;
// Per CarRenderInfo (guest address).
std::unordered_map<uint32_t, History> s_cars;
double s_lastPruneMs = 0.0;
float LoadFloat(uint8_t* base, uint32_t address)
{
const uint32_t bits = PPC_LOAD_U32(address);
float f;
memcpy(&f, &bits, sizeof(f));
return f;
}
void StoreFloat(uint8_t* base, uint32_t address, float f)
{
uint32_t bits;
memcpy(&bits, &f, sizeof(bits));
PPC_STORE_U32(address, bits);
}
void StoreEntry(uint8_t* base, uint32_t info, uint32_t slot, const Entry& e)
{
const uint32_t pos = info + kPositions + slot * kPositionBytes;
for (uint32_t k = 0; k < 3; k++)
StoreFloat(base, pos + k * 4, e.pos[k]);
memcpy(base + info + kMatrices + slot * kMatrixBytes, e.matrix.data(), kMatrixBytes);
}
void RingWrite(PPCContext& ctx, uint8_t* base)
{
const uint32_t info = ctx.r30.u32;
const int32_t newest = int32_t(PPC_LOAD_U32(info + kNewestIndex));
// The game's code as read (sub_824E6220): anything else, leave it be.
if (newest < 0 || newest > 2 || ctx.r3.u32 != info + kMatrices + uint32_t(newest) * kMatrixBytes)
{
static std::once_flag once;
std::call_once(once, [&] {
fprintf(stderr, "[trails] unexpected ring write (info %08X index %d destination %08X): trails left as the game draws them\n",
info, newest, ctx.r3.u32);
});
return;
}
Entry now;
const uint32_t stack = ctx.r1.u32 + kNewPositionOnStack;
for (uint32_t k = 0; k < 3; k++)
now.pos[k] = LoadFloat(base, stack + k * 4);
memcpy(now.matrix.data(), base + ctx.r4.u32, kMatrixBytes);
const double ms = game::FrameClockMs();
Fill fill;
{
std::lock_guard lock(s_mutex);
// Cars gone for a second are forgotten (CarRenderInfos are freed
// and reused).
if (ms - s_lastPruneMs > 1000.0 || ms < s_lastPruneMs)
{
std::erase_if(s_cars, [&](const auto& kv) { return ms - kv.second.NewestMs() > 1000.0; });
s_lastPruneMs = ms;
}
History& h = s_cars[info];
h.Add(ms, now);
fill = FillFor(h, ms);
}
const uint32_t slot = uint32_t(newest);
if (fill.hasMiddle)
StoreEntry(base, info, MiddleSlot(slot), fill.middle);
if (fill.hasOldest)
StoreEntry(base, info, OldestSlot(slot), fill.oldest);
if (fill.hasMiddle && fill.hasOldest)
{
static std::once_flag once;
std::call_once(once, [] {
fprintf(stderr, "[trails] nitrous light trails at the 360's length: the car's ring holds it now and %.1f and %.1f ms "
"of game time ago, as at the console's 30 fps (NFSMW_LIGHT_TRAILS=0: the game's last two frames)\n",
kConsoleFrameMs, 2.0 * kConsoleFrameMs);
});
}
}
}
PPC_FUNC(sub_82134608)
{
if (s_enabled && static_cast<uint32_t>(ctx.lr) == kRingWriteReturn)
RingWrite(ctx, base);
__imp__sub_82134608(ctx, base);
}
+174
View File
@@ -0,0 +1,174 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The nitrous light trails at the length the Xbox 360 draws them, at any
// frame rate (game/light_trails.cpp hooks the game; tests/light_trails_test.cpp
// checks this header alone). GitHub issue #10.
//
// The trail is not an effect on the picture: VehicleRenderConn::RenderFlares
// (sub_824E6220, called per view) keeps, for every car it draws in a player
// view (the view's id at +4 is 1 or 2) and not in a reflection, the car's
// render matrix and position in a ring of three in its CarRenderInfo (the
// connection's +68): the matrices at +4512 (64 bytes each), the positions at
// +4704 (16 bytes each, x y z used), the newest one's index at +4752. It
// advances the index and writes the newest entry once a call, so once a
// frame. Then, when the car's nitrous is on (CarRenderInfo +4492) and it
// moves (faster than 0.2 in the decompilation), it draws the car's light
// flares 8 times along each of the three segments between the entries
// (oldest to middle, newest to oldest, middle to newest; sub_824DF658 with
// flags 8 and 2), each with the matrix of the segment's start. So the trail
// is where the car was over the last two frames, 24 flares.
//
// A real Xbox 360 runs the race at 30 fps (a 60 fps capture of one shows every
// race frame twice, now and then three or four times), so its trail covers
// 4/60 s of driving. At SpeedBreaker's 60 fps it covered 2/60 s: half as long,
// with the same 24 flares packed into half the distance, so brighter.
//
// The fix keeps the game's code and its ring, and changes what is in the two
// entries the game doesn't write: at the ring write (the matrix copy
// sub_82134608 called from 0x824E63D0, after the index has moved and before
// any flare is drawn), the middle entry gets the car's position and matrix of
// 2/60 s of game time ago and the oldest those of 4/60 s ago, from a history
// of what the game wrote; the game then writes the newest as before. Game
// time is the time the main loop hands its frames (game::FrameClockMs: whole
// vblanks, so host timing jitter doesn't move it), and a paused game
// collapses the trail onto the car as a 360 does. At 30 fps those times are
// the frames the game wrote last and the one before (nothing changes); at 60
// fps the frames 2 and 4 before this one. When the history doesn't reach back
// that far (a car just drawn for the first time, or again after a gap), the
// game's own entry stays.
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
namespace game::trails
{
// ctx.lr when RenderFlares calls the matrix copy that writes the newest
// entry (bl 0x82134608 at 0x824E63D0; r3 = the entry, r4 = the matrix,
// r30 = the CarRenderInfo). The position is stored right after it, from
// the caller's r1 + 96.
constexpr uint32_t kRingWriteReturn = 0x824E63D4;
constexpr uint32_t kNewPositionOnStack = 96;
// CarRenderInfo's ring.
constexpr uint32_t kMatrices = 4512, kMatrixBytes = 64;
constexpr uint32_t kPositions = 4704, kPositionBytes = 16;
constexpr uint32_t kNewestIndex = 4752;
// One vblank of game time, a world step; the 360's race frame is two.
constexpr double kVblankMs = 1000.0 / 60.0;
constexpr double kConsoleFrameMs = 2.0 * kVblankMs;
// A sample this close to the wanted time is that time: game time is
// summed from 16.16 ms values, each rounded by at most 1/131072 ms.
constexpr double kSnapMs = 0.01;
// A car not drawn for this long starts its history over (a new car in a
// reused CarRenderInfo, or one back after a while off screen).
constexpr double kGapMs = 250.0;
// The ring entries around the newest (the index the game just moved to).
constexpr uint32_t MiddleSlot(uint32_t newest) { return (newest + 2) % 3; } // the console's last frame
constexpr uint32_t OldestSlot(uint32_t newest) { return (newest + 1) % 3; } // the one before
// One ring entry, the matrix as the game's bytes (copied, never read).
struct Entry
{
float pos[3] = {};
std::array<uint8_t, kMatrixBytes> matrix{};
};
// What the game wrote for one car, at the game times it wrote it.
class History
{
public:
static constexpr size_t kCapacity = 32; // reaches 4/60 s back at up to 465 fps
bool Empty() const { return count_ == 0; }
size_t Size() const { return count_; }
double NewestMs() const { return count_ ? At(count_ - 1).ms : 0.0; }
void Clear() { count_ = 0; }
// The entry the game wrote at `ms`. A second write at the same time
// (the same frame) replaces the first; time going back, or a gap of
// kGapMs, starts over.
void Add(double ms, const Entry& e)
{
if (count_)
{
const double newest = NewestMs();
if (std::abs(ms - newest) <= 1e-6)
{
Mutable(count_ - 1) = { ms, e };
return;
}
if (ms < newest || ms - newest > kGapMs)
count_ = 0;
}
if (count_ == kCapacity)
{
first_ = (first_ + 1) % kCapacity;
count_--;
}
Mutable(count_++) = { ms, e };
}
// The car at game time `ms`: a sample within kSnapMs of it as it was
// written, else the position between the two samples around it and
// the nearer one's matrix. False when the history doesn't reach back
// to `ms` (or `ms` is past the newest sample).
bool Get(double ms, Entry& out) const
{
if (!count_ || ms < At(0).ms - kSnapMs || ms > NewestMs() + kSnapMs)
return false;
for (size_t i = count_; i-- > 0;)
{
const Sample& s = At(i);
if (std::abs(s.ms - ms) <= kSnapMs)
{
out = s.e;
return true;
}
if (s.ms < ms && i + 1 < count_)
{
const Sample& n = At(i + 1);
const double f = (ms - s.ms) / (n.ms - s.ms);
for (int k = 0; k < 3; k++)
out.pos[k] = float(double(s.e.pos[k]) + (double(n.e.pos[k]) - double(s.e.pos[k])) * f);
out.matrix = f < 0.5 ? s.e.matrix : n.e.matrix;
return true;
}
}
return false;
}
private:
struct Sample
{
double ms = 0.0;
Entry e;
};
const Sample& At(size_t i) const { return samples_[(first_ + i) % kCapacity]; }
Sample& Mutable(size_t i) { return samples_[(first_ + i) % kCapacity]; }
std::array<Sample, kCapacity> samples_{};
size_t first_ = 0, count_ = 0;
};
// What to put in the two entries the game doesn't write, given the car's
// history (with the newest entry already added at `nowMs`): the middle
// one 2/60 s back, the oldest 4/60 s back. Each `has` is false where the
// history doesn't reach (keep the game's entry).
struct Fill
{
bool hasMiddle = false, hasOldest = false;
Entry middle, oldest;
};
inline Fill FillFor(const History& h, double nowMs)
{
Fill f;
f.hasMiddle = h.Get(nowMs - kConsoleFrameMs, f.middle);
f.hasOldest = h.Get(nowMs - 2.0 * kConsoleFrameMs, f.oldest);
return f;
}
}
+38 -20
View File
@@ -10,19 +10,32 @@
namespace game
{
// Game time follows the guest vblanks that fire: over any run of frames
// it adds up to them, to within a vblank, and each frame that saw any
// gets whole ones (1 to 6), so the fixed 1/60 s world step runs a whole
// number of times a frame.
// it adds up to them, to within kSlack vblanks, and each frame that saw
// any gets whole ones (1 to 6), so the fixed 1/60 s world step runs a
// whole number of times a frame.
// - A frame the vblank paces (60 fps held, or 30 at Frame Rate 30) saw
// one and gets one.
// - A frame it doesn't (GPU-bound) starts anywhere between two, so its
// count is its length rounded down or up: a 44 ms frame (2.64
// refreshes) sees 2 or 3, and gets them.
// - A frame no vblank fired in (a vblank thread waking late, as the
// Mac's does several ms at a time when its GPU is busy; or two frames
// between two vblanks) keeps its measured time, and is then ahead of
// the vblanks by that much: the next frames give it back, one whole
// vblank fewer when it adds up to one, never fewer than one.
// - The main loop runs up to a few frames ahead of the display (its
// render thread and the GPU ring queue them), so its own frames
// don't line up with the vblanks: a frame the loop is slow with
// (streaming, a file read, a page fault) spans 2 vblanks and the next
// one, catching up, none, while the display still shows one frame a
// vblank from the queue. Each of those frames gets one: game time may
// run up to kSlack vblanks behind or ahead of the vblanks that fired.
// Until 2026-10-11 they got their own counts (2, then the measured
// few ms): the world ran 2 steps in a frame the display showed for
// one vblank and none in the next, a hitch every few seconds while
// driving that no frame-time graph shows (GitHub issue #9).
// - Behind by more than that, the queue ran dry and the display waited
// (a frame shown twice): the frame gets the vblanks beyond kSlack,
// at most 6, so game time keeps up with the display.
// - A frame it doesn't pace (GPU-bound) starts anywhere between two,
// so its count is its length rounded down or up: a 44 ms frame (2.64
// refreshes) sees 2 or 3, and once game time is kSlack behind (the
// first such frame), gets them.
// - Ahead by kSlack (more frames than vblanks for a while: the queue
// filling after a load), a frame no vblank fired in keeps its
// measured time.
// The rule until 2026-10-07 gave a frame its count only when its
// measured time was within half a vblank of it, else the measured time:
// GPU-bound 44 ms frames got 3 vblanks or their own 2.64, so the game ran
@@ -32,11 +45,14 @@ namespace game
// 6-9% slow.
struct WholeVblanks
{
// Game time given beyond the vblanks that fired, in vblanks: at most
// kMaxAhead (a held vblank, as a GPU-only suspension's, isn't
// replayed: what can't be given back is forgotten).
// Game time given beyond the vblanks that fired, in vblanks (below 0:
// behind), within +-kSlack: what can't be given back is forgotten (a
// held vblank, as a GPU-only suspension's, isn't replayed).
double ahead = 0.0;
static constexpr double kMaxAhead = 6.0;
// How far the main loop's frames may drift from the vblanks without
// the display showing it: the frames queued between the loop and the
// display (2-3, measured on the Steam Machine).
static constexpr double kSlack = 3.0;
// A frame the main loop measured at `measuredMs`, during which
// `vblanks` guest vblanks fired (from its start to the next frame's),
@@ -49,14 +65,16 @@ namespace game
ahead = 0.0;
return 0;
}
if (vblanks == 0)
ahead -= double(vblanks);
if (ahead + 1.0 > kSlack)
{
ahead = std::min(ahead + std::max(measuredMs, 0.0) / vblankMs, kMaxAhead);
ahead = std::min(ahead + std::max(measuredMs, 0.0) / vblankMs, kSlack);
return 0;
}
double owed = double(vblanks) - ahead;
int64_t give = std::clamp<int64_t>(std::llround(owed), 1, 6);
ahead = double(give) - owed;
int64_t give = 1;
if (ahead + 1.0 < -kSlack)
give = std::clamp<int64_t>(std::llround(-kSlack - ahead), 1, 6);
ahead = std::max(ahead + double(give), -kSlack);
return uint64_t(give);
}
+35
View File
@@ -0,0 +1,35 @@
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// Draws in depth-only EDRAM mode (RB_MODECONTROL edram_mode kDepthOnly) run
// no pixel shader on Xenos: only the rasterised depth and stencil reach EDRAM
// (xenos/xenos.h, EdramMode::kDepthOnly; Xenia draws them without one). The
// pixel shader still bound then is whatever an earlier draw left, and running
// it gives the draw that shader's kills, alpha test and depth export.
//
// NFSMW's sun shadow pass draws its alpha-tested casters (trees, poles,
// signs) in colour+depth mode with a pixel shader that kills texels whose
// alpha is 0.2 or less. Its other casters are depth-only, drawn with that
// pixel shader still bound and vertex shaders that export no texture
// coordinates: the solid occluders (walls, concrete barriers, tunnel roofs
// and the hills over them) and, after the world's map is resolved to
// 03A8A000, the cars (the map with them is 0444E000). Run, the bound shader
// read texel (0,0)'s alpha for all of them and killed every pixel, so up to
// v0.1.2 no car or wall cast a sun shadow and tunnels let the sun through
// (GitHub issue #3).
#pragma once
#include "xenos/xenos.h"
namespace gpu
{
// Whether a draw in this EDRAM mode runs the guest's bound pixel shader.
// guestOverride (NFSMW_DEPTH_ONLY_PS=1, for A/B only): always, as up to
// v0.1.2.
constexpr bool RunsGuestPixelShader(xe::gpu::xenos::EdramMode mode, bool guestOverride = false)
{
return mode != xe::gpu::xenos::EdramMode::kDepthOnly || guestOverride;
}
// What a depth-only draw runs instead: no inputs, outputs, kill or
// resources, so it fits every pipeline layout and attachment set.
inline constexpr char kEmptyPixelShaderGlsl[] = "#version 460\nvoid main()\n{\n}\n";
}
+35 -1
View File
@@ -15,6 +15,7 @@
#include "shader_translator.h"
#include "zpd_report.h"
#include "depth24_glsl.h"
#include "depth_only.h"
#include "render_scale.h"
#include "shared_memory_glsl.h"
#include "xenos/registers.h"
@@ -1346,6 +1347,28 @@ namespace gpu::renderer
Shader* s_vs = nullptr;
Shader* s_ps = nullptr;
// Depth-only draws run no pixel shader on Xenos (depth_only.h): they
// get this empty one instead of the guest's bound one, left over from
// earlier draws. NFSMW_DEPTH_ONLY_PS=1 runs the bound one, as up to
// v0.1.2 (A/B only).
constexpr uint64_t kDepthOnlyPsHash = 0xDE97400000000001ull; // (a guest shader hashing to it: a 2^-64 chance)
const bool s_depthOnlyGuestPs = [] { const char* v = std::getenv("NFSMW_DEPTH_ONLY_PS"); return v && v[0] == '1'; }();
Shader* DepthOnlyPixelShader()
{
static Shader* const ps = [] {
auto& slot = s_shaders[kDepthOnlyPsHash];
slot = std::make_unique<Shader>();
slot->stage = ShaderStage::Pixel;
slot->hash = kDepthOnlyPsHash;
slot->info.ok = true;
slot->info.constPacked = true; // (reads no constants: none uploaded for it)
slot->info.glsl = gpu::kEmptyPixelShaderGlsl;
return slot.get();
}();
return ps;
}
// IM_LOAD shaders by where they are (LoadShader). A dense frame loads
// thousands, the same few hundred from the same addresses, and
// finding each by a hash of all its dwords (FNV-1a, a latency-bound
@@ -6996,6 +7019,15 @@ void main()
}
if (edramMode != xenos::EdramMode::kColorDepth && edramMode != xenos::EdramMode::kDepthOnly)
return;
// Depth-only: no pixel shader runs (depth_only.h); the guest's stays
// bound for the draws after this one.
struct RestorePs
{
Shader* ps;
~RestorePs() { s_ps = ps; }
} restorePs{ s_ps };
if (!gpu::RunsGuestPixelShader(edramMode, s_depthOnlyGuestPs))
s_ps = DepthOnlyPixelShader();
s_stats.draws++;
auto skip = [&](uint64_t key, const char* why) {
s_stats.skipped++;
@@ -7355,7 +7387,9 @@ void main()
if (polygonal && initiator.prim_type != xenos::PrimitiveType::kRectangleList)
cull = (modeCntl.cull_front ? VK_CULL_MODE_FRONT_BIT : 0) | (modeCntl.cull_back ? VK_CULL_MODE_BACK_BIT : 0);
auto colorControlKey = Reg<reg::RB_COLORCONTROL>();
bool alphaTest = colorControlKey.alpha_test_enable && colorControlKey.alpha_func != xenos::CompareFunction::kAlways;
// (The alpha test tests the pixel shader's alpha: none depth-only.)
bool alphaTest = colorControlKey.alpha_test_enable && colorControlKey.alpha_func != xenos::CompareFunction::kAlways &&
gpu::RunsGuestPixelShader(edramMode, s_depthOnlyGuestPs);
key.raster = cull | (modeCntl.face ? 4u : 0u) | (clipCntl.clip_disable ? 8u : 0u) | (depthBias ? 16u : 0u) |
(alphaTest ? 32u : 0u);
+8 -2
View File
@@ -44,11 +44,13 @@ iOS doesn't give an app that much by default, so the app asks for two entitlemen
([xnu, kern_exec.c](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_exec.c)).
Apple gives it to free Apple Accounts too, but not every tool asks for it.
What that means for each way of signing (as of October 2026; untested with a free account so far):
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, any tool, or Xcode | both | works, as tested |
| 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 |
@@ -63,6 +65,10 @@ start**, whose Details line names the entitlements this copy was signed with. Wh
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),
+3 -1
View File
@@ -93,7 +93,9 @@ xcodebuild -project "$ROOT/scripts/ios/SpeedBreaker.xcodeproj" -scheme SpeedBrea
MARKETING_VERSION="${VERSION:-0.0.0}" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
OTHER_LDFLAGS="${LIBS[*]}" build
APP=$DERIVED/Build/Products/Release-iphoneos/SpeedBreaker.app
nm -gU "$APP/SpeedBreaker" | grep -q ' _vkGetInstanceProcAddr$' || { echo "build.sh: vkGetInstanceProcAddr was stripped" >&2; exit 1; }
# 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
+3 -2
View File
@@ -201,8 +201,9 @@ To move it: copy or move the whole folder (with `data/`, which holds the
game: about 7 GB), then add `speedbreaker.sh` to Steam again on the other
machine (step 2). The game finds itself wherever the folder is. Keep the
folder on internal storage or on a card or drive formatted by SteamOS
(ext4): on one formatted for Windows (exFAT, FAT32, NTFS) the program can't
start, though such a drive is fine for carrying a copy of the folder. On a
(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.
+67 -4
View File
@@ -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)')
+5 -2
View File
@@ -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)
+12
View File
@@ -22,6 +22,10 @@ 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.
@@ -53,6 +57,9 @@ sb_test(frame_rate_policy_test SOURCES ${RT}/video/frame_rate_policy.cpp)
sb_test(swapchain_policy_test SOURCES ${RT}/video/swapchain_policy.cpp)
# Game time in whole vblanks (game/whole_vblanks.h), header only.
sb_test(whole_vblanks_test)
# 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)
sb_test(report_zip_test SOURCES ${RT}/report/zip.cpp ARGS ${CMAKE_CURRENT_BINARY_DIR}/report_zip_out)
find_package(Python3 COMPONENTS Interpreter)
@@ -108,6 +115,11 @@ sb_test(version_check_test)
# Check for Updates' logic: versions, GitHub's JSON, curl's output and the
# release notes (update/check.cpp, self-contained).
sb_test(update_check_test SOURCES ${RT}/update/check.cpp)
# The 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)
+212
View File
@@ -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;
}
+298
View File
@@ -0,0 +1,298 @@
// Tests for game::trails (runtime/game/light_trails.h): what the nitrous light
// trails' ring holds when RenderFlares draws them, with the hook and without,
// at 60 fps, 30 fps and frames between (GitHub issue #10). From the repo root:
// clang++ -std=c++20 -O2 -Iruntime tests/light_trails_test.cpp -o build/light_trails_test
// build/light_trails_test
// The game's side is simulated as sub_824E6220 does it: the index moves
// (-1 -> 0 first), the hook runs at the matrix copy, then the newest entry is
// written. The trail is drawn between the three entries, so its length is the
// distance from the newest to the oldest.
#include <game/light_trails.h>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>
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 namespace game::trails;
// A car on a straight line at `speed` units per ms, its matrix tagged
// with the time it was written (so a test can tell which frame's it is).
Entry CarAt(double ms, double speed)
{
Entry e;
e.pos[0] = float(1000.0 + speed * ms);
e.pos[1] = 5.0f;
e.pos[2] = float(-200.0 + 0.5 * speed * ms);
const uint64_t tag = uint64_t(std::llround(ms * 1000.0));
memcpy(e.matrix.data(), &tag, sizeof(tag));
return e;
}
uint64_t TagOf(const Entry& e)
{
uint64_t tag;
memcpy(&tag, e.matrix.data(), sizeof(tag));
return tag;
}
struct Ring
{
Entry e[3];
int32_t index = -1;
History history;
bool hooked = true;
// One RenderFlares call in a player view at game time `ms`.
void Draw(double ms, const Entry& now)
{
if (index < 0)
index = 0;
if (++index > 2)
index = 0;
const uint32_t slot = uint32_t(index);
if (hooked)
{
history.Add(ms, now);
const Fill f = FillFor(history, ms);
if (f.hasMiddle)
e[MiddleSlot(slot)] = f.middle;
if (f.hasOldest)
e[OldestSlot(slot)] = f.oldest;
}
e[slot] = now;
}
const Entry& Newest() const { return e[index]; }
const Entry& Middle() const { return e[MiddleSlot(uint32_t(index))]; }
const Entry& Oldest() const { return e[OldestSlot(uint32_t(index))]; }
};
double Dist(const Entry& a, const Entry& b)
{
double s = 0;
for (int k = 0; k < 3; k++)
s += (double(a.pos[k]) - double(b.pos[k])) * (double(a.pos[k]) - double(b.pos[k]));
return std::sqrt(s);
}
bool SameEntry(const Entry& a, const Entry& b)
{
return memcmp(a.pos, b.pos, sizeof(a.pos)) == 0 && a.matrix == b.matrix;
}
// Game time as GameFrameTime sums it: each frame's ms in 16.16, rounded.
struct Clock
{
uint64_t units = 0;
double ms = 0;
void Advance(double frameMs)
{
units += uint64_t(std::llround(frameMs * 65536.0));
ms = double(units) / 65536.0;
}
};
constexpr double kSpeed = 0.06; // units per ms: ~60 m/s if a unit is a metre
constexpr double kV = kVblankMs;
void SlotMath()
{
printf("slots\n");
// Newest 1 (the first write, from -1): middle 0, oldest 2; and around.
CHECK(MiddleSlot(1) == 0 && OldestSlot(1) == 2, "newest 1");
CHECK(MiddleSlot(2) == 1 && OldestSlot(2) == 0, "newest 2");
CHECK(MiddleSlot(0) == 2 && OldestSlot(0) == 1, "newest 0");
}
// 60 fps: the game alone spans 2 vblanks of driving; hooked, 4 (the 360's
// two 30 fps frames), the middle exactly halfway, each entry the frame's
// own (not interpolated).
void SixtyFps()
{
printf("60 fps\n");
Ring game, hooked;
game.hooked = false;
Clock c;
for (int f = 0; f < 120; f++)
{
c.Advance(kV);
game.Draw(c.ms, CarAt(c.ms, kSpeed));
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
}
const double gameSpan = Dist(game.Newest(), game.Oldest()), span = Dist(hooked.Newest(), hooked.Oldest());
const double want = Dist(CarAt(0, kSpeed), CarAt(4 * kV, kSpeed));
CHECK(std::abs(gameSpan - want / 2) < 1e-3, "the game's own span %.4f, want half the 360's %.4f", gameSpan, want / 2);
CHECK(std::abs(span - want) < 1e-3, "hooked span %.4f, want %.4f (4/60 s at the car's speed)", span, want);
CHECK(std::abs(Dist(hooked.Newest(), hooked.Middle()) - want / 2) < 1e-3, "middle not halfway");
// Entries are the samples the game wrote 2 and 4 frames ago, as written.
const uint64_t now = TagOf(hooked.Newest());
CHECK(now - TagOf(hooked.Middle()) == uint64_t(std::llround(2 * kV * 1000)) ||
now - TagOf(hooked.Middle()) == uint64_t(std::llround(2 * kV * 1000)) + 1,
"middle is the frame 2 vblanks back (tags %llu %llu)", (unsigned long long)now, (unsigned long long)TagOf(hooked.Middle()));
CHECK(now - TagOf(hooked.Oldest()) >= uint64_t(std::llround(4 * kV * 1000)) - 1 &&
now - TagOf(hooked.Oldest()) <= uint64_t(std::llround(4 * kV * 1000)) + 1,
"oldest is the frame 4 vblanks back");
}
// 30 fps (the 360's own rate, and Frame Rate 30): the hook writes exactly
// what the game had in those entries.
void ThirtyFpsUnchanged()
{
printf("30 fps: no change\n");
Ring game, hooked;
game.hooked = false;
Clock c;
int differ = 0;
for (int f = 0; f < 90; f++)
{
c.Advance(2 * kV);
game.Draw(c.ms, CarAt(c.ms, kSpeed));
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
for (int s = 0; s < 3; s++)
differ += !SameEntry(game.e[s], hooked.e[s]);
}
CHECK(differ == 0, "%d ring entries differ from the game's at 30 fps", differ);
}
// Frames between: Auto switching 60 -> 30 -> 60, a GPU-bound 2-vblank
// frame now and then, measured (unsnapped) frame times. The span stays
// 4/60 s of driving (to interpolation), never the game's 2 frames.
void MixedFrames()
{
printf("mixed frames\n");
Ring hooked;
Clock c;
std::vector<double> frames;
for (int i = 0; i < 60; i++)
frames.push_back(kV);
for (int i = 0; i < 30; i++)
frames.push_back(2 * kV);
for (int i = 0; i < 60; i++)
frames.push_back(i % 7 == 3 ? 2 * kV : kV);
for (int i = 0; i < 60; i++)
frames.push_back(kV * (0.9 + 0.2 * double((i * 37) % 11) / 10.0)); // 15.0-18.3 ms, as measured
double worst = 0;
for (size_t i = 0; i < frames.size(); i++)
{
c.Advance(frames[i]);
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
if (i < 8)
continue;
const double span = Dist(hooked.Newest(), hooked.Oldest()), want = Dist(CarAt(0, kSpeed), CarAt(4 * kV, kSpeed));
worst = std::max(worst, std::abs(span - want) / want);
}
CHECK(worst < 1e-3, "span off by %.4f%% of 4/60 s", worst * 100);
}
// Paused (the world stands still, frames go on): the trail collapses onto
// the car 4 vblanks after the stop, as a 360's does at its second paused
// frame (its ring then holds the stop three times).
void PausedCollapses()
{
printf("paused\n");
Ring hooked;
Clock c;
for (int f = 0; f < 60; f++)
{
c.Advance(kV);
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
}
const double stopMs = c.ms;
for (int f = 1; f <= 3; f++)
{
c.Advance(kV);
hooked.Draw(c.ms, CarAt(stopMs, kSpeed));
CHECK(Dist(hooked.Newest(), hooked.Oldest()) > 0, "collapsed %d vblanks after the stop, before 4", f);
}
c.Advance(kV);
hooked.Draw(c.ms, CarAt(stopMs, kSpeed));
CHECK(Dist(hooked.Newest(), hooked.Oldest()) == 0 && Dist(hooked.Newest(), hooked.Middle()) == 0, "not collapsed 4 vblanks after the stop");
}
// A car drawn for the first time, or again after a gap: where the history
// doesn't reach, the game's entries stay (never one from before the gap).
void ShortHistory()
{
printf("short history and gaps\n");
Ring game, hooked;
game.hooked = false;
Clock c;
for (int f = 0; f < 4; f++)
{
c.Advance(kV);
game.Draw(c.ms, CarAt(c.ms, kSpeed));
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
const Fill fill = FillFor(hooked.history, c.ms);
CHECK(fill.hasMiddle == (f >= 2) && fill.hasOldest == false, "frame %d: middle %d oldest %d", f, fill.hasMiddle, fill.hasOldest);
for (int s = 0; s < 3; s++)
CHECK(f >= 2 || SameEntry(game.e[s], hooked.e[s]), "frame %d entry %d changed with no history", f, s);
}
c.Advance(kV);
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
CHECK(FillFor(hooked.history, c.ms).hasOldest, "4 vblanks of history: oldest filled");
// Off screen for half a second, then back somewhere else.
c.Advance(500.0);
const double back = c.ms;
hooked.Draw(c.ms, CarAt(c.ms + 1e5, kSpeed));
CHECK(hooked.history.Size() == 1, "history kept across a %.0f ms gap (%zu samples)", 500.0, hooked.history.Size());
const Fill fill = FillFor(hooked.history, back);
CHECK(!fill.hasMiddle && !fill.hasOldest, "filled from before the gap");
}
// Two player-view calls in one frame (the same game time): one sample.
void SameFrameTwice()
{
printf("same frame twice\n");
History h;
h.Add(100.0, CarAt(100.0, kSpeed));
h.Add(100.0, CarAt(100.0, kSpeed));
CHECK(h.Size() == 1, "%zu samples", h.Size());
h.Add(90.0, CarAt(90.0, kSpeed));
CHECK(h.Size() == 1 && h.NewestMs() == 90.0, "time going back starts over");
}
// Up to 360 fps (well past any display the vblank locks to) the history
// still reaches 4/60 s back.
void HighFrameRates()
{
printf("high frame rates\n");
for (double fps : { 90.0, 120.0, 144.0, 240.0, 360.0 })
{
Ring hooked;
Clock c;
for (int f = 0; f < 200; f++)
{
c.Advance(1000.0 / fps);
hooked.Draw(c.ms, CarAt(c.ms, kSpeed));
}
const double span = Dist(hooked.Newest(), hooked.Oldest()), want = Dist(CarAt(0, kSpeed), CarAt(4 * kV, kSpeed));
CHECK(std::abs(span - want) / want < 1e-3, "%.0f fps: span %.4f, want %.4f", fps, span, want);
}
}
}
int main()
{
SlotMath();
SixtyFps();
ThirtyFpsUnchanged();
MixedFrames();
PausedCollapses();
ShortHistory();
SameFrameTwice();
HighFrameRates();
if (g_failures)
{
printf("%d FAILED\n", g_failures);
return 1;
}
printf("all passed\n");
return 0;
}
+279
View File
@@ -0,0 +1,279 @@
// Tests for config/switch_tables.toml against config/nfsmw.toml's leaf
// function bounds: the jump tables XenonRecomp turns into C++ switches.
// XenonAnalyse takes a table's index register, label count and default from
// the bounds check before it (a cmplwi and a bgt/ble within 32
// instructions). A table with no bounds check of its own got the compare of
// the code before it, across a return: in v0.1.2 sub_822E7CE0 (the
// Performance Tuning row's label, GitHub issue #8) switched on r3 over one
// label instead of r4 over seven, so every row read "Steering"; two more
// leaf functions (sub_822C7720, sub_822C89F8) had the same fault.
// survey/fix_switch_tables.py now drops such tables and
// survey/find_missing_tables.py decodes them from the dispatch. Checked
// here, with no game files:
// - every table inside a leaf function the config bounds keeps its labels
// and its default (0 = a conditional return) inside that function: a
// borrowed default lies before the function's start;
// - the three tables as the guest code dispatches them;
// - the v0.1.2 entries, planted, fail the first check.
// From the repo root:
// clang++ -std=c++20 -O2 tests/switch_tables_test.cpp -o build/switch_tables_test
// build/switch_tables_test config/switch_tables.toml config/nfsmw.toml
#include <cinttypes>
#include <cstdint>
#include <cstdio>
#include <fstream>
#include <regex>
#include <sstream>
#include <string>
#include <vector>
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)
struct Table
{
uint32_t base = 0;
uint32_t r = 99;
bool hasDefault = false;
uint32_t defaultLabel = 0;
std::vector<uint32_t> labels;
};
struct Function
{
uint32_t address = 0;
uint32_t size = 0;
bool Contains(uint32_t a) const { return a >= address && a < address + size; }
};
static std::string ReadFile(const char* path)
{
std::ifstream f(path, std::ios::binary);
std::stringstream s;
s << f.rdbuf();
return s.str();
}
// The subset of TOML the generators write: [[switch]] blocks of
// `base = 0x..`, `r = N`, `default = 0x..` and a `labels = [ 0x.., ]` list.
static std::vector<Table> ParseTables(const std::string& text)
{
std::vector<Table> tables;
std::istringstream in(text);
std::string line;
bool inLabels = false;
std::smatch m;
static const std::regex kKey(R"(^\s*(base|r|default)\s*=\s*(0x[0-9A-Fa-f]+|\d+)\s*$)");
static const std::regex kLabel(R"(^\s*(0x[0-9A-Fa-f]+)\s*,?\s*$)");
while (std::getline(in, line))
{
if (line.rfind("[[switch]]", 0) == 0)
{
tables.emplace_back();
inLabels = false;
continue;
}
if (tables.empty() || line.empty() || line[0] == '#')
continue;
Table& t = tables.back();
if (inLabels)
{
if (line.find(']') != std::string::npos)
inLabels = false;
else if (std::regex_match(line, m, kLabel))
t.labels.push_back(uint32_t(std::stoul(m[1].str(), nullptr, 16)));
continue;
}
if (line.rfind("labels", 0) == 0)
{
inLabels = true;
continue;
}
if (std::regex_match(line, m, kKey))
{
uint32_t v = uint32_t(std::stoul(m[2].str(), nullptr, 0));
if (m[1] == "base")
t.base = v;
else if (m[1] == "r")
t.r = v;
else
{
t.hasDefault = true;
t.defaultLabel = v;
}
}
}
return tables;
}
static std::vector<Function> ParseFunctions(const std::string& text)
{
std::vector<Function> functions;
static const std::regex kFunction(R"(\{\s*address\s*=\s*(0x[0-9A-Fa-f]+)\s*,\s*size\s*=\s*(0x[0-9A-Fa-f]+)\s*\})");
for (std::sregex_iterator it(text.begin(), text.end(), kFunction), end; it != end; ++it)
functions.push_back({ uint32_t(std::stoul((*it)[1].str(), nullptr, 16)), uint32_t(std::stoul((*it)[2].str(), nullptr, 16)) });
return functions;
}
// Tables inside a bounded leaf function with a label or a default outside
// it, as "base: what" lines.
static std::vector<std::string> Escapes(const std::vector<Table>& tables, const std::vector<Function>& functions)
{
std::vector<std::string> out;
char buf[160];
for (const Table& t : tables)
for (const Function& f : functions)
{
if (!f.Contains(t.base))
continue;
if (t.hasDefault && t.defaultLabel != 0 && !f.Contains(t.defaultLabel))
{
snprintf(buf, sizeof(buf), "%08X: default %08X outside %08X+%X", t.base, t.defaultLabel, f.address, f.size);
out.push_back(buf);
}
for (uint32_t label : t.labels)
if (!f.Contains(label))
{
snprintf(buf, sizeof(buf), "%08X: label %08X outside %08X+%X", t.base, label, f.address, f.size);
out.push_back(buf);
}
}
return out;
}
// The table XenonRecomp uses at a bctr: the last [[switch]] keyed at or
// before it within the dispatch (XenonAnalyse keys the lis 20 bytes before
// the bctr, find_missing_tables.py the rlwinm 12 bytes before).
static const Table* TableAt(const std::vector<Table>& tables, uint32_t bctr)
{
const Table* best = nullptr;
for (const Table& t : tables)
if (t.base <= bctr && bctr - t.base <= 0x20 && (!best || t.base > best->base))
best = &t;
return best;
}
static const Function* FunctionAt(const std::vector<Function>& functions, uint32_t a)
{
for (const Function& f : functions)
if (f.Contains(a))
return &f;
return nullptr;
}
// The guest's dispatch: index register, table entries, last instruction.
struct Dispatch
{
const char* what;
uint32_t function;
uint32_t bctr;
uint32_t index;
std::vector<uint32_t> labels;
uint32_t end; // one past the last case's blr
};
static const Dispatch kDispatches[] = {
// Performance Tuning: the row's label string hash by slider (r4, 0-6:
// Steering ... Nitrous, then Turbo or Supercharger by r3).
{ "tuning row label", 0x822E7CE0, 0x822E7CF4, 4,
{ 0x822E7D14, 0x822E7D20, 0x822E7D2C, 0x822E7D38, 0x822E7D44, 0x822E7D50, 0x822E7D5C }, 0x822E7D80 },
// A count and its value from one of ten fields of the record in r3
// (x2000, x450, x250, ...), stored to *r4 and *r5.
{ "count and value by r6", 0x822C7720, 0x822C7734, 6,
{ 0x822C7760, 0x822C7774, 0x822C7788, 0x822C779C, 0x822C77B0,
0x822C77C4, 0x822C77D8, 0x822C77EC, 0x822C7800, 0x822C7814 }, 0x822C7828 },
// 0-6 -> 0-6 (the identity, written as a table).
{ "enum by r3", 0x822C89F8, 0x822C8A0C, 3,
{ 0x822C8A2C, 0x822C8A34, 0x822C8A5C, 0x822C8A3C, 0x822C8A44, 0x822C8A4C, 0x822C8A54 }, 0x822C8A64 },
};
// v0.1.2's entries for those dispatches (XenonAnalyse's output) and their
// function bounds.
static const char kReleased[] = R"(
[[switch]]
base = 0x822C7720
r = 11
default = 0x822C76E4
labels = [
0x822C7760,
0x822C7774,
]
[[switch]]
base = 0x822C89F8
r = 10
default = 0x822C89D0
labels = [
0x822C8A2C,
0x822C8A34,
]
[[switch]]
base = 0x822E7CE0
r = 3
default = 0x822E7BC4
labels = [
0x822E7D14,
]
)";
static const char kReleasedFunctions[] = R"(
{ address = 0x822C7720, size = 0x68 },
{ address = 0x822C89F8, size = 0x44 },
{ address = 0x822E7CE0, size = 0x40 },
)";
static bool Matches(const Table* t, const Function* f, const Dispatch& d)
{
return t && f && t->r == d.index && t->labels == d.labels && f->address == d.function && f->address + f->size >= d.end;
}
int main(int argc, char** argv)
{
if (argc < 3)
{
fprintf(stderr, "usage: switch_tables_test <config/switch_tables.toml> <config/nfsmw.toml>\n");
return 2;
}
std::vector<Table> tables = ParseTables(ReadFile(argv[1]));
std::vector<Function> functions = ParseFunctions(ReadFile(argv[2]));
printf("switch_tables_test: %zu tables, %zu bounded leaf functions\n", tables.size(), functions.size());
CHECK(tables.size() > 500, "%zu tables: the file wasn't read", tables.size());
CHECK(functions.size() > 100, "%zu functions: the file wasn't read", functions.size());
for (const Table& t : tables)
CHECK(t.base != 0 && t.r < 32 && !t.labels.empty(), "an entry at %08X didn't parse (r %u, %zu labels)", t.base, t.r, t.labels.size());
for (const std::string& e : Escapes(tables, functions))
CHECK(false, "%s", e.c_str());
for (const Dispatch& d : kDispatches)
{
const Table* t = TableAt(tables, d.bctr);
const Function* f = FunctionAt(functions, d.bctr);
CHECK(t != nullptr, "%s: no table for the bctr at %08X", d.what, d.bctr);
CHECK(f != nullptr, "%s: no bounded function holds %08X", d.what, d.bctr);
if (!t || !f)
continue;
CHECK(t->r == d.index, "%s: switches on r%u, the guest indexes with r%u", d.what, t->r, d.index);
CHECK(t->labels == d.labels, "%s: %zu labels, the guest's table has %zu", d.what, t->labels.size(), d.labels.size());
CHECK(!t->hasDefault, "%s: a default label %08X, the guest has no bounds check", d.what, t->defaultLabel);
CHECK(f->address == d.function && f->address + f->size >= d.end,
"%s: function %08X+%X, the cases run %08X..%08X", d.what, f->address, f->size, d.function, d.end);
}
// Planted: the released entries must fail both checks.
std::vector<Table> released = ParseTables(kReleased);
std::vector<Function> releasedFunctions = ParseFunctions(kReleasedFunctions);
std::vector<std::string> escapes = Escapes(released, releasedFunctions);
CHECK(escapes.size() == 3, "the v0.1.2 entries escape their functions %zu times, expected 3 (one default each)", escapes.size());
for (const Dispatch& d : kDispatches)
CHECK(!Matches(TableAt(released, d.bctr), FunctionAt(releasedFunctions, d.bctr), d),
"%s: the v0.1.2 entry passes as the guest's dispatch", d.what);
if (g_failures)
{
printf("switch_tables_test: %d FAILED\n", g_failures);
return 1;
}
printf("switch_tables_test: all passed\n");
return 0;
}
+169 -17
View File
@@ -33,12 +33,92 @@ namespace
return std::abs(measuredMs / vblankMs - double(vblanks)) > 0.5 ? 0 : vblanks;
}
// The rule from 2026-10-07 to 2026-10-11: each frame its own count,
// game time level with the vblanks after every frame (a frame that saw
// none kept its measured time and was given back by the next ones).
struct CountRule
{
double ahead = 0.0;
uint64_t Give(double measuredMs, uint64_t vblanks, double vblankMs)
{
if (vblanks > 6)
{
ahead = 0.0;
return 0;
}
if (vblanks == 0)
{
ahead = std::min(ahead + std::max(measuredMs, 0.0) / vblankMs, 6.0);
return 0;
}
double owed = double(vblanks) - ahead;
int64_t give = std::clamp<int64_t>(std::llround(owed), 1, 6);
ahead = double(give) - owed;
return uint64_t(give);
}
};
struct Frame
{
double ms;
uint64_t vblanks;
};
// GitHub issue #9's mechanism: the main loop runs up to `queue` frames
// ahead of a display that shows one frame a vblank (the render thread
// and the GPU ring hold them). A frame's work is 4-10 ms, and with
// probability `hiccupP` 15 ms or more longer (streaming, a page fault):
// up to `hiccupMs`. The loop starts a frame when the last one is done
// and the queue has room (a vblank took a frame from it). Returns the
// frames' measured times and counts, and how many vblanks found the
// queue empty (the display showed a frame again).
std::vector<Frame> QueuedLoop(int queue, double hiccupP, double hiccupMs, int count, uint32_t seed, int& dry)
{
std::mt19937 rng(seed);
std::uniform_real_distribution<double> unit(0.0, 1.0);
std::vector<double> starts, done;
std::vector<int> shownAt; // the vblank each frame was first shown at
double t = 0.0;
int nextShown = 0; // frames shown so far
int vblank = 1; // the next vblank to fire, at vblank * kRefreshMs
dry = 0;
auto fireUntil = [&](double until) {
for (; double(vblank) * kRefreshMs <= until; vblank++)
{
if (nextShown < int(done.size()) && done[size_t(nextShown)] <= double(vblank) * kRefreshMs)
shownAt.push_back(vblank), nextShown++;
else if (nextShown > 0)
dry++;
}
};
for (int k = 0; k <= count; k++)
{
// Room in the queue: frames done but not shown fewer than `queue`.
while (int(done.size()) - nextShown >= queue)
{
double next = double(vblank) * kRefreshMs;
fireUntil(next);
t = std::max(t, next);
}
fireUntil(t);
starts.push_back(t);
double work = 4.0 + 6.0 * unit(rng);
if (unit(rng) < hiccupP)
work += 15.0 + (hiccupMs - 15.0) * unit(rng);
t += work;
done.push_back(t);
fireUntil(t);
}
std::vector<Frame> out;
for (int k = 0; k < count; k++)
{
double a = starts[size_t(k)], b = starts[size_t(k) + 1];
uint64_t n = uint64_t(std::floor(b / kRefreshMs) - std::floor(a / kRefreshMs));
out.push_back({ b - a, n });
}
return out;
}
struct Totals
{
double measuredMs = 0, vblankMs = 0, newMs = 0, oldMs = 0;
@@ -106,32 +186,102 @@ namespace
return out;
}
// Issue #9: the queued loop at 60 fps held. With no vblank finding the
// queue empty, every frame is shown for one vblank and must get one.
void QueuedLoops()
{
struct Case
{
const char* name;
int queue;
double hiccupP, hiccupMs;
};
const Case cases[] = {
{ "queue 2, a 15-30 ms hiccup every ~3 s", 2, 1.0 / 180, 30.0 },
{ "queue 3, a 15-45 ms hiccup every ~3 s", 3, 1.0 / 180, 45.0 },
{ "queue 3, a 15-45 ms hiccup every ~0.5 s (a Deck-like CPU)", 3, 1.0 / 30, 45.0 },
{ "queue 2, a 15-80 ms hiccup every ~1 s (some run the queue dry)", 2, 1.0 / 60, 80.0 },
};
for (const Case& c : cases)
{
uint64_t frames = 0, newOff = 0, oldOff = 0, dryTotal = 0;
double worst = 0;
for (int p = 0; p < 8; p++)
{
int dry = 0;
std::vector<Frame> loop = QueuedLoop(c.queue, c.hiccupP, c.hiccupMs, 6000, 77u + uint32_t(p), dry);
game::WholeVblanks rule;
CountRule old;
double given = 0, fired = 0;
for (const Frame& f : loop)
{
uint64_t k = rule.Give(f.ms, f.vblanks, kRefreshMs), o = old.Give(f.ms, f.vblanks, kRefreshMs);
newOff += k != 1;
oldOff += o != 1;
given += k ? double(k) : f.ms / kRefreshMs;
fired += double(f.vblanks);
}
frames += loop.size();
dryTotal += uint64_t(dry);
worst = std::max(worst, std::abs(given - fired));
}
printf(" %-62s frames not given 1: %llu (the rule until 2026-10-11: %llu) of %llu; vblanks with the queue empty %llu; "
"game time within %.2f vblanks\n", c.name, (unsigned long long)newOff, (unsigned long long)oldOff,
(unsigned long long)frames, (unsigned long long)dryTotal, worst);
CHECK(oldOff > 0, "%s: the old rule's hitches are there to remove (%llu)", c.name, (unsigned long long)oldOff);
if (dryTotal == 0)
CHECK(newOff == 0, "%s: issue #9: every frame given 1 while the display shows one a vblank (%llu not)", c.name,
(unsigned long long)newOff);
else
CHECK(newOff <= dryTotal && newOff < oldOff, "%s: only frames after an empty queue given more (%llu, %llu empty, old %llu)",
c.name, (unsigned long long)newOff, (unsigned long long)dryTotal, (unsigned long long)oldOff);
CHECK(worst <= game::WholeVblanks::kSlack + 1.0, "%s: game time within kSlack + 1 vblanks (%.2f)", c.name, worst);
}
}
void Synthetic()
{
// Frame by frame, from a fresh start.
{
game::WholeVblanks r;
CHECK(r.Give(16.6, 1, kRefreshMs) == 1, "a 60 fps frame: 1");
CHECK(r.Give(44.0, 3, kRefreshMs) == 3, "44 ms, 3 fired (2.64 rounded up): 3");
CHECK(r.Give(44.0, 2, kRefreshMs) == 2, "44 ms, 2 fired (2.64 rounded down): 2; the half-vblank rule kept 44 ms");
CHECK(HalfVblankRule(44.0, 2, kRefreshMs) == 0, "the old rule's miss");
CHECK(r.ahead == 0.0, "nothing ahead");
CHECK(HalfVblankRule(44.0, 2, kRefreshMs) == 0, "the old rule's miss: 44 ms, 2 fired, kept 44 ms");
// GPU-bound 44 ms frames (2.64 refreshes, 2 or 3 fired): game time
// falls kSlack behind first, then each gets its count.
CHECK(r.Give(44.0, 3, kRefreshMs) == 1 && r.ahead == -2.0, "first slow frame: 1 (%.2f behind)", -r.ahead);
CHECK(r.Give(44.0, 2, kRefreshMs) == 1 && r.ahead == -3.0, "then 1, kSlack behind (%.2f)", -r.ahead);
CHECK(r.Give(44.0, 3, kRefreshMs) == 3, "3 fired, kSlack behind: 3");
CHECK(r.Give(44.0, 2, kRefreshMs) == 2, "2 fired: 2");
CHECK(r.ahead == -game::WholeVblanks::kSlack, "and stays kSlack behind (%.2f)", r.ahead);
CHECK(r.Give(44.0, 1, 2 * kRefreshMs) == 1 && r.Give(44.0, 2, 2 * kRefreshMs) == 2,
"Frame Rate 30: 44 ms is 1.32 guest vblanks of 33.3 ms; 1 or 2 fired, given");
// A late vblank: an 18.7 ms frame sees none (its measured time),
// the next sees 2 and is given 1; then 1 each.
CHECK(r.Give(18.7, 0, kRefreshMs) == 0, "none fired: measured time");
CHECK(std::abs(r.ahead - 18.7 / kRefreshMs) < 1e-9, "ahead by the measured time (%.3f)", r.ahead);
CHECK(r.Give(18.7, 2, kRefreshMs) == 1, "the catch-up gives back the vblank");
CHECK(r.Give(18.7, 1, kRefreshMs) == 1, "then one each");
CHECK(std::abs(r.ahead - (18.7 / kRefreshMs - 1.0)) < 1e-9, "the fraction stays (%.3f)", r.ahead);
// Ahead by more than half a vblank: a frame of 1 still gets 1.
CHECK(r.Give(16.0, 0, kRefreshMs) == 0 && r.ahead > 1.0, "ahead by more than a vblank");
CHECK(r.Give(16.6, 1, kRefreshMs) == 1, "never fewer than one");
r.Reset();
CHECK(r.ahead == 0.0, "reset");
// GitHub issue #9: at 60 fps held, the main loop's frames drift
// against the vblanks while the display shows one frame a vblank
// from its queue: a slow frame sees 2 or 3, the next ones none.
// Each is shown for one vblank and gets one.
const uint64_t drift[] = { 1, 2, 0, 1, 1, 3, 0, 0, 1, 2, 1, 0, 1 };
bool allOne = true;
for (uint64_t k : drift)
allOne &= r.Give(k == 0 ? 4.0 : 16.6 * double(k), k, kRefreshMs) == 1;
CHECK(allOne && r.ahead == 0.0, "issue #9: every drifting frame given 1, nothing left over (%.2f)", r.ahead);
// A slow frame of 6 (100 ms: the queue ran dry, the display waited
// 3 vblanks): it gets the 3 beyond kSlack, the 3 catching up one each.
CHECK(r.Give(100.0, 6, kRefreshMs) == 3, "6 fired, 3 beyond kSlack: 3");
CHECK(r.Give(3.0, 0, kRefreshMs) == 1 && r.Give(3.0, 0, kRefreshMs) == 1 && r.Give(3.0, 0, kRefreshMs) == 1 && r.ahead == 0.0,
"the frames catching up: 1 each, back level (%.2f)", r.ahead);
// More frames than vblanks (the queue filling): one each up to
// kSlack ahead, then their measured time.
CHECK(r.Give(4.0, 0, kRefreshMs) == 1 && r.Give(4.0, 0, kRefreshMs) == 1 && r.Give(4.0, 0, kRefreshMs) == 1, "kSlack frames ahead: 1 each");
CHECK(r.Give(4.0, 0, kRefreshMs) == 0 && r.ahead == game::WholeVblanks::kSlack, "then measured time, never more than kSlack ahead");
CHECK(r.Give(16.6, 1, kRefreshMs) == 1 && r.ahead == game::WholeVblanks::kSlack, "kSlack ahead, a frame of 1: 1 (it fits)");
CHECK(r.Give(33.3, 2, kRefreshMs) == 1 && r.ahead == game::WholeVblanks::kSlack - 1.0, "a frame of 2: 1, giving one back");
CHECK(r.Give(120.0, 7, kRefreshMs) == 0 && r.ahead == 0.0, "7 vblanks (loading): measured time, and a fresh start");
for (int i = 0; i < 20; i++)
r.Give(30.0, 0, kRefreshMs);
CHECK(r.ahead == game::WholeVblanks::kMaxAhead, "ahead at most %.0f vblanks", game::WholeVblanks::kMaxAhead);
CHECK(r.ahead == game::WholeVblanks::kSlack, "ahead at most kSlack (%.2f)", r.ahead);
r.Reset();
CHECK(r.ahead == 0.0 && r.Give(16.6, 1, 0.0) == 0, "no vblank period: measured time");
}
@@ -174,9 +324,9 @@ namespace
printf(" %-48s new rule within %.2f vblanks of the vblanks' time; half-vblank rule x%.4f; %llu of %llu frames given whole vblanks\n",
c.name, worst, oldMean, (unsigned long long)given, (unsigned long long)frames);
// Game time against the vblanks that fired, over 4000 frames:
// within what can be ahead at the end (half a vblank, and a last
// frame that saw none), whatever the frames did.
CHECK(worst <= 2.0, "%s: the new rule within 2 vblanks of the vblanks (%.2f)", c.name, worst);
// within kSlack (and a last frame that saw none), whatever the
// frames did.
CHECK(worst <= game::WholeVblanks::kSlack + 1.0, "%s: the new rule within kSlack + 1 vblanks of the vblanks (%.2f)", c.name, worst);
CHECK(oldMean >= c.oldMin && oldMean <= c.oldMax, "%s: the half-vblank rule's known bias (x%.4f)", c.name, oldMean);
if (c.paced)
CHECK(pacedOne == paced && given == frames, "%s: every paced frame given one vblank (%llu of %llu)", c.name,
@@ -229,6 +379,8 @@ int main(int argc, char** argv)
{
printf("synthetic frames:\n");
Synthetic();
printf("a main loop queued ahead of the display (GitHub issue #9):\n");
QueuedLoops();
if (argc > 1)
printf("logs:\n");
for (int i = 1; i < argc; i++)