Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b49fc321d1 | ||
|
|
34e1f75901 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 531 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 656 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 294 KiB |
@@ -91,14 +91,14 @@ functions = [
|
||||
{ address = 0x822C2068, size = 0xA8 },
|
||||
{ address = 0x822C5AA0, size = 0x9C },
|
||||
{ address = 0x822C5B40, size = 0xA8 },
|
||||
{ address = 0x822C7720, size = 0x68 },
|
||||
{ address = 0x822C7720, size = 0x108 },
|
||||
{ address = 0x822C8630, size = 0x74 },
|
||||
{ address = 0x822C89F8, size = 0x44 },
|
||||
{ address = 0x822C89F8, size = 0x6C },
|
||||
{ address = 0x822CDEB8, size = 0x26C },
|
||||
{ address = 0x822D3B90, size = 0xDC },
|
||||
{ address = 0x822D9A20, size = 0x12C },
|
||||
{ address = 0x822E3D68, size = 0x304 },
|
||||
{ address = 0x822E7CE0, size = 0x40 },
|
||||
{ address = 0x822E7CE0, size = 0xA0 },
|
||||
{ address = 0x822E7D88, size = 0x164 },
|
||||
{ address = 0x822EA348, size = 0x248 },
|
||||
{ address = 0x822EA590, size = 0x7C },
|
||||
|
||||
@@ -2939,15 +2939,6 @@ labels = [
|
||||
0x822C74F4,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C7720
|
||||
r = 11
|
||||
default = 0x822C76E4
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C8638
|
||||
r = 3
|
||||
@@ -2976,15 +2967,6 @@ labels = [
|
||||
0x822C88D4,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822C89F8
|
||||
r = 10
|
||||
default = 0x822C89D0
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822CC614
|
||||
r = 11
|
||||
@@ -3277,14 +3259,6 @@ labels = [
|
||||
0x822E7CB0,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822E7CE0
|
||||
r = 3
|
||||
default = 0x822E7BC4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
]
|
||||
|
||||
[[switch]]
|
||||
base = 0x822E7D9C
|
||||
r = 4
|
||||
@@ -13320,6 +13294,51 @@ labels = [
|
||||
0x822C5BD8,
|
||||
]
|
||||
|
||||
# bctr 0x822c7734 in 0x822c7718: absolute table at 0x822c7738, index r6, 10 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C7728
|
||||
r = 6
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
0x822C7788,
|
||||
0x822C779C,
|
||||
0x822C77B0,
|
||||
0x822C77C4,
|
||||
0x822C77D8,
|
||||
0x822C77EC,
|
||||
0x822C7800,
|
||||
0x822C7814,
|
||||
]
|
||||
|
||||
# bctr 0x822c8a0c in 0x822c89f4: absolute table at 0x822c8a10, index r3, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C8A00
|
||||
r = 3
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
0x822C8A5C,
|
||||
0x822C8A3C,
|
||||
0x822C8A44,
|
||||
0x822C8A4C,
|
||||
0x822C8A54,
|
||||
]
|
||||
|
||||
# bctr 0x822e7cf4 in 0x822e7cdc: absolute table at 0x822e7cf8, index r4, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E7CE8
|
||||
r = 4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
0x822E7D20,
|
||||
0x822E7D2C,
|
||||
0x822E7D38,
|
||||
0x822E7D44,
|
||||
0x822E7D50,
|
||||
0x822E7D5C,
|
||||
]
|
||||
|
||||
# bctr 0x822e9a30 in 0x822e9a00: absolute table at 0x822e9a34, index r5, 56 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E9A24
|
||||
|
||||
@@ -131,6 +131,51 @@ labels = [
|
||||
0x822C5BD8,
|
||||
]
|
||||
|
||||
# bctr 0x822c7734 in 0x822c7718: absolute table at 0x822c7738, index r6, 10 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C7728
|
||||
r = 6
|
||||
labels = [
|
||||
0x822C7760,
|
||||
0x822C7774,
|
||||
0x822C7788,
|
||||
0x822C779C,
|
||||
0x822C77B0,
|
||||
0x822C77C4,
|
||||
0x822C77D8,
|
||||
0x822C77EC,
|
||||
0x822C7800,
|
||||
0x822C7814,
|
||||
]
|
||||
|
||||
# bctr 0x822c8a0c in 0x822c89f4: absolute table at 0x822c8a10, index r3, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822C8A00
|
||||
r = 3
|
||||
labels = [
|
||||
0x822C8A2C,
|
||||
0x822C8A34,
|
||||
0x822C8A5C,
|
||||
0x822C8A3C,
|
||||
0x822C8A44,
|
||||
0x822C8A4C,
|
||||
0x822C8A54,
|
||||
]
|
||||
|
||||
# bctr 0x822e7cf4 in 0x822e7cdc: absolute table at 0x822e7cf8, index r4, 7 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E7CE8
|
||||
r = 4
|
||||
labels = [
|
||||
0x822E7D14,
|
||||
0x822E7D20,
|
||||
0x822E7D2C,
|
||||
0x822E7D38,
|
||||
0x822E7D44,
|
||||
0x822E7D50,
|
||||
0x822E7D5C,
|
||||
]
|
||||
|
||||
# bctr 0x822e9a30 in 0x822e9a00: absolute table at 0x822e9a34, index r5, 56 entries (no bounds check)
|
||||
[[switch]]
|
||||
base = 0x822E9A24
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,4 +15,10 @@ namespace game
|
||||
uint64_t frames, noStep, multiStep, snapped;
|
||||
};
|
||||
FrameTimeStats GetFrameTimeStats();
|
||||
|
||||
// The game time the main loop has handed its frames since launch, in ms:
|
||||
// each frame's measured time, or the whole vblanks GameFrameTime gives it
|
||||
// (1/60 s each). It moves once a frame, at the frame's start, paused or
|
||||
// not; the game draws the frame after (the nitrous trails, game/light_trails.h).
|
||||
double FrameClockMs();
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See light_trails.h.
|
||||
//
|
||||
// One function override (the generated functions are weak; no ppc/
|
||||
// regeneration): sub_82134608, the game's 4x4 matrix copy (r3 = destination,
|
||||
// r4 = source), called from many places. Called from RenderFlares' ring
|
||||
// write (ctx.lr = kRingWriteReturn), it first records the car's new entry
|
||||
// and fills the ring's other two entries with the car 2/60 and 4/60 s of
|
||||
// game time ago; then the copy runs as always.
|
||||
// NFSMW_LIGHT_TRAILS=0 leaves the ring as the game writes it (an A/B switch:
|
||||
// trails half the 360's length at 60 fps).
|
||||
#include <stdafx.h>
|
||||
#include "light_trails.h"
|
||||
#include "frame_time.h"
|
||||
|
||||
#include <kernel/function.h>
|
||||
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
extern "C" PPC_FUNC(__imp__sub_82134608);
|
||||
|
||||
namespace
|
||||
{
|
||||
using namespace game::trails;
|
||||
|
||||
const bool s_enabled = [] {
|
||||
const char* v = std::getenv("NFSMW_LIGHT_TRAILS");
|
||||
const bool on = !(v && v[0] == '0');
|
||||
if (!on)
|
||||
fprintf(stderr, "[trails] NFSMW_LIGHT_TRAILS=0: nitrous light trails as the game draws them at this frame rate "
|
||||
"(the last two frames: half the 360's length at 60 fps)\n");
|
||||
return on;
|
||||
}();
|
||||
|
||||
std::mutex s_mutex;
|
||||
// Per CarRenderInfo (guest address).
|
||||
std::unordered_map<uint32_t, History> s_cars;
|
||||
double s_lastPruneMs = 0.0;
|
||||
|
||||
float LoadFloat(uint8_t* base, uint32_t address)
|
||||
{
|
||||
const uint32_t bits = PPC_LOAD_U32(address);
|
||||
float f;
|
||||
memcpy(&f, &bits, sizeof(f));
|
||||
return f;
|
||||
}
|
||||
|
||||
void StoreFloat(uint8_t* base, uint32_t address, float f)
|
||||
{
|
||||
uint32_t bits;
|
||||
memcpy(&bits, &f, sizeof(bits));
|
||||
PPC_STORE_U32(address, bits);
|
||||
}
|
||||
|
||||
void StoreEntry(uint8_t* base, uint32_t info, uint32_t slot, const Entry& e)
|
||||
{
|
||||
const uint32_t pos = info + kPositions + slot * kPositionBytes;
|
||||
for (uint32_t k = 0; k < 3; k++)
|
||||
StoreFloat(base, pos + k * 4, e.pos[k]);
|
||||
memcpy(base + info + kMatrices + slot * kMatrixBytes, e.matrix.data(), kMatrixBytes);
|
||||
}
|
||||
|
||||
void RingWrite(PPCContext& ctx, uint8_t* base)
|
||||
{
|
||||
const uint32_t info = ctx.r30.u32;
|
||||
const int32_t newest = int32_t(PPC_LOAD_U32(info + kNewestIndex));
|
||||
// The game's code as read (sub_824E6220): anything else, leave it be.
|
||||
if (newest < 0 || newest > 2 || ctx.r3.u32 != info + kMatrices + uint32_t(newest) * kMatrixBytes)
|
||||
{
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [&] {
|
||||
fprintf(stderr, "[trails] unexpected ring write (info %08X index %d destination %08X): trails left as the game draws them\n",
|
||||
info, newest, ctx.r3.u32);
|
||||
});
|
||||
return;
|
||||
}
|
||||
Entry now;
|
||||
const uint32_t stack = ctx.r1.u32 + kNewPositionOnStack;
|
||||
for (uint32_t k = 0; k < 3; k++)
|
||||
now.pos[k] = LoadFloat(base, stack + k * 4);
|
||||
memcpy(now.matrix.data(), base + ctx.r4.u32, kMatrixBytes);
|
||||
|
||||
const double ms = game::FrameClockMs();
|
||||
Fill fill;
|
||||
{
|
||||
std::lock_guard lock(s_mutex);
|
||||
// Cars gone for a second are forgotten (CarRenderInfos are freed
|
||||
// and reused).
|
||||
if (ms - s_lastPruneMs > 1000.0 || ms < s_lastPruneMs)
|
||||
{
|
||||
std::erase_if(s_cars, [&](const auto& kv) { return ms - kv.second.NewestMs() > 1000.0; });
|
||||
s_lastPruneMs = ms;
|
||||
}
|
||||
History& h = s_cars[info];
|
||||
h.Add(ms, now);
|
||||
fill = FillFor(h, ms);
|
||||
}
|
||||
const uint32_t slot = uint32_t(newest);
|
||||
if (fill.hasMiddle)
|
||||
StoreEntry(base, info, MiddleSlot(slot), fill.middle);
|
||||
if (fill.hasOldest)
|
||||
StoreEntry(base, info, OldestSlot(slot), fill.oldest);
|
||||
if (fill.hasMiddle && fill.hasOldest)
|
||||
{
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [] {
|
||||
fprintf(stderr, "[trails] nitrous light trails at the 360's length: the car's ring holds it now and %.1f and %.1f ms "
|
||||
"of game time ago, as at the console's 30 fps (NFSMW_LIGHT_TRAILS=0: the game's last two frames)\n",
|
||||
kConsoleFrameMs, 2.0 * kConsoleFrameMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PPC_FUNC(sub_82134608)
|
||||
{
|
||||
if (s_enabled && static_cast<uint32_t>(ctx.lr) == kRingWriteReturn)
|
||||
RingWrite(ctx, base);
|
||||
__imp__sub_82134608(ctx, base);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
||||
//
|
||||
// The nitrous light trails at the length the Xbox 360 draws them, at any
|
||||
// frame rate (game/light_trails.cpp hooks the game; tests/light_trails_test.cpp
|
||||
// checks this header alone). GitHub issue #10.
|
||||
//
|
||||
// The trail is not an effect on the picture: VehicleRenderConn::RenderFlares
|
||||
// (sub_824E6220, called per view) keeps, for every car it draws in a player
|
||||
// view (the view's id at +4 is 1 or 2) and not in a reflection, the car's
|
||||
// render matrix and position in a ring of three in its CarRenderInfo (the
|
||||
// connection's +68): the matrices at +4512 (64 bytes each), the positions at
|
||||
// +4704 (16 bytes each, x y z used), the newest one's index at +4752. It
|
||||
// advances the index and writes the newest entry once a call, so once a
|
||||
// frame. Then, when the car's nitrous is on (CarRenderInfo +4492) and it
|
||||
// moves (faster than 0.2 in the decompilation), it draws the car's light
|
||||
// flares 8 times along each of the three segments between the entries
|
||||
// (oldest to middle, newest to oldest, middle to newest; sub_824DF658 with
|
||||
// flags 8 and 2), each with the matrix of the segment's start. So the trail
|
||||
// is where the car was over the last two frames, 24 flares.
|
||||
//
|
||||
// A real Xbox 360 runs the race at 30 fps (a 60 fps capture of one shows every
|
||||
// race frame twice, now and then three or four times), so its trail covers
|
||||
// 4/60 s of driving. At SpeedBreaker's 60 fps it covered 2/60 s: half as long,
|
||||
// with the same 24 flares packed into half the distance, so brighter.
|
||||
//
|
||||
// The fix keeps the game's code and its ring, and changes what is in the two
|
||||
// entries the game doesn't write: at the ring write (the matrix copy
|
||||
// sub_82134608 called from 0x824E63D0, after the index has moved and before
|
||||
// any flare is drawn), the middle entry gets the car's position and matrix of
|
||||
// 2/60 s of game time ago and the oldest those of 4/60 s ago, from a history
|
||||
// of what the game wrote; the game then writes the newest as before. Game
|
||||
// time is the time the main loop hands its frames (game::FrameClockMs: whole
|
||||
// vblanks, so host timing jitter doesn't move it), and a paused game
|
||||
// collapses the trail onto the car as a 360 does. At 30 fps those times are
|
||||
// the frames the game wrote last and the one before (nothing changes); at 60
|
||||
// fps the frames 2 and 4 before this one. When the history doesn't reach back
|
||||
// that far (a car just drawn for the first time, or again after a gap), the
|
||||
// game's own entry stays.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace game::trails
|
||||
{
|
||||
// ctx.lr when RenderFlares calls the matrix copy that writes the newest
|
||||
// entry (bl 0x82134608 at 0x824E63D0; r3 = the entry, r4 = the matrix,
|
||||
// r30 = the CarRenderInfo). The position is stored right after it, from
|
||||
// the caller's r1 + 96.
|
||||
constexpr uint32_t kRingWriteReturn = 0x824E63D4;
|
||||
constexpr uint32_t kNewPositionOnStack = 96;
|
||||
// CarRenderInfo's ring.
|
||||
constexpr uint32_t kMatrices = 4512, kMatrixBytes = 64;
|
||||
constexpr uint32_t kPositions = 4704, kPositionBytes = 16;
|
||||
constexpr uint32_t kNewestIndex = 4752;
|
||||
|
||||
// One vblank of game time, a world step; the 360's race frame is two.
|
||||
constexpr double kVblankMs = 1000.0 / 60.0;
|
||||
constexpr double kConsoleFrameMs = 2.0 * kVblankMs;
|
||||
// A sample this close to the wanted time is that time: game time is
|
||||
// summed from 16.16 ms values, each rounded by at most 1/131072 ms.
|
||||
constexpr double kSnapMs = 0.01;
|
||||
// A car not drawn for this long starts its history over (a new car in a
|
||||
// reused CarRenderInfo, or one back after a while off screen).
|
||||
constexpr double kGapMs = 250.0;
|
||||
|
||||
// The ring entries around the newest (the index the game just moved to).
|
||||
constexpr uint32_t MiddleSlot(uint32_t newest) { return (newest + 2) % 3; } // the console's last frame
|
||||
constexpr uint32_t OldestSlot(uint32_t newest) { return (newest + 1) % 3; } // the one before
|
||||
|
||||
// One ring entry, the matrix as the game's bytes (copied, never read).
|
||||
struct Entry
|
||||
{
|
||||
float pos[3] = {};
|
||||
std::array<uint8_t, kMatrixBytes> matrix{};
|
||||
};
|
||||
|
||||
// What the game wrote for one car, at the game times it wrote it.
|
||||
class History
|
||||
{
|
||||
public:
|
||||
static constexpr size_t kCapacity = 32; // reaches 4/60 s back at up to 465 fps
|
||||
|
||||
bool Empty() const { return count_ == 0; }
|
||||
size_t Size() const { return count_; }
|
||||
double NewestMs() const { return count_ ? At(count_ - 1).ms : 0.0; }
|
||||
|
||||
void Clear() { count_ = 0; }
|
||||
|
||||
// The entry the game wrote at `ms`. A second write at the same time
|
||||
// (the same frame) replaces the first; time going back, or a gap of
|
||||
// kGapMs, starts over.
|
||||
void Add(double ms, const Entry& e)
|
||||
{
|
||||
if (count_)
|
||||
{
|
||||
const double newest = NewestMs();
|
||||
if (std::abs(ms - newest) <= 1e-6)
|
||||
{
|
||||
Mutable(count_ - 1) = { ms, e };
|
||||
return;
|
||||
}
|
||||
if (ms < newest || ms - newest > kGapMs)
|
||||
count_ = 0;
|
||||
}
|
||||
if (count_ == kCapacity)
|
||||
{
|
||||
first_ = (first_ + 1) % kCapacity;
|
||||
count_--;
|
||||
}
|
||||
Mutable(count_++) = { ms, e };
|
||||
}
|
||||
|
||||
// The car at game time `ms`: a sample within kSnapMs of it as it was
|
||||
// written, else the position between the two samples around it and
|
||||
// the nearer one's matrix. False when the history doesn't reach back
|
||||
// to `ms` (or `ms` is past the newest sample).
|
||||
bool Get(double ms, Entry& out) const
|
||||
{
|
||||
if (!count_ || ms < At(0).ms - kSnapMs || ms > NewestMs() + kSnapMs)
|
||||
return false;
|
||||
for (size_t i = count_; i-- > 0;)
|
||||
{
|
||||
const Sample& s = At(i);
|
||||
if (std::abs(s.ms - ms) <= kSnapMs)
|
||||
{
|
||||
out = s.e;
|
||||
return true;
|
||||
}
|
||||
if (s.ms < ms && i + 1 < count_)
|
||||
{
|
||||
const Sample& n = At(i + 1);
|
||||
const double f = (ms - s.ms) / (n.ms - s.ms);
|
||||
for (int k = 0; k < 3; k++)
|
||||
out.pos[k] = float(double(s.e.pos[k]) + (double(n.e.pos[k]) - double(s.e.pos[k])) * f);
|
||||
out.matrix = f < 0.5 ? s.e.matrix : n.e.matrix;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Sample
|
||||
{
|
||||
double ms = 0.0;
|
||||
Entry e;
|
||||
};
|
||||
const Sample& At(size_t i) const { return samples_[(first_ + i) % kCapacity]; }
|
||||
Sample& Mutable(size_t i) { return samples_[(first_ + i) % kCapacity]; }
|
||||
|
||||
std::array<Sample, kCapacity> samples_{};
|
||||
size_t first_ = 0, count_ = 0;
|
||||
};
|
||||
|
||||
// What to put in the two entries the game doesn't write, given the car's
|
||||
// history (with the newest entry already added at `nowMs`): the middle
|
||||
// one 2/60 s back, the oldest 4/60 s back. Each `has` is false where the
|
||||
// history doesn't reach (keep the game's entry).
|
||||
struct Fill
|
||||
{
|
||||
bool hasMiddle = false, hasOldest = false;
|
||||
Entry middle, oldest;
|
||||
};
|
||||
inline Fill FillFor(const History& h, double nowMs)
|
||||
{
|
||||
Fill f;
|
||||
f.hasMiddle = h.Get(nowMs - kConsoleFrameMs, f.middle);
|
||||
f.hasOldest = h.Get(nowMs - 2.0 * kConsoleFrameMs, f.oldest);
|
||||
return f;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
XenonAnalyse game/files/default.xex game/survey/switch_tables.raw.toml
|
||||
python3 survey/fix_switch_tables.py # writes config/switch_tables.toml
|
||||
python3 survey/find_missing_tables.py > config/switch_tables_inline.toml
|
||||
python3 survey/fix_switch_tables.py # again, with the new inline tables
|
||||
python3 survey/leaf_switch_bounds.py --write
|
||||
|
||||
Reads game/survey/image.bin (survey/xexsurvey.cpp) for the bounds checks.
|
||||
"""
|
||||
import re
|
||||
import re, struct
|
||||
|
||||
RAW, OUT = 'game/survey/switch_tables.raw.toml', 'config/switch_tables.toml'
|
||||
|
||||
@@ -21,15 +26,73 @@ ADD = [
|
||||
0x825A4970, 0x825A49A0, 0x825A49CC, 0x825A49F8, 0x825A4A2C, 0x825A47B4]),
|
||||
]
|
||||
|
||||
IMG, IMG_BASE = 'game/survey/image.bin', 0x82000000
|
||||
img = open(IMG, 'rb').read()
|
||||
word = lambda a: struct.unpack('>I', img[a - IMG_BASE:a - IMG_BASE + 4])[0]
|
||||
pdata_starts = set()
|
||||
for a in range(0x820B1200, 0x820DD6B8, 8):
|
||||
pdata_starts.add(word(a))
|
||||
|
||||
|
||||
def is_exit(w):
|
||||
"""b/ba, blr, bctr, or a bc/bclr/bcctr that always branches (BO 1z1zz)."""
|
||||
op, bo = w >> 26, (w >> 21) & 31
|
||||
if op == 18:
|
||||
return w & 1 == 0
|
||||
if op == 16:
|
||||
return bo & 0x14 == 0x14 and w & 1 == 0
|
||||
if op == 19 and (w >> 1) & 0x3FF in (16, 528):
|
||||
return bo & 0x14 == 0x14 and w & 1 == 0
|
||||
return False
|
||||
|
||||
|
||||
def borrowed_bounds_check(base):
|
||||
"""XenonAnalyse's ScanTable takes a table's bounds check from the code
|
||||
before its `lis`: walking back up to 32 instructions, the first bgt/ble,
|
||||
then the first cmplwi on that cr gives `r`, the label count (immediate+1)
|
||||
and `default`. A table with no bounds check of its own (a leaf function
|
||||
whose caller guarantees the index) gets a compare from whatever precedes
|
||||
it, across a return and into the previous function. Returns (the cmplwi,
|
||||
the first exit or .pdata start between it and the table), or None when
|
||||
the compare reaches the table."""
|
||||
cr = None
|
||||
for i in range(32):
|
||||
a = base - 4 * i
|
||||
w = word(a)
|
||||
op, bo, bi = w >> 26, (w >> 21) & 31, (w >> 16) & 31
|
||||
if cr is None:
|
||||
if (op == 16 or (op == 19 and (w >> 1) & 0x3FF == 16)) and bi % 4 == 1 and bo & 0x1C in (0x0C, 0x04):
|
||||
cr = bi // 4
|
||||
elif op == 10 and (w >> 21) & 1 == 0 and (w >> 23) & 7 == cr: # cmplwi on that cr
|
||||
cut = [x for x in range(a + 4, base, 4) if is_exit(word(x))]
|
||||
cut += [x for x in range(a + 4, base + 4, 4) if x in pdata_starts]
|
||||
return (a, min(cut)) if cut else None
|
||||
return None
|
||||
|
||||
|
||||
text = open(RAW).read()
|
||||
head, *blocks = text.split('[[switch]]')
|
||||
kept = []
|
||||
kept, borrowed = [], []
|
||||
for b in blocks:
|
||||
m = re.search(r'base = 0x([0-9A-F]+)', b)
|
||||
if m and int(m.group(1), 16) in DROP:
|
||||
continue
|
||||
# A table whose bounds check is borrowed (see borrowed_bounds_check) has
|
||||
# the wrong index register and too few labels. XenonRecomp then emits a
|
||||
# switch on that register over those labels with an unreachable default,
|
||||
# so the function always takes its first case: every Performance Tuning
|
||||
# row got the first row's label ("Steering", GitHub issue #8). Drop it:
|
||||
# find_missing_tables.py decodes it from its dispatch, as a table with no
|
||||
# bounds check. Regenerate config/switch_tables_inline.toml after this.
|
||||
if m and 'default = ' in b:
|
||||
cut = borrowed_bounds_check(int(m.group(1), 16))
|
||||
if cut:
|
||||
borrowed.append((int(m.group(1), 16), *cut))
|
||||
continue
|
||||
kept.append(b)
|
||||
assert len(blocks) - len(kept) == len(DROP), 'a DROP entry no longer matches the analyzer output'
|
||||
assert len(blocks) - len(kept) == len(DROP) + len(borrowed), 'a DROP entry no longer matches the analyzer output'
|
||||
for base, cmp, cut in borrowed:
|
||||
print(f'borrowed bounds check: table 0x{base:08X} took cmplwi 0x{cmp:08X}, cut off at 0x{cut:08X}; dropped')
|
||||
|
||||
out = head + '[[switch]]'.join([''] + kept)
|
||||
out = out.rstrip() + '\n\n# ---- MANUAL (survey/fix_switch_tables.py) ----\n'
|
||||
@@ -42,4 +105,4 @@ for t in ADD:
|
||||
out += '# ---- INLINE (config/switch_tables_inline.toml) ----\n'
|
||||
out += open('config/switch_tables_inline.toml').read()
|
||||
open(OUT, 'w').write(out)
|
||||
print(f'{OUT}: {len(kept) + len(ADD)} tables ({len(DROP)} dropped, {len(ADD)} added)')
|
||||
print(f'{OUT}: {len(kept) + len(ADD)} tables ({len(DROP) + len(borrowed)} dropped, {len(ADD)} added)')
|
||||
@@ -129,11 +129,14 @@ block = '\n'.join(lines)
|
||||
if '--write' in sys.argv:
|
||||
MARKER = '# --- generated by survey/leaf_switch_bounds.py --write ---'
|
||||
cfg = 'config/nfsmw.toml'
|
||||
text = open(cfg).read().split('# --- generated by survey/leaf_switch_bounds.py')[0].rstrip()
|
||||
full = open(cfg).read()
|
||||
text = full.split('# --- generated by survey/leaf_switch_bounds.py')[0].rstrip()
|
||||
# Keep what follows the generated list (the hooks).
|
||||
rest = full.split(MARKER, 1)[1].split('\n]\n', 1)[1] if MARKER in full else ''
|
||||
open(cfg, 'w').write(text + '\n\n' + MARKER + '\n'
|
||||
'# Leaf functions (no .pdata) with jump tables: start = latest entry (bl target,\n'
|
||||
'# data-referenced address, or after padding); end = branch-closure extent;\n'
|
||||
'# overlaps merged. Plus MANUAL entries. Do not edit by hand; rerun.\n' + block + '\n')
|
||||
'# overlaps merged. Plus MANUAL entries. Do not edit by hand; rerun.\n' + block + '\n' + rest)
|
||||
print(f'wrote {cfg}', file=sys.stderr)
|
||||
else:
|
||||
print(block)
|
||||
@@ -22,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)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Tests for gpu/depth_only.h: draws in depth-only EDRAM mode run no pixel
|
||||
// shader (Xenos writes their rasterised depth and stencil only). Up to v0.1.2
|
||||
// the renderer ran the pixel shader still bound, and in the sun's shadow pass
|
||||
// that was the alpha-tested tree casters' one: with no texture coordinates
|
||||
// from the cars' (and tunnel roofs') vertex shaders it read texel (0,0)'s
|
||||
// alpha and killed every pixel, so cars cast no sun shadow and tunnels let
|
||||
// the sun through. From the repo root:
|
||||
// clang++ -std=c++20 -O2 -Iruntime tests/depth_only_test.cpp -o build/depth_only_test
|
||||
// build/depth_only_test runtime/gpu/renderer.cpp
|
||||
// The shadow pass is modelled with the order and the state the game's
|
||||
// commands have, on a small depth map. With
|
||||
// renderer.cpp's path (ctest passes it), Draw is also checked to use the
|
||||
// rule: the empty pixel shader for depth-only draws, before its shader checks
|
||||
// and its pipeline, and no alpha test for them.
|
||||
#include <gpu/depth_only.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using xe::gpu::xenos::EdramMode;
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
||||
|
||||
static void TestRule()
|
||||
{
|
||||
CHECK(gpu::RunsGuestPixelShader(EdramMode::kColorDepth), "colour+depth draws run their pixel shader");
|
||||
CHECK(!gpu::RunsGuestPixelShader(EdramMode::kDepthOnly), "depth-only draws run none");
|
||||
CHECK(gpu::RunsGuestPixelShader(EdramMode::kDepthOnly, true), "NFSMW_DEPTH_ONLY_PS=1: the bound one, as up to v0.1.2");
|
||||
CHECK(gpu::RunsGuestPixelShader(EdramMode::kColorDepth, true), "the override changes depth-only draws only");
|
||||
}
|
||||
|
||||
static void TestEmptyShader()
|
||||
{
|
||||
const std::string g = gpu::kEmptyPixelShaderGlsl;
|
||||
CHECK(g.rfind("#version 460\n", 0) == 0, "a GLSL 4.60 shader: %s", g.c_str());
|
||||
CHECK(g.find("void main()") != std::string::npos, "has main: %s", g.c_str());
|
||||
for (const char* word : { "discard", "out ", "in ", "uniform", "layout", "buffer", "gl_FragDepth" })
|
||||
CHECK(g.find(word) == std::string::npos, "no '%s' (no kill, outputs, inputs or resources): %s", word, g.c_str());
|
||||
}
|
||||
|
||||
// The sun's shadow pass on a 32x32 map, as the game draws it each frame:
|
||||
// a depth-only clear, the alpha-tested world casters (colour+depth), the
|
||||
// depth-only world occluders (a tunnel roof), the resolve to 03A8A000 (B),
|
||||
// the depth-only car casters, the resolve to 0444E000 (A).
|
||||
namespace model
|
||||
{
|
||||
constexpr int kSize = 32;
|
||||
using Map = std::array<float, kSize * kSize>;
|
||||
|
||||
// The tree casters' pixel shader (ps_7f7cf48a3edfed26): kill when the
|
||||
// texture's alpha at the interpolated coordinate is 0.2 or less (PS c0.x,
|
||||
// with c1.x = 1 to enable it). Its texture: alpha 0 at texel (0,0) (as
|
||||
// the one bound for the cars samples), 1 over the leaves.
|
||||
float Alpha(int u, int v) { return (u == 0 && v == 0) ? 0.0f : 1.0f; }
|
||||
bool Kills(bool hasTexcoord, int u, int v)
|
||||
{
|
||||
// No texture coordinate exported by the vertex shader: the input
|
||||
// register reads 0 (as the renderer, and Xenia, give it).
|
||||
float a = hasTexcoord ? Alpha(u, v) : Alpha(0, 0);
|
||||
return a <= 0.2f;
|
||||
}
|
||||
|
||||
struct Draw
|
||||
{
|
||||
EdramMode mode;
|
||||
int x0, y0, x1, y1;
|
||||
float z;
|
||||
bool texcoord; // the vertex shader exports one
|
||||
bool always; // depth test ALWAYS (the clear), else LEQUAL
|
||||
bool treeShader; // the pixel shader bound is the tree casters' (else the clear's, which kills nothing)
|
||||
};
|
||||
|
||||
void Run(Map& depth, const Draw& d, bool guestOverride)
|
||||
{
|
||||
bool shaderRuns = gpu::RunsGuestPixelShader(d.mode, guestOverride);
|
||||
for (int y = d.y0; y < d.y1; y++)
|
||||
for (int x = d.x0; x < d.x1; x++)
|
||||
{
|
||||
if (shaderRuns && d.treeShader && Kills(d.texcoord, x + 1, y + 1))
|
||||
continue;
|
||||
float& z = depth[y * kSize + x];
|
||||
if (d.always || d.z <= z)
|
||||
z = d.z;
|
||||
}
|
||||
}
|
||||
|
||||
struct Pass { Map b, a; };
|
||||
|
||||
Pass ShadowPass(bool guestOverride)
|
||||
{
|
||||
Map depth;
|
||||
depth.fill(0.0f); // whatever EDRAM held
|
||||
Run(depth, { EdramMode::kDepthOnly, 0, 0, kSize, kSize, 1.0f, false, true, false }, guestOverride); // the clear
|
||||
Run(depth, { EdramMode::kColorDepth, 2, 2, 8, 8, 0.47f, true, false, true }, guestOverride); // a tree
|
||||
Run(depth, { EdramMode::kDepthOnly, 12, 0, 20, 32, 0.50f, false, false, true }, guestOverride); // a tunnel roof
|
||||
Pass p;
|
||||
p.b = depth; // resolve B
|
||||
Run(depth, { EdramMode::kDepthOnly, 24, 20, 28, 30, 0.52f, false, false, true }, guestOverride); // a car
|
||||
p.a = depth; // resolve A
|
||||
return p;
|
||||
}
|
||||
|
||||
int Count(const Map& m, int x0, int y0, int x1, int y1, float z)
|
||||
{
|
||||
int n = 0;
|
||||
for (int y = y0; y < y1; y++)
|
||||
for (int x = x0; x < x1; x++)
|
||||
n += m[y * kSize + x] == z;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
static void TestShadowPass()
|
||||
{
|
||||
using namespace model;
|
||||
Pass p = ShadowPass(false);
|
||||
CHECK(Count(p.b, 0, 0, kSize, kSize, 1.0f) == kSize * kSize - 36 - 8 * 32, "B: far plane where no caster is (%d)",
|
||||
Count(p.b, 0, 0, kSize, kSize, 1.0f));
|
||||
CHECK(Count(p.b, 2, 2, 8, 8, 0.47f) == 36, "B holds the tree (%d of 36)", Count(p.b, 2, 2, 8, 8, 0.47f));
|
||||
CHECK(Count(p.b, 12, 0, 20, 32, 0.50f) == 256, "B holds the tunnel roof (%d of 256)", Count(p.b, 12, 0, 20, 32, 0.50f));
|
||||
CHECK(Count(p.a, 24, 20, 28, 30, 0.52f) == 40, "A holds the car (%d of 40)", Count(p.a, 24, 20, 28, 30, 0.52f));
|
||||
int differ = 0;
|
||||
for (int i = 0; i < kSize * kSize; i++)
|
||||
differ += p.a[i] != p.b[i];
|
||||
CHECK(differ == 40, "A is B plus the car, nothing else (%d texels differ)", differ);
|
||||
|
||||
// Up to v0.1.2 (the bound pixel shader run depth-only): the tree only,
|
||||
// A == B: no car shadow, no tunnel shade. The model must show the bug.
|
||||
Pass old = ShadowPass(true);
|
||||
CHECK(Count(old.b, 2, 2, 8, 8, 0.47f) == 36, "v0.1.2 model: the tree (%d)", Count(old.b, 2, 2, 8, 8, 0.47f));
|
||||
CHECK(Count(old.b, 12, 0, 20, 32, 0.50f) == 0, "v0.1.2 model: no tunnel roof (%d)", Count(old.b, 12, 0, 20, 32, 0.50f));
|
||||
CHECK(old.a == old.b, "v0.1.2 model: A == B, no car");
|
||||
}
|
||||
|
||||
// renderer.cpp's Draw, comments left out.
|
||||
static std::string DrawBody(const std::string& source)
|
||||
{
|
||||
std::string code;
|
||||
std::istringstream in(source);
|
||||
for (std::string line; std::getline(in, line);)
|
||||
{
|
||||
size_t c = line.find("//");
|
||||
code += (c == std::string::npos ? line : line.substr(0, c)) + "\n";
|
||||
}
|
||||
size_t start = code.find("void Draw(uint32_t initiatorValue");
|
||||
if (start == std::string::npos)
|
||||
return {};
|
||||
// To the next top-level function of the namespace (Draw's closing brace
|
||||
// is followed by `void Submit()`).
|
||||
size_t end = code.find("void Submit()", start);
|
||||
return code.substr(start, end == std::string::npos ? std::string::npos : end - start);
|
||||
}
|
||||
|
||||
static std::string Squash(const std::string& s)
|
||||
{
|
||||
std::string r;
|
||||
for (char c : s)
|
||||
if (c != ' ' && c != '\n' && c != '\t')
|
||||
r += c;
|
||||
return r;
|
||||
}
|
||||
|
||||
static void TestRenderer(const char* path)
|
||||
{
|
||||
std::ifstream f(path);
|
||||
CHECK(f.good(), "cannot read %s", path);
|
||||
if (!f.good())
|
||||
return;
|
||||
std::stringstream ss;
|
||||
ss << f.rdbuf();
|
||||
const std::string source = ss.str();
|
||||
const std::string draw = Squash(DrawBody(source));
|
||||
CHECK(!draw.empty(), "Draw not found in %s", path);
|
||||
if (draw.empty())
|
||||
return;
|
||||
size_t swap = draw.find("if(!gpu::RunsGuestPixelShader(edramMode,s_depthOnlyGuestPs))s_ps=DepthOnlyPixelShader();");
|
||||
size_t restore = draw.find("~RestorePs(){s_ps=ps;}}restorePs{s_ps};");
|
||||
size_t loaded = draw.find("if(!s_vs||!s_ps)");
|
||||
size_t key = draw.find("key.ps=s_ps->hash;");
|
||||
CHECK(swap != std::string::npos, "Draw gives depth-only draws the empty pixel shader");
|
||||
CHECK(restore != std::string::npos && restore < swap, "Draw puts the guest's pixel shader back after the draw");
|
||||
CHECK(loaded != std::string::npos && swap < loaded, "the swap comes before the 'no shaders loaded' check");
|
||||
CHECK(key != std::string::npos && swap < key, "the swap comes before the pipeline key");
|
||||
CHECK(draw.find("alpha_func!=xenos::CompareFunction::kAlways&&gpu::RunsGuestPixelShader(edramMode,s_depthOnlyGuestPs);") !=
|
||||
std::string::npos, "no alpha test for depth-only draws");
|
||||
const std::string all = Squash(source);
|
||||
CHECK(all.find("slot->info.glsl=gpu::kEmptyPixelShaderGlsl;") != std::string::npos, "DepthOnlyPixelShader is the empty shader");
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
TestRule();
|
||||
TestEmptyShader();
|
||||
TestShadowPass();
|
||||
if (argc > 1)
|
||||
TestRenderer(argv[1]);
|
||||
else
|
||||
printf("(no renderer.cpp given: its checks skipped)\n");
|
||||
if (g_failures)
|
||||
{
|
||||
printf("depth_only_test: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("depth_only_test: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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++)
|
||||
|
||||