Compare commits

..

8 Commits

Author SHA1 Message Date
patchzyy 37a0b68d85 keep steam desktop layout off while focused 2026-10-29 18:21:00 +01:00
FireDragon b748155cdb Update README.md to address NTSC-U and NTSC-J support added in v0.9.0 (#375)
* Update README.md

Added NTSC-U and NTSC-J to compatible ISOs. Changed "other regions" to NTSC-K

* Update README.md

Fixed typo and installation.

* Update README.md

Added NTSC-U and NTSC-J to line 130.

* Fixed "and" to "or" and line 130.
2026-10-29 16:08:09 +01:00
patchzyy eb99d8652b Fix wired PDP Afterglow Wave not being detected (#379)
* Fix wired PDP Afterglow Wave not being detected (#377)

Bumps aurora so the vendored SDL treats 0e6f:018b as input-only over USB
(patchzyy/aurora-wiicompiled#8).

* Point aurora at the merged PDP Afterglow Wave fix
2026-10-29 16:06:58 +01:00
patchzyy ee8443423d Add shake to trick (#378)
* add shake to trick

* address review

* add motion trick tests to ci

* reject bad sensor data under fast math

* harden bad data test

* widen motion trick test coverage
2026-10-29 14:41:06 +01:00
patchzyy 3645c4af1f Mask GX matrix indices to six bits (#376) 2026-10-29 11:17:53 +01:00
patchzyy ae54b6e082 skip escape exit prompt with valve controllers (#372) 2026-10-27 20:32:53 +01:00
Gitez af5a45e270 [Feature] Add aspect ratio selector for non-widescreen monitors (#339)
* Added aspect ratio selector

* Dirty 4:3 fix

* Fixed dynamic aspect in 4:3

* Cleaned up aspect ratio

* Fixed force aspect ratio

* More clean up

* use widescreen key, keep force_16_9 compat

* migrate legacy force_16_9 once at startup

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
2026-10-26 19:42:28 +01:00
Begood 896dcef9e3 macOS: pass explicit CMAKE_OSX_SYSROOT to the native configure (#362)
* macOS: pass explicit CMAKE_OSX_SYSROOT to the native configure

Fixes #353

## Problem
On recent macOS versions, the CMake bundled in Setup.app fails to detect the SDK, leaving `CMAKE_OSX_SYSROOT` empty. Regular compile steps still work (clang falls back to the default SDK), but libpng's `genout.cmake` step only passes `-isysroot` when `CMAKE_OSX_SYSROOT` is set, so `pnglibconf.out` generation fails with:

```
fatal error: 'sys/types.h' file not found
```

## Fix
Resolve the SDK path explicitly and pass it to the native configure step, so the build no longer depends on the bundled CMake's SDK detection. Also fails early with a clear message if the SDK cannot be resolved.

## Testing
Confirmed on MacBook Pro M1 (macOS 27.0.1): the workaround (setting `CMAKE_OSX_SYSROOT` manually) completes the build successfully; this PR bakes the same fix into the build script.

* fix sysroot line and check sdk dir

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
2026-10-26 17:14:54 +01:00
29 changed files with 1585 additions and 1381 deletions
+2 -1
View File
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '8.0.427'
dotnet-version: '8.0.x'
- name: Cache NuGet packages
uses: actions/cache@v5
@@ -102,6 +102,7 @@ jobs:
mkw_nand_settings_tests \
mkw_sc_serial_tests \
mkw_input_expr_tests \
mkw_motion_trick_tests \
mkw_bluetooth_controller_tests \
mkw_macos_native_compile \
mkw_macos_context_abi_tests \
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '8.0.427'
dotnet-version: '8.0.x'
# Cache downloads only. Preparation still validates pins, and every run
# compiles current Aurora, runtime, and generated sources from scratch.
+2 -1
View File
@@ -64,6 +64,7 @@ done
macos_arch=$(uname -m)
case "$macos_arch" in arm64|x86_64) ;; *) fail "unsupported macOS architecture: $macos_arch" ;; esac
macos_deployment_target=12.0
macos_sysroot=$(xcrun --show-sdk-path 2>/dev/null) && [[ -d "$macos_sysroot" ]] || fail 'could not resolve the macOS SDK; install the Xcode Command Line Tools (xcode-select --install)'
workspace=$(cd "$workspace" && pwd)
if [[ -z "$setup_version" && -f "$workspace/Launcher/Directory.Build.props" ]]; then
setup_version=$(sed -nE 's/.*<Version>([^<]+)<\/Version>.*/\1/p' "$workspace/Launcher/Directory.Build.props" | head -n 1)
@@ -186,7 +187,7 @@ fi
step configure-native 'Configuring the native toolchain'
# Use Aurora's pinned SDL3 source on macOS. A system SDL3 can be older than
# Aurora's required API even when find_package() succeeds.
"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER="$cc_bin" -DCMAKE_CXX_COMPILER="$cxx_bin" -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DCMAKE_OSX_ARCHITECTURES="$macos_arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$macos_deployment_target" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DMKW_X86_CPU_PROFILE="$x86_cpu_profile" -DAURORA_SDL3_PROVIDER=vendor -DMKW_SETUP_VERSION="$setup_version"
"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER="$cc_bin" -DCMAKE_CXX_COMPILER="$cxx_bin" -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DCMAKE_OSX_ARCHITECTURES="$macos_arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$macos_deployment_target" -DCMAKE_OSX_SYSROOT="$macos_sysroot" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DMKW_X86_CPU_PROFILE="$x86_cpu_profile" -DAURORA_SDL3_PROVIDER=vendor -DMKW_SETUP_VERSION="$setup_version"
mkw_write_identity "$toolchain_provenance" ToolchainFingerprint "$toolchain_fingerprint"
targets=(); [[ "$profile" != retro-rewind ]] && targets+=(WiiCompiled); [[ "$profile" != base ]] && targets+=(RetroRewind)
step compile "Compiling ${targets[*]} locally"; "$cmake_bin" --build "$native_build" --target "${targets[@]}" --parallel "$global_jobs"
+6 -7
View File
@@ -22,7 +22,7 @@ anywhere at runtime.
> [!IMPORTANT]
> There is no Nintendo code, no assets and no game data anywhere in this project or its releases.
> You need your own legally dumped copy of the PAL version of the game. Setup only ships the
> You need your own legally dumped copy of the PAL, NTSC-U, or NTSC-J version of the game. Setup only ships the
> toolchain, the translation runs on your machine against your disc image, and nothing ever gets
> uploaded.
@@ -103,14 +103,13 @@ Known limitations of the Wii Remote path:
- About 20 GB of free disk space during installation (Final game size ~5 GB)
- macOS 12 (Monterey) or later on Apple Silicon (`arm64`) or Intel (`x86_64-v3`); pre-Haswell Intel CPUs are unsupported
- On macOS, a Metal-capable GPU and Apple Xcode Command Line Tools (Setup opens Apple's installer when they are missing)
- A clean, unmodified **PAL `RMCP01`** disc image of Mario Kart Wii, dumped by you. ISO, GCM,
- A clean, unmodified **PAL `RMCP01`**, **NTSC-U `RMCE01`**, or **NTSC-J `RMCJ01`** disc image of Mario Kart Wii, dumped by you. ISO, GCM,
GCZ, CISO, WBFS, WIA and RVZ are accepted.
> [!NOTE]
> GPU/CPU minimums are set by driver support and D3D12/Vulkan feature requirements, not by the game's actual demands.
Only the clean PAL revision will work. Anything else (other
regions, patched executables) is rejected outright.
Only the clean PAL, NTSC-U, and NTSC-J revisions will work. Anything else (NTSC-K, patched executables) is rejected outright.
> [!NOTE]
> Nobody here will tell you where to get the game. Dumping your own disc is on you, and links to
@@ -118,7 +117,7 @@ regions, patched executables) is rejected outright.
## Installing
For an easy experience, use [Wheel Wizard](https://github.com/TeamWheelWizard/WheelWizard). Pick your clean PAL `RMCP01`
For an easy experience, use [Wheel Wizard](https://github.com/TeamWheelWizard/WheelWizard). Pick your clean PAL `RMCP01`, NTSC-U `RMCE01`, or NTSC-J `RMCJ01`
image under Settings, turn on **WiiCompiled (beta)**, and hit install from the Home page.
Wheel Wizard downloads the setup tool from this repo and walks you through install, updates and
launching. The backend itself is deliberately command-line only, Wheel Wizard is a wrapper around it.
@@ -128,7 +127,7 @@ launching. The backend itself is deliberately command-line only, Wheel Wizard is
Download `WiiCompiled-Setup.pkg` from this repository's Releases page and open it. The universal
package selects the appropriate bundled tools for the host architecture, supporting both Apple Silicon (`arm64`)
and Intel (`x86_64`) Macs. It installs **WiiCompiled Setup** in Applications; open that app, choose
your clean PAL `RMCP01` disc image, and select either the base game or Retro Rewind. For Retro Rewind, choose the `RetroRewind6` folder
your clean PAL `RMCP01`, NTSC-U `RMCE01`, or NTSC-J `RMCJ01` disc image, and select either the base game or Retro Rewind. For Retro Rewind, choose the `RetroRewind6` folder
or its parent folder.
Setup verifies and extracts the image locally, then translates and compiles the native app on your
@@ -196,7 +195,7 @@ don't want to risk it right now, setup has to run a static recompiler over the w
and then throw a C++ compiler at the result. It's a **one-time cost** on your machine.
**Which game version works?**
Clean PAL `RMCP01`. Other regions and modified executables are **rejected**. Translating
Clean PAL `RMCP01`, NTSC-U `RMCE01`, and NTSC-J `RMCJ01`. NTSC-K `RMCK01` and modified executables are **rejected**. Translating
them against the wrong manifest would give you a subtly broken game that's miserable to debug for us.
**Can I recompile other GameCube/Wii games with it?**
+9
View File
@@ -63,6 +63,15 @@ target_include_directories(mkw_input_expr_tests PRIVATE "${MKW_TEST_RUNTIME_DIR}
target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17)
add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests)
add_executable(mkw_motion_trick_tests
"${MKW_TEST_RUNTIME_DIR}/tests/motion_trick_tests.cpp"
"${MKW_TEST_RUNTIME_DIR}/src/motion_trick_detector.cpp")
target_include_directories(mkw_motion_trick_tests PRIVATE "${MKW_TEST_RUNTIME_DIR}/include")
target_compile_features(mkw_motion_trick_tests PRIVATE cxx_std_17)
# Match the runtime's float model so the bad-data guard is tested as shipped.
target_compile_options(mkw_motion_trick_tests PRIVATE $<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-ffast-math>)
add_test(NAME mkw_motion_trick_tests COMMAND mkw_motion_trick_tests)
# The emulated Bluetooth controller is pure host code.
add_executable(mkw_bluetooth_controller_tests
"${MKW_TEST_RUNTIME_DIR}/tests/bluetooth_controller_tests.cpp"
+5 -3
View File
@@ -2,6 +2,7 @@
#include "friend_code_keyboard.h"
#include "settings_overlay.h"
#include "steam_input_layout.h"
#include "runtime_config.h"
#include "gx_worker.h"
#include "perf_stats.h"
@@ -19,9 +20,8 @@
#endif
extern "C" bool g_dynamicAspectRatioEnabled;
void ConfigureMkwDynamicAspect(bool widescreen, bool forceAspect169, uint32_t surfaceWidth, uint32_t surfaceHeight);
void SetMkwForceAspect169(bool enabled);
bool MkwForceAspect169Requested();
void ConfigureMkwDynamicAspect(bool widescreen, bool forceAspect, uint32_t surfaceWidth, uint32_t surfaceHeight);
void SetMkwForceAspect(bool enabled);
void UpdateMkwDynamicAspectSurface(uint32_t surfaceWidth, uint32_t surfaceHeight);
// Arms the "keep EGG::Frustum's projection scale" flag on every screen that
// renders to a fixed-size offscreen target. Cheap and idempotent; called from
@@ -64,6 +64,7 @@ inline void Flush(bool force = false) {
// process directly and do not run the crash/atexit paths.
[[noreturn]] inline void ExitForAuroraWindowClose() noexcept {
settings_overlay::ReleaseControllers();
SteamInputLayout::Release();
WindowPlacementPersistence::Flush(true);
#if defined(_WIN32)
::ExitProcess(0);
@@ -121,6 +122,7 @@ inline void ProcessAuroraEvents(const AuroraEvent* events) {
}
settings_overlay::HandleEvents(events);
FriendCodeKeyboard::HandleEvents(events);
SteamInputLayout::HandleEvents(events);
}
inline void ApplyPendingMkwDynamicAspectSurface() {
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// Shake to trick: a flick of a controller's motion sensor briefly presses the
// matching D-pad direction, which the game treats as a trick (or a wheelie on
// bikes), like shaking a Wii Remote.
#include <cstdint>
#include <dolphin/pad.h>
#include "motion_trick_detector.h"
namespace MotionTrick {
bool Enabled() noexcept;
void SetEnabled(bool enabled) noexcept;
int Sensitivity() noexcept;
void SetSensitivity(int level) noexcept;
// The controller on this port reports a gyro or accelerometer of its own.
bool PortHasMotion(uint32_t port) noexcept;
// Call once per guest PADRead, after every other input source has been merged.
void Apply(PADStatus* statuses) noexcept;
struct PortStatus {
float intensity = 0.0f; // decaying peak, 1 being the trigger threshold
Direction lastTrick = Direction::None;
uint64_t lastTrickUs = 0;
bool sensorsUnavailable = false; // the controller refused to turn its sensors on
};
PortStatus Status(uint32_t port) noexcept;
uint64_t NowUs() noexcept;
} // namespace MotionTrick
+43
View File
@@ -0,0 +1,43 @@
#pragma once
// Shake detection for controllers with motion sensors. Pure host code, so it is
// unit-testable without SDL.
#include <cstdint>
namespace MotionTrick {
enum class Direction : uint8_t { None, Up, Down, Left, Right };
inline constexpr int kMinSensitivity = 1;
inline constexpr int kMaxSensitivity = 5;
inline constexpr int kDefaultSensitivity = 3;
// SDL gamepad sensor axes: x right, y top, z toward the player.
struct MotionSample {
bool hasGyro = false;
bool hasAccel = false;
float gyro[3]{}; // rad/s: pitch, yaw, roll
float accel[3]{}; // m/s^2, gravity included
};
// Fed one sample per game frame; reports at most one trick per flick.
class ShakeDetector {
public:
ShakeDetector() { SetSensitivity(kDefaultSensitivity); }
void SetSensitivity(int level);
Direction Update(const MotionSample& sample, uint64_t nowUs);
// Strength of the last sample, 1 being the trigger threshold.
float Intensity() const { return m_intensity; }
private:
float m_gyroThreshold = 0.0f;
float m_accelThreshold = 0.0f;
float m_intensity = 0.0f;
bool m_armed = true;
uint64_t m_lastFireUs = 0;
};
} // namespace MotionTrick
-1
View File
@@ -4,7 +4,6 @@
// MKW_PERF_STATS=1: logs the game thread's busy time per presented frame, i.e. wall time minus the
// time it spends blocked on VI pacing, the GX worker or the audio mixer.
// MKW_FRAME_TRACE=<csv path> also records those per-frame timings in Aurora's trace.
namespace PerfStats {
bool Enabled() noexcept;
+82 -8
View File
@@ -33,7 +33,8 @@
struct RuntimeUserConfig {
std::optional<bool> widescreen;
std::optional<bool> forceAspect169;
std::optional<bool> forceAspect;
bool legacyForceAspect = false;
std::optional<int32_t> windowPosX;
std::optional<int32_t> windowPosY;
std::optional<uint32_t> windowWidth;
@@ -69,6 +70,10 @@ struct RuntimeUserConfig {
std::optional<int32_t> wiiIrSensitivity;
std::optional<int32_t> wiiSpeakerVolume;
std::optional<bool> wiiRumble;
// Outside a Steam launch, ask a running Steam client for a game controller layout while
// the window has focus instead of its desktop one (buttons bound to mouse and keyboard).
std::optional<bool> steamInputOverride;
std::optional<uint32_t> steamInputAppId;
std::optional<bool> networkEnabled;
std::optional<bool> discordPresenceEnabled;
// The application ID of the WiiCompiled Discord application. This is only
@@ -86,6 +91,9 @@ struct RuntimeUserConfig {
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
std::array<std::optional<std::string>, 12> controllerButtons;
std::optional<bool> rumbleEnabled;
// Shake a motion controller to press the D-pad, like shaking a Wii Remote.
std::optional<bool> motionTrick;
std::optional<int32_t> motionTrickSensitivity;
std::optional<int32_t> muteHotkey;
std::map<std::string, std::string> controllerExpressions;
};
@@ -271,7 +279,7 @@ inline void EnsureConfigFile() {
"# Set paths.dvd_root to an extracted Mario Kart Wii DATA directory.\n\n"
"[video]\n"
"widescreen = true\n"
"force_16_9 = false\n"
"force_aspect = false\n"
"resolution_multiplier = 1.0\n"
"frame_interpolation_fps = 0\n"
"display_mode = \"windowed\"\n"
@@ -304,6 +312,12 @@ inline void EnsureConfigFile() {
"mix_worker = true\n\n"
"[network]\n"
"enabled = true\n\n"
"[controller]\n"
"# Outside a Steam launch, Steam's desktop layout binds controller buttons\n"
"# to mouse and keyboard. While the game has focus, ask a running Steam\n"
"# client to use the game layout of steam_input_app_id instead.\n"
"steam_input_override = true\n"
"# steam_input_app_id = 480\n\n"
"[discord]\n"
"# Rich Presence talks only to a locally-running Discord client.\n"
"# Retro Rewind supplies its official app ID automatically. Set this\n"
@@ -389,6 +403,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
}
config.rumbleEnabled = FindConfigValue<bool>(document, "controller", "rumble");
config.motionTrick = FindConfigValue<bool>(document, "controller", "motion_trick");
if (const auto value = FindConfigInt(document, "controller", "motion_trick_sensitivity"); value && *value >= 1 && *value <= 5) {
config.motionTrickSensitivity = *value;
}
if (auto value = FindConfigInt(document, "audio", "mute_key")) {
config.muteHotkey = *value;
}
@@ -403,7 +421,15 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
}
config.widescreen = FindConfigValue<bool>(document, "video", "widescreen");
config.forceAspect169 = FindConfigValue<bool>(document, "video", "force_16_9");
config.forceAspect = FindConfigValue<bool>(document, "video", "force_aspect");
if (!config.forceAspect) {
// Legacy key; it always forced a 16:9 game.
config.forceAspect = FindConfigValue<bool>(document, "video", "force_16_9");
config.legacyForceAspect = config.forceAspect.has_value();
if (config.forceAspect.value_or(false)) {
config.widescreen = true;
}
}
config.windowPosX = FindConfigInt(document, "video", "window_x");
config.windowPosY = FindConfigInt(document, "video", "window_y");
if (auto value = FindConfigUint(document, "video", "window_width"); value && *value != 0) {
@@ -473,6 +499,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
config.wiiSpeakerVolume = *value;
}
config.wiiRumble = FindConfigValue<bool>(document, "controller", "wii_rumble");
config.steamInputOverride = FindConfigValue<bool>(document, "controller", "steam_input_override");
if (const auto value = FindConfigUint(document, "controller", "steam_input_app_id"); value && *value != 0) {
config.steamInputAppId = *value;
}
config.networkEnabled = FindConfigValue<bool>(document, "network", "enabled");
config.discordPresenceEnabled = FindConfigValue<bool>(document, "discord", "enabled");
config.discordClientId = FindConfigValue<std::string>(document, "discord", "client_id");
@@ -692,6 +722,24 @@ inline bool SetRumbleEnabled(bool value) {
return WriteSetting("controller", "rumble", value ? "true" : "false");
}
inline bool MotionTrickEnabled(bool fallback = false) {
return Get().motionTrick.value_or(fallback);
}
inline bool SetMotionTrickEnabled(bool value) {
Mutable().motionTrick = value;
return WriteSetting("controller", "motion_trick", value ? "true" : "false");
}
inline int32_t MotionTrickSensitivity(int32_t fallback = 3) {
return Get().motionTrickSensitivity.value_or(fallback);
}
inline bool SetMotionTrickSensitivity(int32_t value) {
Mutable().motionTrickSensitivity = value;
return WriteSetting("controller", "motion_trick_sensitivity", std::to_string(value));
}
inline int32_t MuteHotkey(int32_t fallback) {
return Get().muteHotkey.value_or(fallback);
}
@@ -760,13 +808,30 @@ inline bool WidescreenEnabled(bool fallback = false) {
return Get().widescreen.value_or(fallback);
}
inline bool ForceAspect169Enabled(bool fallback = false) {
return Get().forceAspect169.value_or(fallback);
inline bool ForceAspectEnabled(bool fallback = false) {
return Get().forceAspect.value_or(fallback);
}
inline bool SetForceAspect169(bool value) {
Mutable().forceAspect169 = value;
return WriteSetting("video", "force_16_9", value ? "true" : "false");
inline bool SetForceAspect(bool value) {
Mutable().forceAspect = value;
return WriteSetting("video", "force_aspect", value ? "true" : "false");
}
// The in-memory value stays as booted; SCGetAspectRatio must not change mid-session.
inline bool SetWidescreen(bool value) {
return WriteSetting("video", "widescreen", value ? "true" : "false");
}
// Rewrites a legacy force_16_9 as force_aspect plus the widescreen it implied.
inline void MigrateLegacyForceAspect() {
if (!Get().legacyForceAspect) {
return;
}
const bool force = ForceAspectEnabled();
if ((!force || WriteSetting("video", "widescreen", "true")) &&
WriteSetting("video", "force_aspect", force ? "true" : "false")) {
Mutable().legacyForceAspect = false;
}
}
inline bool WindowPosition(int32_t& x, int32_t& y) {
@@ -844,6 +909,15 @@ inline bool SetWiiRemotesEnabled(bool value) {
return true;
}
inline bool SteamInputOverrideEnabled(bool fallback = true) {
return Get().steamInputOverride.value_or(fallback);
}
// Spacewar, Valve's Steamworks sample app, available to every Steam account.
inline uint32_t SteamInputAppId(uint32_t fallback = 480) {
return Get().steamInputAppId.value_or(fallback);
}
inline bool WiiAutoPairEnabled(bool fallback = true) {
return Get().wiiAutoPair.value_or(fallback);
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <aurora/event.h>
#include <cstdint>
// Outside a Steam launch, Steam applies its desktop controller layout, which binds
// controller buttons to mouse and keyboard actions. While the game window has focus,
// a running Steam client is asked to apply the given app's game layout instead.
namespace SteamInputLayout {
void Initialize(bool enabled, uint32_t appId);
void HandleEvents(const AuroraEvent* events);
// Restores the desktop layout. Idempotent; safe to call from any exit path.
void Release() noexcept;
} // namespace SteamInputLayout
+34 -54
View File
@@ -14,10 +14,9 @@ extern "C" int g_gxFrameCount;
namespace {
bool g_widescreenConfigured = false;
bool g_widescreenSetting = false;
bool g_forceAspect169 = false;
std::atomic_bool g_requestedForceAspect169{false};
bool g_widescreen = true;
bool g_forceAspect = false;
std::atomic_bool g_requestedForceAspect{false};
bool g_policyDirty = false;
uint32_t g_lastEggWidth43 = 0;
uint32_t g_lastEggWidth169 = 0;
@@ -130,19 +129,20 @@ void WriteEggScreenRecord(uint32_t recordAddr, uint32_t width) {
void ApplyEggScreenRecords(uint32_t surfaceWidth, uint32_t surfaceHeight) {
using namespace MkwDynamicAspect;
if (g_forceAspect) {
surfaceWidth = g_widescreen ? 16u : 4u;
surfaceHeight = g_widescreen ? 9u : 3u;
}
const float referenceAspect = g_widescreen ? kPresentationAspect169 : kPresentationAspect43;
const uint32_t width43 =
g_widescreenConfigured ? EggRecordWidth(kEggRecordWidth43, surfaceWidth, surfaceHeight)
: kEggRecordWidth43;
EggRecordWidth(kEggRecordWidth43, surfaceWidth, surfaceHeight, referenceAspect);
const uint32_t width169 =
g_widescreenConfigured ? EggRecordWidth(kEggRecordWidth169, surfaceWidth, surfaceHeight)
: kEggRecordWidth169;
EggRecordWidth(kEggRecordWidth169, surfaceWidth, surfaceHeight);
WriteEggScreenRecord(kEggScreenRecord43, width43);
WriteEggScreenRecord(kEggScreenRecord169, width169);
Memory::WriteFloat32(kEggScreenProjScaleX, 1.0f);
Memory::WriteFloat32(kEggScreenProjScaleY,
g_widescreenConfigured
? VerticalExpansion(surfaceWidth, surfaceHeight)
: 1.0f);
VerticalExpansion(surfaceWidth, surfaceHeight, referenceAspect));
AssertOffscreenScreenBypass();
if (width43 == g_lastEggWidth43 && width169 == g_lastEggWidth169) {
return;
@@ -172,58 +172,38 @@ void AssertMkwOffscreenScreenBypass() {
}
void UpdateMkwDynamicAspectSurface(uint32_t surfaceWidth, uint32_t surfaceHeight) {
const bool requestedForceAspect169 = g_requestedForceAspect169.load(std::memory_order_acquire);
if (g_forceAspect169 != requestedForceAspect169) {
g_forceAspect169 = requestedForceAspect169;
g_widescreenConfigured = g_widescreenSetting || g_forceAspect169;
g_dynamicAspectRatioEnabled = g_widescreenConfigured;
const bool requestedForceAspect = g_requestedForceAspect.load(std::memory_order_acquire);
if (g_forceAspect != requestedForceAspect) {
g_forceAspect = requestedForceAspect;
g_policyDirty = true;
}
if (surfaceWidth == 0 || surfaceHeight == 0) {
return;
}
if (!g_widescreenConfigured) {
if (g_policyDirty) {
if (g_policyDirty) {
if (g_forceAspect && !g_widescreen) {
AuroraSetViewportPolicy(AURORA_VIEWPORT_FIT);
VILockAspectRatio(4, 3);
ApplyEggScreenRecords(surfaceWidth, surfaceHeight);
g_policyDirty = false;
} else {
AuroraSetViewportPolicy(g_forceAspect ? AURORA_VIEWPORT_16_9 : AURORA_VIEWPORT_STRETCH);
VIUnlockAspectRatio();
}
return;
}
if (g_policyDirty) {
VIUnlockAspectRatio();
g_policyDirty = false;
}
AuroraSetViewportPolicy(g_forceAspect169 ? AURORA_VIEWPORT_16_9 : AURORA_VIEWPORT_STRETCH);
ApplyEggScreenRecords(g_forceAspect169 ? 16u : surfaceWidth,
g_forceAspect169 ? 9u : surfaceHeight);
}
void SetMkwForceAspect169(bool enabled) {
g_requestedForceAspect169.store(enabled, std::memory_order_release);
}
bool MkwForceAspect169Requested() {
return g_requestedForceAspect169.load(std::memory_order_acquire);
}
void ConfigureMkwDynamicAspect(bool widescreen, bool forceAspect169, uint32_t surfaceWidth, uint32_t surfaceHeight) {
g_widescreenSetting = widescreen;
g_widescreenConfigured = widescreen || forceAspect169;
g_forceAspect169 = forceAspect169;
g_requestedForceAspect169.store(forceAspect169, std::memory_order_relaxed);
g_dynamicAspectRatioEnabled = g_widescreenConfigured;
g_lastEggWidth43 = 0;
g_lastEggWidth169 = 0;
if (g_widescreenConfigured) {
VIUnlockAspectRatio();
ApplyEggScreenRecords(forceAspect169 ? 16u : surfaceWidth,
forceAspect169 ? 9u : surfaceHeight);
return;
}
AuroraSetViewportPolicy(AURORA_VIEWPORT_FIT);
VILockAspectRatio(4, 3);
ApplyEggScreenRecords(surfaceWidth, surfaceHeight);
}
void SetMkwForceAspect(bool enabled) {
g_requestedForceAspect.store(enabled, std::memory_order_release);
}
void ConfigureMkwDynamicAspect(bool widescreen, bool forceAspect, uint32_t surfaceWidth, uint32_t surfaceHeight) {
g_widescreen = widescreen;
g_forceAspect = forceAspect;
g_requestedForceAspect.store(forceAspect, std::memory_order_relaxed);
g_policyDirty = true;
g_dynamicAspectRatioEnabled = true;
g_lastEggWidth43 = 0;
g_lastEggWidth169 = 0;
ApplyEggScreenRecords(surfaceWidth, surfaceHeight);
}
-6
View File
@@ -5,7 +5,6 @@
#include "guest_function_imports.h"
#include "memory.h"
#include "runtime_log.h"
#include <aurora/aurora.h>
#include <array>
#include <charconv>
@@ -185,11 +184,6 @@ REGISTER_NATIVE_WRAPPER_AS(0x8052F4E8, RacedataScenario_InitScreens_8052f4e8, "R
extern "C" void KartAIController_IsCPU_8072624c(CpuContext* ctx) {
MKW_GUEST_FUNC(8072624C)(ctx);
if (g_cpuDriven) {
static bool benchmarkReady = false;
if (!benchmarkReady) {
benchmarkReady = true;
aurora_report_benchmark_ready();
}
ctx->gpr[3] = 1;
}
}
+20 -16
View File
@@ -13,6 +13,7 @@
namespace MkwDynamicAspect {
inline constexpr float kPresentationAspect169 = 16.0f / 9.0f;
inline constexpr float kPresentationAspect43 = 4.0f / 3.0f;
inline constexpr float kEggFramebufferWidth = 608.0f;
inline constexpr uint32_t kEggScreenHeight = 456u;
inline constexpr uint32_t kEggRecordWidth43 = 608u;
@@ -31,32 +32,35 @@ inline float SurfaceAspect(uint32_t width, uint32_t height) noexcept {
static_cast<float>(width) / static_cast<float>(height));
}
// surfaceAspect / (16:9). Above 1 the surface is wider than 16:9, below 1 it
// is tighter (4:3, portrait).
inline float AspectRatioFactor(uint32_t width, uint32_t height) noexcept {
return SurfaceAspect(width, height) / kPresentationAspect169;
// surfaceAspect / referenceAspect. Above 1 the surface is wider than the
// reference, below 1 it is tighter (4:3, portrait).
inline float AspectRatioFactor(uint32_t width, uint32_t height,
float referenceAspect = kPresentationAspect169) noexcept {
return SurfaceAspect(width, height) / referenceAspect;
}
// Hor+ factor, clamped at 1 so a tighter-than-16:9 surface never narrows the
// canvas (that would crop away scene the 16:9 view showed).
inline float HorizontalExpansion(uint32_t width, uint32_t height) noexcept {
return std::max(1.0f, AspectRatioFactor(width, height));
// Hor+ factor, clamped at 1 so a tighter surface never narrows the canvas
// (that would crop away scene the reference view showed).
inline float HorizontalExpansion(uint32_t width, uint32_t height,
float referenceAspect = kPresentationAspect169) noexcept {
return std::max(1.0f, AspectRatioFactor(width, height, referenceAspect));
}
// Vert+ factor, clamped at 1 so a wider-than-16:9 surface never shortens the
// frustum. Exactly one of the two expansions is ever greater than 1.
inline float VerticalExpansion(uint32_t width, uint32_t height) noexcept {
const float factor = AspectRatioFactor(width, height);
// Vert+ factor, clamped at 1 so a wider surface never shortens the frustum.
// Exactly one of the two expansions is ever greater than 1.
inline float VerticalExpansion(uint32_t width, uint32_t height,
float referenceAspect = kPresentationAspect169) noexcept {
const float factor = AspectRatioFactor(width, height, referenceAspect);
if (!(factor > 0.0f)) {
return 1.0f;
}
return std::clamp(1.0f / factor, 1.0f, kMaxVerticalExpansion);
}
inline uint32_t EggRecordWidth(uint32_t baseWidth, uint32_t surfaceWidth,
uint32_t surfaceHeight) noexcept {
const float scaledWidth =
static_cast<float>(baseWidth) * HorizontalExpansion(surfaceWidth, surfaceHeight);
inline uint32_t EggRecordWidth(uint32_t baseWidth, uint32_t surfaceWidth, uint32_t surfaceHeight,
float referenceAspect = kPresentationAspect169) noexcept {
const float scaledWidth = static_cast<float>(baseWidth) *
HorizontalExpansion(surfaceWidth, surfaceHeight, referenceAspect);
return std::clamp<uint32_t>(
static_cast<uint32_t>(std::lround(scaledWidth / 4.0f)) * 4u, 4u, 0xFFFCu);
}
+2
View File
@@ -2,6 +2,7 @@
#include "memory.h"
#include "hle/controller_status_contract.h"
#include "input_bindings.h"
#include "motion_trick.h"
#include <algorithm>
#include <atomic>
@@ -116,6 +117,7 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr)
FillTriggersHeldByButtons(statuses);
InputBindings::Apply(statuses);
MotionTrick::Apply(statuses);
try {
for (uint32_t i = 0; i < PAD_CHANMAX; ++i) {
+1 -1
View File
@@ -53,7 +53,7 @@ PPC_NATIVE_OVERRIDE(801B0220, SCCheckStatus_HLE, uint32_t, (), ());
// Returns: 0 = 4:3, 1 = 16:9
extern "C" uint32_t SCGetAspectRatio_HLE()
{
return (RuntimeConfigFile::WidescreenEnabled(true) || MkwForceAspect169Requested()) ? 1u : 0u;
return RuntimeConfigFile::WidescreenEnabled(true) ? 1u : 0u;
}
PPC_NATIVE_OVERRIDE(801B1BE4, SCGetAspectRatio_HLE, uint32_t, (), ());
+31 -17
View File
@@ -41,6 +41,7 @@
#include <mmsystem.h>
#include <dbghelp.h>
#else
#include <fcntl.h>
#include <signal.h>
#if defined(__x86_64__)
// Only the x86 POSIX fault path inspects ucontext_t to recover the page-fault
@@ -68,6 +69,7 @@
#include "aurora_events.h"
#include "wiimote_host.h"
#include "discord_presence.h"
#include "steam_input_layout.h"
#include "fiber_manager.h"
#include "gx_worker.h"
#include "hle_stubs.h"
@@ -169,7 +171,6 @@ std::atomic_flag g_abortSignalHandled = ATOMIC_FLAG_INIT;
std::atomic_bool g_fatalErrorReported{false};
std::atomic_bool g_fatalPopupShown{false};
bool g_headless = false;
bool g_hidden = false;
std::atomic<int> g_lastExitCode{0};
std::atomic_bool g_exitCodeSet{false};
@@ -338,6 +339,9 @@ bool InstallTranscriptPipe(int& outReadFd, int& outWriteFd, int targetFd) {
if (::pipe(pipeFds) != 0) {
return false;
}
// Spawned helpers must not keep the transcript open; its reader waits for EOF on shutdown.
::fcntl(pipeFds[0], F_SETFD, FD_CLOEXEC);
::fcntl(pipeFds[1], F_SETFD, FD_CLOEXEC);
#endif
outReadFd = pipeFds[0];
@@ -776,7 +780,7 @@ void MarkFatalErrorReported() {
}
void ShowRuntimeFatalPopup(std::string_view category, std::string_view details) noexcept {
if (g_headless || g_hidden || g_fatalPopupShown.exchange(true, std::memory_order_acq_rel)) {
if (g_headless || g_fatalPopupShown.exchange(true, std::memory_order_acq_rel)) {
return;
}
@@ -1099,6 +1103,7 @@ LONG ReportFatalSehAndExit(EXCEPTION_POINTERS* info) {
<< " at host address 0x" << record->ExceptionInformation[1];
}
popupDetails << ".\n\nThe process transcript and crash log contain the full CPU and stack diagnostics.";
SteamInputLayout::Release();
ShowRuntimeFatalPopup("a native crash occurred", popupDetails.str());
DumpHostStackTrace();
@@ -1199,6 +1204,7 @@ void PosixMemoryFaultHandler(int sig, siginfo_t* info, void* ucontextVoid) {
}
popupDetails << ".\n\nThe process transcript and crash log contain the full CPU and stack "
"diagnostics.";
SteamInputLayout::Release();
ShowRuntimeFatalPopup("a native crash occurred", popupDetails.str());
DumpHostStackTrace();
WriteFatalLogImpl(sig == SIGBUS ? "sigbus" : "sigsegv");
@@ -1232,6 +1238,7 @@ void AbortSignalHandler(int signum) {
// abort() bypasses atexit, so the guest-memory fault summary has to be
// emitted here too. It is idempotent, so a later AtExitHandler is a no-op.
GuestFlat::LogFaultSummary();
SteamInputLayout::Release();
ShowRuntimeFatalPopup("a fatal internal error occurred",
"The process called abort while running the game or Aurora renderer.\n\n"
@@ -1267,6 +1274,7 @@ void SetRuntimeExitCode(int code) {
// Global handler called via atexit() to flush buffers before any exit
static void AtExitHandler() {
SteamInputLayout::Release();
// End-of-run guest memory report. This runs before the fatal-report check
// below because the counters describe the whole session and are just as
// interesting after a crash as after a clean exit.
@@ -1291,6 +1299,7 @@ static void AtExitHandler() {
// Global terminate handler for uncaught exceptions
static void TerminateHandler() {
SteamInputLayout::Release();
// Skip detailed dump if already reported
if (g_fatalErrorReported.load(std::memory_order_acquire)) {
std::fflush(stderr);
@@ -1351,19 +1360,16 @@ int RuntimeMain(int argc, char** argv) {
// `--headless`: with a boot race, run without a window, sound or dialogs.
g_headless = std::any_of(argv + 1, argv + argc,
[](const char* arg) { return std::string_view(arg) == "--headless"; });
g_hidden = std::any_of(argv + 1, argv + argc,
[](const char* arg) { return std::string_view(arg) == "--hidden"; });
try {
for (int i = 1; i < argc; ++i) {
if (std::string_view(argv[i]) != "--headless" && std::string_view(argv[i]) != "--hidden" &&
!BootRace::ParseArgument(argv[i])) {
if (std::string_view(argv[i]) != "--headless" && !BootRace::ParseArgument(argv[i])) {
throw std::invalid_argument(std::string("Unknown command-line option '") + argv[i] +
"'; the game runtime only accepts --race[=<course id>], --race-cpu, --hidden and --headless, use Config.toml through the installed host.");
"'; the game runtime only accepts --race[=<course id>], --race-cpu and --headless, use Config.toml through the installed host.");
}
}
if ((g_headless || g_hidden) && !BootRace::Requested()) {
throw std::invalid_argument("--headless and --hidden need --race[=<course id>] or --race-cpu");
if (g_headless && !BootRace::Requested()) {
throw std::invalid_argument("--headless needs --race[=<course id>] or --race-cpu");
}
if (g_headless) {
AudioBackend::Instance().SetSilenced(true);
@@ -1395,16 +1401,17 @@ int RuntimeMain(int argc, char** argv) {
auroraConfig.cachePath = auroraCachePath.c_str();
auroraConfig.logCallback = &RuntimeAuroraLogCallback;
auroraConfig.logLevel = LOG_DEBUG;
RuntimeConfigFile::MigrateLegacyForceAspect();
const bool configWidescreen = RuntimeConfigFile::WidescreenEnabled(true);
const bool forceAspect169 = RuntimeConfigFile::ForceAspect169Enabled();
auroraConfig.windowWidth = (configWidescreen || forceAspect169) ? 854 : 640;
const bool forceAspect = RuntimeConfigFile::ForceAspectEnabled();
auroraConfig.windowWidth = configWidescreen ? 854 : 640;
auroraConfig.windowHeight = 480;
auroraConfig.windowWidth = RuntimeConfigFile::WindowWidth(auroraConfig.windowWidth);
auroraConfig.windowHeight = RuntimeConfigFile::WindowHeight(auroraConfig.windowHeight);
auroraConfig.hasWindowPosition = RuntimeConfigFile::WindowPosition(
auroraConfig.windowPosX, auroraConfig.windowPosY);
auroraConfig.allowJoystickBackgroundEvents = !g_headless;
auroraConfig.startHidden = g_hidden || g_headless;
auroraConfig.startHidden = g_headless;
auroraConfig.disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true);
// Dolphin-style custom textures. Aurora indexes <userPath>/texture_replacements
// once during aurora_initialize, so both knobs only take effect on the next launch.
@@ -1416,7 +1423,7 @@ int RuntimeMain(int argc, char** argv) {
auroraConfig.vsync = RuntimeConfigFile::Vsync();
auroraConfig.desiredBackend = BACKEND_AUTO;
const float resolutionMultiplier = RuntimeConfigFile::ResolutionMultiplier(1.0f);
ConfigureMkwDynamicAspect(configWidescreen, forceAspect169,
ConfigureMkwDynamicAspect(configWidescreen, forceAspect,
auroraConfig.windowWidth, auroraConfig.windowHeight);
VISetFrameBufferScale(resolutionMultiplier);
// One table for both directions. RuntimeConfigFile::IsSupportedGraphicsApi
@@ -1483,19 +1490,23 @@ int RuntimeMain(int argc, char** argv) {
UpdateMkwDynamicAspectSurface(auroraInfo.windowSize.native_fb_width,
auroraInfo.windowSize.native_fb_height);
settings_overlay::InitializeRuntimeSettings();
if (!g_headless) {
SteamInputLayout::Initialize(RuntimeConfigFile::SteamInputOverrideEnabled(),
RuntimeConfigFile::SteamInputAppId());
}
if (BootRace::Requested()) {
aurora_set_skip_unready_pipelines(false);
}
RT_LOG(RT_TAG_CONFIG) << "video.widescreen=" << (configWidescreen ? "true" : "false")
<< " force_16_9=" << (forceAspect169 ? "true" : "false")
<< " SCGetAspectRatio=" << (configWidescreen || forceAspect169 ? 1 : 0)
<< " force_aspect=" << (forceAspect ? "true" : "false")
<< " SCGetAspectRatio=" << (configWidescreen ? 1 : 0)
<< " resolutionMultiplier=" << resolutionMultiplier
<< " window=" << auroraInfo.windowSize.width << "x" << auroraInfo.windowSize.height
<< " native=" << auroraInfo.windowSize.native_fb_width << "x"
<< auroraInfo.windowSize.native_fb_height
<< " viewportPolicy=" << (forceAspect169 ? "16:9" : (configWidescreen ? "stretch" : "fit"))
<< " viewportPolicy=" << (forceAspect ? (configWidescreen ? "16:9" : "fit") : "stretch")
<< " presentAspect="
<< (forceAspect169 ? "16:9" : (configWidescreen ? "surface (dynamic EGG canvas)" : "4:3"))
<< (forceAspect ? (configWidescreen ? "16:9" : "4:3") : "surface (dynamic EGG canvas)")
<< std::endl;
g_auroraInitialized.store(true, std::memory_order_release);
GxWorker::SetWaitCallback(ServiceGuestTimingDuringAuroraFrameWait);
@@ -1528,6 +1539,7 @@ int RuntimeMain(int argc, char** argv) {
Fiber::GuestFiberManager::Shutdown();
WindowPlacementPersistence::Flush(true);
Wiimote::Shutdown();
SteamInputLayout::Release();
GxWorker::Stop();
aurora_shutdown();
DiscordPresence::Shutdown();
@@ -1542,6 +1554,7 @@ int RuntimeMain(int argc, char** argv) {
<< " len=0x" << ex.length()
<< std::dec << std::nouppercase
<< " reason=" << ex.reason();
SteamInputLayout::Release();
ShowRuntimeFatalPopup("a guest memory access was out of bounds", details.str());
WriteFatalLogImpl("access_violation", details.str());
SetRuntimeExitCodeImpl(1);
@@ -1556,6 +1569,7 @@ int RuntimeMain(int argc, char** argv) {
} catch (const std::exception& ex) {
std::cerr << "Runtime error: " << ex.what() << std::endl;
SystemBridge::DumpCpuState(TryGetCpuContext());
SteamInputLayout::Release();
ShowRuntimeFatalPopup("a runtime exception occurred", ex.what());
WriteFatalLogImpl("exception");
SetRuntimeExitCodeImpl(1);
+227
View File
@@ -0,0 +1,227 @@
#include "motion_trick.h"
#include "input_bindings.h"
#include "runtime_config.h"
#include <algorithm>
#include <array>
#include <atomic>
#include <cstring>
#include <mutex>
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_keyboard.h>
#include <SDL3/SDL_timer.h>
namespace MotionTrick {
namespace {
// Long enough for the game to see the press, short enough not to hold a trick.
constexpr int kPulseFrames = 4;
// Sensors report zeros or stale data for a moment after being switched on.
constexpr int kWarmupFrames = 6;
constexpr float kIntensityDecay = 0.85f;
// Real sensors always jitter; identical readings mean the stream was switched
// off behind our back, so ask for it again a few times.
constexpr int kStaleFrames = 30;
constexpr uint64_t kResyncIntervalUs = 2'000'000;
constexpr int kMaxResyncs = 3;
struct PortState {
SDL_JoystickID id = 0;
bool gyro = false;
bool accel = false;
int warmup = 0;
MotionSample previous;
int staleFrames = 0;
int resyncs = 0;
uint64_t lastResyncUs = 0;
ShakeDetector detector;
float displayIntensity = 0.0f;
Direction pulse = Direction::None;
int pulseFrames = 0;
Direction lastTrick = Direction::None;
uint64_t lastTrickUs = 0;
};
std::atomic<bool> g_enabled{RuntimeConfigFile::MotionTrickEnabled(false)};
std::atomic<int> g_sensitivity{RuntimeConfigFile::MotionTrickSensitivity(kDefaultSensitivity)};
std::mutex g_mutex;
std::array<PortState, PAD_CHANMAX> g_ports;
// A keyboard player keeps their controller assigned, but aurora ignores it.
SDL_Gamepad* GamepadForPort(uint32_t port) {
uint32_t keyCount = 0;
if (PADGetKeyButtonBindings(port, &keyCount) != nullptr) {
return nullptr;
}
const s32 index = PADGetIndexForPort(port);
return index < 0 ? nullptr : PADGetSDLGamepadForIndex(static_cast<u32>(index));
}
uint16_t ButtonFor(Direction direction) {
switch (direction) {
case Direction::Up: return PAD_BUTTON_UP;
case Direction::Down: return PAD_BUTTON_DOWN;
case Direction::Left: return PAD_BUTTON_LEFT;
case Direction::Right: return PAD_BUTTON_RIGHT;
default: return 0;
}
}
bool HeldByOtherPort(uint32_t port, SDL_JoystickID id) {
for (uint32_t other = 0; other < g_ports.size(); ++other) {
if (other != port && g_ports[other].id == id) {
return true;
}
}
return false;
}
void SetSensors(SDL_Gamepad* gamepad, const PortState& state, bool enabled) {
if (state.gyro) SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_GYRO, enabled);
if (state.accel) SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_ACCEL, enabled);
}
void Attach(uint32_t port, SDL_Gamepad* gamepad) {
PortState& state = g_ports[port];
const SDL_JoystickID id = gamepad != nullptr ? SDL_GetGamepadID(gamepad) : 0;
if (id == state.id) {
return;
}
if (state.id != 0 && !HeldByOtherPort(port, state.id)) {
if (SDL_Gamepad* previous = SDL_GetGamepadFromID(state.id)) {
SetSensors(previous, state, false);
}
}
state = PortState{};
if (gamepad == nullptr) {
return;
}
state.id = id;
state.gyro = SDL_GamepadHasSensor(gamepad, SDL_SENSOR_GYRO) &&
SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_GYRO, true);
state.accel = SDL_GamepadHasSensor(gamepad, SDL_SENSOR_ACCEL) &&
SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_ACCEL, true);
state.warmup = kWarmupFrames;
}
bool SameReading(const MotionSample& a, const MotionSample& b) {
return a.hasGyro == b.hasGyro && a.hasAccel == b.hasAccel &&
std::memcmp(a.gyro, b.gyro, sizeof(a.gyro)) == 0 && std::memcmp(a.accel, b.accel, sizeof(a.accel)) == 0;
}
// Returns true when the sensors were restarted.
bool ResyncIfStale(SDL_Gamepad* gamepad, PortState& state, const MotionSample& sample, uint64_t now) {
const bool stale = SameReading(sample, state.previous) && SDL_GetKeyboardFocus() != nullptr;
state.previous = sample;
state.staleFrames = stale ? state.staleFrames + 1 : 0;
if (!stale) {
state.resyncs = 0;
}
if (state.staleFrames < kStaleFrames || state.resyncs >= kMaxResyncs ||
now - state.lastResyncUs < kResyncIntervalUs) {
return false;
}
SetSensors(gamepad, state, false);
SetSensors(gamepad, state, true);
state.staleFrames = 0;
++state.resyncs;
state.lastResyncUs = now;
state.warmup = kWarmupFrames;
return true;
}
MotionSample ReadSample(SDL_Gamepad* gamepad, const PortState& state) {
MotionSample sample;
sample.hasGyro = state.gyro && SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_GYRO, sample.gyro, 3);
sample.hasAccel = state.accel && SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_ACCEL, sample.accel, 3) &&
(sample.accel[0] != 0.0f || sample.accel[1] != 0.0f || sample.accel[2] != 0.0f);
return sample;
}
} // namespace
bool Enabled() noexcept {
return g_enabled.load(std::memory_order_relaxed);
}
void SetEnabled(bool enabled) noexcept {
g_enabled.store(enabled, std::memory_order_relaxed);
RuntimeConfigFile::SetMotionTrickEnabled(enabled);
}
int Sensitivity() noexcept {
return g_sensitivity.load(std::memory_order_relaxed);
}
void SetSensitivity(int level) noexcept {
level = std::clamp(level, kMinSensitivity, kMaxSensitivity);
g_sensitivity.store(level, std::memory_order_relaxed);
RuntimeConfigFile::SetMotionTrickSensitivity(level);
}
bool PortHasMotion(uint32_t port) noexcept {
SDL_Gamepad* gamepad = GamepadForPort(port);
return gamepad != nullptr &&
(SDL_GamepadHasSensor(gamepad, SDL_SENSOR_GYRO) || SDL_GamepadHasSensor(gamepad, SDL_SENSOR_ACCEL));
}
uint64_t NowUs() noexcept {
return SDL_GetTicksNS() / 1000;
}
void Apply(PADStatus* statuses) noexcept {
if (statuses == nullptr) {
return;
}
const bool enabled = Enabled();
const int sensitivity = Sensitivity();
const bool blocked = InputBindings::InputBlocked();
const uint64_t now = NowUs();
std::lock_guard<std::mutex> lock(g_mutex);
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
SDL_Gamepad* gamepad =
enabled && statuses[port].err == PAD_ERR_NONE ? GamepadForPort(port) : nullptr;
Attach(port, gamepad);
PortState& state = g_ports[port];
if (gamepad == nullptr || (!state.gyro && !state.accel)) {
continue;
}
if (state.warmup > 0) {
--state.warmup;
} else if (const MotionSample sample = ReadSample(gamepad, state);
!ResyncIfStale(gamepad, state, sample, now)) {
state.detector.SetSensitivity(sensitivity);
const Direction fired = state.detector.Update(sample, now);
state.displayIntensity =
std::max(state.detector.Intensity(), state.displayIntensity * kIntensityDecay);
if (fired != Direction::None) {
state.lastTrick = fired;
state.lastTrickUs = now;
state.pulse = fired;
state.pulseFrames = kPulseFrames;
}
}
if (blocked) {
state.pulseFrames = 0;
}
if (state.pulseFrames > 0) {
statuses[port].button |= ButtonFor(state.pulse);
--state.pulseFrames;
}
}
}
PortStatus Status(uint32_t port) noexcept {
if (port >= PAD_CHANMAX) {
return {};
}
std::lock_guard<std::mutex> lock(g_mutex);
const PortState& state = g_ports[port];
return {state.displayIntensity, state.lastTrick, state.lastTrickUs, state.id != 0 && !state.gyro && !state.accel};
}
} // namespace MotionTrick
+80
View File
@@ -0,0 +1,80 @@
#include "motion_trick_detector.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <cstring>
namespace MotionTrick {
namespace {
constexpr float kGravity = 9.80665f;
constexpr float kRearmIntensity = 0.35f;
constexpr uint64_t kCooldownUs = 400'000;
// Indexed by sensitivity level - 1.
constexpr std::array<float, kMaxSensitivity> kGyroThresholds = {11.0f, 9.0f, 7.0f, 5.5f, 4.0f};
constexpr std::array<float, kMaxSensitivity> kAccelThresholds = {20.0f, 16.0f, 13.0f, 10.0f, 8.0f};
// The runtime builds with -ffast-math, which may fold std::isfinite away.
bool Finite(const float (&v)[3]) {
for (const float value : v) {
uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
if ((bits & 0x7f800000u) == 0x7f800000u) {
return false;
}
}
return true;
}
float Length(const float (&v)[3]) {
return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
}
Direction GyroDirection(const float (&gyro)[3]) {
const float pitch = gyro[0];
const float yaw = gyro[1];
const float roll = gyro[2];
if (std::fabs(pitch) >= std::max(std::fabs(yaw), std::fabs(roll))) {
return pitch >= 0.0f ? Direction::Up : Direction::Down;
}
const float lateral = std::fabs(roll) >= std::fabs(yaw) ? roll : yaw;
return lateral >= 0.0f ? Direction::Left : Direction::Right;
}
} // namespace
void ShakeDetector::SetSensitivity(int level) {
const size_t index = static_cast<size_t>(std::clamp(level, kMinSensitivity, kMaxSensitivity) - 1);
m_gyroThreshold = kGyroThresholds[index];
m_accelThreshold = kAccelThresholds[index];
}
Direction ShakeDetector::Update(const MotionSample& sample, uint64_t nowUs) {
// The accelerometer only stands in for a missing gyro: on a gyro pad it
// mostly picks up bumps, and it carries no direction.
const float(&values)[3] = sample.hasGyro ? sample.gyro : sample.accel;
if ((!sample.hasGyro && !sample.hasAccel) || !Finite(values)) {
m_intensity = 0.0f;
return Direction::None;
}
m_intensity = sample.hasGyro ? Length(sample.gyro) / m_gyroThreshold
: std::fabs(Length(sample.accel) - kGravity) / m_accelThreshold;
if (!m_armed) {
if (m_intensity < kRearmIntensity && nowUs - m_lastFireUs >= kCooldownUs) {
m_armed = true;
}
return Direction::None;
}
if (m_intensity < 1.0f) {
return Direction::None;
}
m_armed = false;
m_lastFireUs = nowUs;
return sample.hasGyro ? GyroDirection(sample.gyro) : Direction::Up;
}
} // namespace MotionTrick
+2 -15
View File
@@ -1,7 +1,6 @@
#include "perf_stats.h"
#include "runtime_log.h"
#include <aurora/aurora.h>
#include <algorithm>
#include <array>
@@ -25,20 +24,12 @@ struct FrameWindow {
FrameWindow g_window;
bool LogEnabled() noexcept {
static const bool enabled = [] {
const char* value = std::getenv("MKW_PERF_STATS");
return value != nullptr && std::strcmp(value, "0") != 0;
}();
return enabled;
}
} // namespace
bool Enabled() noexcept {
static const bool enabled = [] {
const char* trace = std::getenv("MKW_FRAME_TRACE");
return LogEnabled() || (trace != nullptr && *trace);
const char* value = std::getenv("MKW_PERF_STATS");
return value != nullptr && std::strcmp(value, "0") != 0;
}();
return enabled;
}
@@ -57,11 +48,7 @@ void OnFramePresented() noexcept {
return;
}
const auto busy = (now - g_window.last) - wait;
aurora_report_producer_timing(
std::chrono::duration_cast<std::chrono::nanoseconds>(now - g_window.last).count(),
std::chrono::duration_cast<std::chrono::nanoseconds>(wait).count());
g_window.last = now;
if (!LogEnabled()) return;
g_window.busyMs[g_window.count++] = std::chrono::duration<double, std::milli>(busy).count();
if (g_window.count < kWindow) {
return;
+95 -6
View File
@@ -5,6 +5,7 @@
#include "controller_button_names.h"
#include "controller_mapping_wizard.h"
#include "input_bindings.h"
#include "motion_trick.h"
#include "game_graphics_options.h"
#include "music_attenuation.h"
#include "runtime_config.h"
@@ -113,7 +114,8 @@ bool g_skipUnreadyPipelines = RuntimeConfigFile::SkipUnreadyPipelines(true);
bool g_vsync = RuntimeConfigFile::Vsync();
bool g_disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true);
bool g_showFps = RuntimeConfigFile::ShowFps(true);
bool g_forceAspect169 = RuntimeConfigFile::ForceAspect169Enabled();
bool g_forceAspect = RuntimeConfigFile::ForceAspectEnabled();
bool g_widescreen = RuntimeConfigFile::WidescreenEnabled(true);
#if defined(__APPLE__)
bool g_metalFxSpatialUpscaling = RuntimeConfigFile::MetalFxSpatialUpscaling(false);
#endif
@@ -744,6 +746,69 @@ void DrawRumbleSettings() {
}
}
const char* TrickDirectionLabel(MotionTrick::Direction direction) {
switch (direction) {
case MotionTrick::Direction::Up: return "up";
case MotionTrick::Direction::Down: return "down";
case MotionTrick::Direction::Left: return "left";
case MotionTrick::Direction::Right: return "right";
default: return "";
}
}
void DrawMotionTrickSettings(uint32_t port) {
const MotionTrick::PortStatus status = MotionTrick::Status(port);
const bool hasMotion = MotionTrick::PortHasMotion(port) && !status.sensorsUnavailable;
bool enabled = MotionTrick::Enabled();
ImGui::BeginDisabled(!hasMotion && !enabled);
if (ImGui::Checkbox("Shake to trick", &enabled)) {
MotionTrick::SetEnabled(enabled);
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip(hasMotion
? "Flick the controller to trick off ramps, or to wheelie on a bike, like shaking a Wii Remote.\n"
"The flick direction picks the trick. The D-pad keeps working. Applies to every player."
: "Steam Input can hide a controller's motion sensor; turn it off for this game to use it here.");
}
if (!hasMotion) {
ImGui::SameLine();
ImGui::TextDisabled("(no motion sensor)");
return;
}
if (!enabled) {
return;
}
ImGui::Indent();
int sensitivity = MotionTrick::Sensitivity();
ImGui::SetNextItemWidth(160.0f);
if (ImGui::SliderInt("Sensitivity##motion", &sensitivity, MotionTrick::kMinSensitivity,
MotionTrick::kMaxSensitivity, "%d", ImGuiSliderFlags_AlwaysClamp)) {
MotionTrick::SetSensitivity(sensitivity);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Higher reacts to smaller flicks. Lower it if you trick or wheelie by accident while steering.");
}
constexpr float kMeterScale = 1.5f;
const ImVec2 meterPos = ImGui::GetCursorScreenPos();
const ImVec2 meterSize(160.0f, ImGui::GetFrameHeight() * 0.6f);
ImGui::ProgressBar(std::min(status.intensity / kMeterScale, 1.0f), meterSize, "");
const float thresholdX = meterPos.x + meterSize.x / kMeterScale;
ImGui::GetWindowDrawList()->AddLine(ImVec2(thresholdX, meterPos.y), ImVec2(thresholdX, meterPos.y + meterSize.y),
ImGui::GetColorU32(ImGuiCol_Text), 2.0f);
ImGui::SameLine();
const bool recent = status.lastTrick != MotionTrick::Direction::None &&
MotionTrick::NowUs() - status.lastTrickUs < 700'000;
if (recent) {
ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "Trick! (%s)", TrickDirectionLabel(status.lastTrick));
} else {
ImGui::TextDisabled("Flick to test");
}
ImGui::Unindent();
}
void DrawGamepadMapping(uint32_t port) {
uint32_t mappingCount = 0;
PADButtonMapping* mappings = PADGetButtonMappings(port, &mappingCount);
@@ -984,6 +1049,7 @@ void DrawControllerSettings() {
}
} else {
ImGui::TextUnformatted("Ready to play.");
DrawMotionTrickSettings(port);
if (ImGui::CollapsingHeader("Customize buttons")) {
ImGui::Indent();
DrawGamepadMapping(port);
@@ -1080,11 +1146,22 @@ void DrawAudioSettings() {
void DrawGraphicsSettings() {
g_displayMode = static_cast<int>(aurora_get_display_mode());
if (ImGui::Checkbox("Force 16:9", &g_forceAspect169)) {
SetMkwForceAspect169(g_forceAspect169);
RuntimeConfigFile::SetForceAspect169(g_forceAspect169);
if (ImGui::BeginCombo("Aspect ratio (restart required)", g_widescreen ? "16:9" : "4:3")) {
if (ImGui::Selectable("16:9", g_widescreen)) {
g_widescreen = true;
RuntimeConfigFile::SetWidescreen(true);
}
if (ImGui::Selectable("4:3", !g_widescreen)) {
g_widescreen = false;
RuntimeConfigFile::SetWidescreen(false);
}
ImGui::EndCombo();
}
ImGui::TextDisabled("Keep a 16:9 image with black bars when the window has another shape.");
if (ImGui::Checkbox("Force aspect ratio", &g_forceAspect)) {
SetMkwForceAspect(g_forceAspect);
RuntimeConfigFile::SetForceAspect(g_forceAspect);
}
ImGui::TextDisabled("Keep the current aspect ratio with black bars when the window has another shape.");
ImGui::Separator();
struct EffectFlag {
const char* label;
@@ -1470,6 +1547,18 @@ void DrawTopBar() {
ImGui::EndMainMenuBar();
}
// Steam's desktop layout maps B to Escape on Valve controllers.
bool ValveControllerConnected() {
int count = 0;
SDL_JoystickID* ids = SDL_GetJoysticks(&count);
bool found = false;
for (int i = 0; ids != nullptr && i < count && !found; ++i) {
found = SDL_GetJoystickVendorForID(ids[i]) == 0x28DE && SDL_GetJoystickProductForID(ids[i]) != 0x11FF;
}
SDL_free(ids);
return found;
}
bool IsToggleKey(const SDL_Event& event, SDL_Scancode code) {
return event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && event.key.scancode == code;
}
@@ -1591,7 +1680,7 @@ void HandleEvents(const AuroraEvent* events) noexcept {
g_exitPromptOpen = false;
} else if (g_topBarVisible) {
SetTopBarVisible(false);
} else {
} else if (!ValveControllerConnected()) {
g_exitPromptOpen = true;
}
ApplyInputBlockState();
+603
View File
@@ -0,0 +1,603 @@
#include "steam_input_layout.h"
#include "runtime_log.h"
#include <aurora/aurora.h>
#include <SDL3/SDL_video.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdlib>
#include <memory>
#include <mutex>
#include <string>
#include <system_error>
#include <thread>
#include <utility>
#include <vector>
#if defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <cwchar>
#include <filesystem>
#include <optional>
#else
#include <cerrno>
#include <csignal>
#include <fcntl.h>
#include <spawn.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#if defined(__APPLE__)
#include <libproc.h>
#include <sys/proc.h>
#include <sys/proc_info.h>
#else
#include <dirent.h>
#include <fstream>
#include <sstream>
#endif
extern char** environ;
#endif
namespace SteamInputLayout {
namespace {
using Clock = std::chrono::steady_clock;
enum class Handoff { None, Unconfirmed, Confirmed };
constexpr std::chrono::milliseconds kHandoffTimeout{2000};
#if defined(__linux__)
// xdg-open can return before the Steam forwarder it started has delivered the URL.
constexpr std::chrono::milliseconds kDeliverySpacing{1500};
#else
constexpr std::chrono::milliseconds kDeliverySpacing{0};
#endif
struct State {
std::mutex mutex;
std::condition_variable wake;
// Serializes requests to Steam so they arrive in order.
std::timed_mutex delivery;
bool enabled = false;
bool stopping = false;
bool desired = false;
bool sent = false;
bool maybeForced = false;
uint32_t appId = 0;
SDL_WindowID windowId = 0;
std::atomic<Clock::rep> lastDelivery{0};
std::atomic<bool> released{false};
};
// Never destroyed: the detached worker and late exit paths may still touch it.
std::atomic<State*> g_state{nullptr};
thread_local bool t_isWorker = false;
thread_local bool t_inRelease = false;
// The worker launches and publishes its request helper under this lock, so an exit that
// can't wait for the worker can cancel it: either the helper is published, or it never starts.
std::mutex g_helperMutex;
std::atomic<bool> g_helperCancelled{false};
#if defined(_WIN32)
HANDLE g_helper = nullptr;
#else
pid_t g_helper = 0;
#endif
// These locks are only held briefly; failing to get one means a crash inside it.
bool TryLockBriefly(std::unique_lock<std::mutex>& lock,
std::chrono::milliseconds timeout = std::chrono::milliseconds(250)) {
const auto deadline = Clock::now() + timeout;
while (!lock.try_lock()) {
if (Clock::now() >= deadline) {
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return true;
}
// Steam applies the launching app's own layout to everything it starts, children included.
bool LaunchedBySteam() {
for (const char* name : {"SteamGameId", "SteamAppId", "SteamClientLaunch"}) {
const char* value = std::getenv(name);
if (value != nullptr && *value != '\0') {
return true;
}
}
#if defined(_WIN32)
if (GetModuleHandleW(L"GameOverlayRenderer64.dll") != nullptr) {
return true;
}
#endif
return false;
}
#if defined(_WIN32)
std::vector<BYTE> TokenUserOf(HANDLE process) {
std::vector<BYTE> user;
HANDLE token = nullptr;
if (!OpenProcessToken(process, TOKEN_QUERY, &token)) {
return user;
}
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
user.resize(size);
if (size == 0 || !GetTokenInformation(token, TokenUser, user.data(), size, &size)) {
user.clear();
}
CloseHandle(token);
return user;
}
// A stale pid can be reused by another signed-in user's Steam.
bool OwnedByCurrentUser(HANDLE process) {
const auto theirs = TokenUserOf(process);
const auto ours = TokenUserOf(GetCurrentProcess());
return !theirs.empty() && !ours.empty() &&
EqualSid(reinterpret_cast<const TOKEN_USER*>(theirs.data())->User.Sid,
reinterpret_cast<const TOKEN_USER*>(ours.data())->User.Sid);
}
// ActiveProcess\pid outlives a Steam crash, so the process behind it is verified too.
std::optional<std::wstring> RunningSteamExecutable() {
DWORD pid = 0;
DWORD size = sizeof(pid);
if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Valve\\Steam\\ActiveProcess", L"pid",
RRF_RT_REG_DWORD, nullptr, &pid, &size) != ERROR_SUCCESS ||
pid == 0) {
return std::nullopt;
}
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (process == nullptr) {
return std::nullopt;
}
std::wstring path(32768, L'\0');
DWORD length = static_cast<DWORD>(path.size());
DWORD exitCode = 0;
const bool alive = QueryFullProcessImageNameW(process, 0, path.data(), &length) &&
GetExitCodeProcess(process, &exitCode) && exitCode == STILL_ACTIVE &&
OwnedByCurrentUser(process);
CloseHandle(process);
if (!alive) {
return std::nullopt;
}
path.resize(length);
if (_wcsicmp(std::filesystem::path(path).filename().c_str(), L"steam.exe") != 0) {
return std::nullopt;
}
return path;
}
// Same command line as Steam's registered steam:// handler, sent to the verified client.
Handoff OpenSteamUrl(const std::string& url, bool bounded) {
const auto steam = RunningSteamExecutable();
if (!steam) {
return Handoff::None;
}
std::wstring commandLine = L"\"" + *steam + L"\" -- \"" + std::wstring(url.begin(), url.end()) + L"\"";
const std::wstring directory = std::filesystem::path(*steam).parent_path().wstring();
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
PROCESS_INFORMATION info{};
std::unique_lock launch(g_helperMutex, std::defer_lock);
if (!bounded) {
launch.lock();
if (g_helperCancelled) {
return Handoff::None;
}
}
// The worker's helper starts suspended so a cancellation that couldn't take the lock while
// CreateProcessW ran still stops it before it can reach Steam.
if (!CreateProcessW(steam->c_str(), commandLine.data(), nullptr, nullptr, FALSE,
bounded ? 0 : CREATE_SUSPENDED, nullptr, directory.c_str(), &startup, &info)) {
return Handoff::None;
}
if (!bounded) {
g_helper = info.hProcess;
launch.unlock();
if (g_helperCancelled) {
TerminateProcess(info.hProcess, 1);
} else {
ResumeThread(info.hThread);
}
}
CloseHandle(info.hThread);
const DWORD waited = WaitForSingleObject(info.hProcess, bounded ? static_cast<DWORD>(kHandoffTimeout.count()) : INFINITE);
if (!bounded) {
std::lock_guard lock(g_helperMutex);
g_helper = nullptr;
}
DWORD exitCode = 1;
const bool succeeded = waited == WAIT_OBJECT_0 && GetExitCodeProcess(info.hProcess, &exitCode) && exitCode == 0;
CloseHandle(info.hProcess);
return succeeded ? Handoff::Confirmed : Handoff::Unconfirmed;
}
void CancelWorkerRequests() {
g_helperCancelled = true;
std::unique_lock lock(g_helperMutex, std::defer_lock);
if (!TryLockBriefly(lock, kHandoffTimeout)) {
return;
}
if (g_helper != nullptr && TerminateProcess(g_helper, 1)) {
WaitForSingleObject(g_helper, static_cast<DWORD>(kHandoffTimeout.count()));
}
}
#else
// Only a live client of this user; a URL handler with no client running would start Steam.
bool SteamRunning() {
const uid_t uid = getuid();
#if defined(__APPLE__)
const int count = proc_listallpids(nullptr, 0);
if (count <= 0) {
return false;
}
std::vector<pid_t> pids(static_cast<size_t>(count) + 32);
const int filled = proc_listallpids(pids.data(), static_cast<int>(pids.size() * sizeof(pid_t)));
for (int i = 0; i < filled; ++i) {
char name[2 * MAXCOMLEN + 1] = {};
if (proc_name(pids[i], name, sizeof(name)) <= 0 || std::string(name) != "steam_osx") {
continue;
}
proc_bsdinfo info{};
if (proc_pidinfo(pids[i], PROC_PIDTBSDINFO, 0, &info, sizeof(info)) == sizeof(info) &&
info.pbi_uid == uid && info.pbi_status != SZOMB) {
return true;
}
}
return false;
#else
DIR* proc = opendir("/proc");
if (proc == nullptr) {
return false;
}
bool found = false;
while (const dirent* entry = readdir(proc)) {
if (entry->d_name[0] < '1' || entry->d_name[0] > '9') {
continue;
}
std::ifstream status(std::string("/proc/") + entry->d_name + "/status");
std::string line;
bool named = false;
bool live = false;
bool owned = false;
while (std::getline(status, line)) {
if (line.rfind("Name:", 0) == 0) {
named = line == "Name:\tsteam";
} else if (line.rfind("State:", 0) == 0) {
live = line.find_first_of("ZX") == std::string::npos;
} else if (line.rfind("Uid:", 0) == 0) {
std::istringstream fields(line.substr(4));
uid_t real = 0;
owned = static_cast<bool>(fields >> real) && real == uid;
break;
}
}
if (named && live && owned) {
found = true;
break;
}
}
closedir(proc);
return found;
#endif
}
Handoff OpenSteamUrl(const std::string& url, bool bounded) {
if (!SteamRunning()) {
return Handoff::None;
}
// The worker's helper waits for a go-ahead on fd 3 before it runs the URL handler, so a
// cancellation that couldn't take the lock while posix_spawn ran still stops it in time.
static char gateScript[] = "read -r go <&3 || exit 1; exec 3<&-; exec \"$0\" \"$@\"";
#if defined(__APPLE__)
const char* program = "/usr/bin/open";
char* argv[] = {const_cast<char*>("open"), const_cast<char*>("-g"), const_cast<char*>(url.c_str()), nullptr};
char* gated[] = {const_cast<char*>("sh"), const_cast<char*>("-c"), gateScript, const_cast<char*>(program),
const_cast<char*>("-g"), const_cast<char*>(url.c_str()), nullptr};
constexpr int kNoSignal = 0;
#else
const char* program = "xdg-open";
char* argv[] = {const_cast<char*>("xdg-open"), const_cast<char*>(url.c_str()), nullptr};
char* gated[] = {const_cast<char*>("sh"), const_cast<char*>("-c"), gateScript, const_cast<char*>(program),
const_cast<char*>(url.c_str()), nullptr};
constexpr int kNoSignal = MSG_NOSIGNAL;
#endif
int gate[2] = {-1, -1};
if (!bounded) {
if (socketpair(AF_UNIX, SOCK_STREAM, 0, gate) != 0) {
return Handoff::None;
}
// dup2(3, 3) keeps FD_CLOEXEC on macOS, which would close the gate in the child.
if (gate[0] == 3) {
std::swap(gate[0], gate[1]);
}
fcntl(gate[0], F_SETFD, FD_CLOEXEC);
fcntl(gate[1], F_SETFD, FD_CLOEXEC);
#if defined(__APPLE__)
int noSigPipe = 1;
setsockopt(gate[1], SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe));
#endif
}
const auto closeGate = [&gate] {
for (int& end : gate) {
if (end != -1) {
close(end);
end = -1;
}
}
};
posix_spawn_file_actions_t actions;
if (posix_spawn_file_actions_init(&actions) != 0) {
closeGate();
return Handoff::None;
}
if (!bounded) {
posix_spawn_file_actions_adddup2(&actions, gate[0], 3);
}
posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0);
posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO, "/dev/null", O_WRONLY, 0);
posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
// A crash handler can call this with signals blocked; the child should not inherit that.
posix_spawnattr_t attributes;
posix_spawnattr_init(&attributes);
sigset_t noSignals;
sigemptyset(&noSignals);
posix_spawnattr_setsigmask(&attributes, &noSignals);
posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSIGMASK);
pid_t child = 0;
std::unique_lock launch(g_helperMutex, std::defer_lock);
if (!bounded) {
launch.lock();
}
int error = ECANCELED;
if (bounded) {
error = posix_spawnp(&child, program, &actions, &attributes, argv, environ);
} else if (!g_helperCancelled) {
error = posix_spawn(&child, "/bin/sh", &actions, &attributes, gated, environ);
}
posix_spawnattr_destroy(&attributes);
posix_spawn_file_actions_destroy(&actions);
if (error != 0) {
closeGate();
return Handoff::None;
}
int status = 0;
if (!bounded) {
g_helper = child;
if (!g_helperCancelled) {
send(gate[1], "1\n", 2, kNoSignal);
}
closeGate();
launch.unlock();
// Wait without reaping so the pid can't be reused while it is still published.
siginfo_t info{};
while (waitid(P_PID, static_cast<id_t>(child), &info, WEXITED | WNOWAIT) == -1 && errno == EINTR) {
}
{
std::lock_guard lock(g_helperMutex);
g_helper = 0;
}
}
const auto deadline = Clock::now() + kHandoffTimeout;
for (;;) {
const pid_t waited = waitpid(child, &status, bounded ? WNOHANG : 0);
if (waited == child) {
return WIFEXITED(status) && WEXITSTATUS(status) == 0 ? Handoff::Confirmed : Handoff::Unconfirmed;
}
if (waited == -1 && errno != EINTR) {
return Handoff::Unconfirmed;
}
if (waited == 0) {
if (Clock::now() >= deadline) {
try {
std::thread([child] { waitpid(child, nullptr, 0); }).detach();
} catch (...) {
}
return Handoff::Unconfirmed;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
}
void CancelWorkerRequests() {
g_helperCancelled = true;
std::unique_lock lock(g_helperMutex, std::defer_lock);
if (!TryLockBriefly(lock, kHandoffTimeout)) {
return;
}
if (g_helper == 0 || kill(g_helper, SIGKILL) != 0) {
return;
}
// The worker only reaps after clearing g_helper under this lock, so the pid stays ours.
const auto deadline = Clock::now() + kHandoffTimeout;
for (;;) {
siginfo_t info{};
if (waitid(P_PID, static_cast<id_t>(g_helper), &info, WEXITED | WNOWAIT | WNOHANG) != 0 ||
info.si_pid != 0 || Clock::now() >= deadline) {
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
#endif
// Caller holds state.delivery. Only exit paths bound the wait; the worker waits for each
// request to finish so a slow one can never land after the next.
Handoff Deliver(State& state, bool forced, uint32_t appId, bool bounded) {
const auto earliest = Clock::time_point(Clock::duration(state.lastDelivery.load())) + kDeliverySpacing;
if (const auto now = Clock::now(); now < earliest) {
std::this_thread::sleep_for(earliest - now);
}
const Handoff delivered = OpenSteamUrl("steam://forceinputappid/" + std::to_string(forced ? appId : 0), bounded);
if (delivered != Handoff::None) {
state.lastDelivery = Clock::now().time_since_epoch().count();
if (forced) {
RT_LOG(RT_TAG_RUNTIME) << "Steam Input: applying the controller layout of app " << appId << std::endl;
} else {
RT_LOG(RT_TAG_RUNTIME) << "Steam Input: restored the desktop controller layout" << std::endl;
}
}
return delivered;
}
void Worker(State& state) {
t_isWorker = true;
std::unique_lock lock(state.mutex);
for (;;) {
state.wake.wait(lock, [&] { return state.stopping || state.desired != state.sent; });
if (state.stopping) {
return;
}
const bool target = state.desired;
const uint32_t appId = state.appId;
// A failed request is retried on the next focus change rather than in a loop.
state.sent = target;
lock.unlock();
{
std::lock_guard delivery(state.delivery);
{
// Release may have run while this thread waited for the delivery lock.
std::lock_guard guard(state.mutex);
if (state.stopping) {
return;
}
}
const Handoff delivered = Deliver(state, target, appId, false);
std::lock_guard guard(state.mutex);
state.maybeForced = target ? delivered != Handoff::None || state.maybeForced
: state.maybeForced && delivered != Handoff::Confirmed;
}
lock.lock();
}
}
} // namespace
void Initialize(bool enabled, uint32_t appId) {
if (!enabled || appId == 0 || g_state.load() != nullptr) {
return;
}
if (LaunchedBySteam()) {
RT_LOG(RT_TAG_RUNTIME) << "Steam Input: launched by Steam, leaving its controller layout alone" << std::endl;
return;
}
SDL_Window* window = aurora_get_window();
if (window == nullptr) {
return;
}
auto* state = new State();
state->enabled = true;
state->appId = appId;
state->windowId = SDL_GetWindowID(window);
state->desired = (SDL_GetWindowFlags(window) & SDL_WINDOW_INPUT_FOCUS) != 0;
try {
std::thread(Worker, std::ref(*state)).detach();
} catch (const std::exception& error) {
RT_LOG(RT_TAG_RUNTIME) << "Steam Input: " << error.what() << std::endl;
delete state;
return;
}
g_state.store(state);
}
void HandleEvents(const AuroraEvent* events) {
State* state = g_state.load();
if (state == nullptr || events == nullptr) {
return;
}
int focus = -1;
for (const AuroraEvent* event = events; event->type != AURORA_NONE; ++event) {
if (event->type != AURORA_SDL_EVENT) {
continue;
}
const SDL_Event& sdl = event->sdl;
const bool gained = sdl.type == SDL_EVENT_WINDOW_FOCUS_GAINED;
if ((gained || sdl.type == SDL_EVENT_WINDOW_FOCUS_LOST) && sdl.window.windowID == state->windowId) {
focus = gained ? 1 : 0;
}
}
if (focus < 0) {
return;
}
std::lock_guard lock(state->mutex);
if (state->enabled) {
state->desired = focus == 1;
state->wake.notify_one();
}
}
void Release() noexcept {
State* state = g_state.load();
if (state == nullptr) {
return;
}
const auto releaseWait = kHandoffTimeout + kDeliverySpacing + std::chrono::milliseconds(500);
try {
{
std::unique_lock lock(state->mutex, std::defer_lock);
if (!TryLockBriefly(lock)) {
return;
}
if (!state->enabled) {
lock.unlock();
// Another thread is restoring; exiting under it would kill its request.
const auto deadline = Clock::now() + 2 * releaseWait;
while (!t_inRelease && !state->released.load() && Clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
return;
}
t_inRelease = true;
state->enabled = false;
state->stopping = true;
state->wake.notify_one();
}
// Let an in-flight request land first so the restore is the last one Steam sees.
std::unique_lock delivery(state->delivery, std::defer_lock);
const bool ordered = !t_isWorker && delivery.try_lock_for(releaseWait);
bool maybeForced = true;
if (ordered) {
std::unique_lock lock(state->mutex, std::defer_lock);
maybeForced = !TryLockBriefly(lock) || state->maybeForced;
}
// A request still in flight, or about to start, could otherwise land after the restore.
CancelWorkerRequests();
if (maybeForced) {
// Discovery and spawning can block as well; they must not hold up the exit.
auto done = std::make_shared<std::atomic<bool>>(false);
try {
std::thread([state, done] {
Deliver(*state, false, 0, true);
done->store(true);
}).detach();
} catch (const std::system_error&) {
done->store(true);
}
const auto deadline = Clock::now() + releaseWait;
while (!done->load() && Clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
} catch (...) {
}
state->released.store(true);
}
} // namespace SteamInputLayout
+247
View File
@@ -0,0 +1,247 @@
#include "motion_trick_detector.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
using MotionTrick::Direction;
using MotionTrick::MotionSample;
using MotionTrick::ShakeDetector;
namespace {
int g_failures = 0;
constexpr uint64_t kFrameUs = 16'667;
void Check(bool ok, const std::string& what) {
if (!ok) {
std::printf(" FAIL: %s\n", what.c_str());
++g_failures;
}
}
// Written as raw bits so no float operation under -ffast-math can drop them.
void SetBits(float& target, uint32_t bits) {
std::memcpy(&target, &bits, sizeof(target));
}
MotionSample Gyro(float pitch, float yaw, float roll) {
MotionSample sample;
sample.hasGyro = true;
sample.gyro[0] = pitch;
sample.gyro[1] = yaw;
sample.gyro[2] = roll;
sample.hasAccel = true;
sample.accel[1] = 9.80665f;
return sample;
}
MotionSample AtRest() {
return Gyro(0.0f, 0.0f, 0.0f);
}
struct Run {
ShakeDetector detector;
uint64_t now = 0;
std::vector<Direction> fired;
void Feed(const MotionSample& sample, int frames = 1) {
for (int i = 0; i < frames; ++i) {
const Direction direction = detector.Update(sample, now);
if (direction != Direction::None) fired.push_back(direction);
now += kFrameUs;
}
}
// Ramp up to the peak and back down over a few frames, like a real flick.
void Flick(const MotionSample& peak) {
for (const float scale : {0.3f, 0.7f, 1.0f, 0.6f, 0.2f}) {
MotionSample sample = peak;
for (float& value : sample.gyro) value *= scale;
Feed(sample);
}
}
};
void TestRestNeverFires() {
Run run;
run.Feed(AtRest(), 600);
Check(run.fired.empty(), "controller at rest fired a trick");
}
void TestSteeringWobbleNeverFires() {
Run run;
for (int i = 0; i < 600; ++i) {
run.Feed(Gyro(1.5f * std::sin(i * 0.2f), 2.0f * std::sin(i * 0.13f), 2.5f * std::cos(i * 0.17f)));
}
Check(run.fired.empty(), "steering wobble fired a trick");
}
void TestOneFlickOneTrick() {
Run run;
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
run.Feed(AtRest(), 60);
Check(run.fired.size() == 1, "one flick did not give exactly one trick");
Check(!run.fired.empty() && run.fired[0] == Direction::Up, "upward flick was not Up");
}
void TestReturnStrokeIsIgnored() {
Run run;
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
run.Flick(Gyro(-9.0f, 0.0f, 0.0f));
run.Feed(AtRest(), 60);
Check(run.fired.size() == 1, "bringing the controller back gave a second trick");
}
void TestSeparateFlicksBothFire() {
Run run;
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
run.Feed(AtRest(), 40);
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
run.Feed(AtRest(), 40);
Check(run.fired.size() == 2, "two separate flicks did not give two tricks");
}
void TestSustainedShakeFiresOnce() {
Run run;
for (int i = 0; i < 120; ++i) {
run.Feed(Gyro(i % 2 == 0 ? 12.0f : -12.0f, 0.0f, 0.0f));
}
Check(run.fired.size() == 1, "continuous shaking fired more than once");
}
void TestDirections() {
const struct {
MotionSample peak;
Direction expected;
const char* name;
} cases[] = {
{Gyro(10.0f, 0.0f, 0.0f), Direction::Up, "pitch up"},
{Gyro(-10.0f, 0.0f, 0.0f), Direction::Down, "pitch down"},
{Gyro(1.0f, 0.0f, 10.0f), Direction::Left, "roll left"},
{Gyro(1.0f, 0.0f, -10.0f), Direction::Right, "roll right"},
{Gyro(0.0f, 10.0f, 2.0f), Direction::Left, "yaw left"},
{Gyro(0.0f, -10.0f, -2.0f), Direction::Right, "yaw right"},
};
for (const auto& c : cases) {
Run run;
run.Flick(c.peak);
Check(run.fired.size() == 1 && run.fired[0] == c.expected, std::string("wrong direction for ") + c.name);
}
}
void TestSensitivity() {
Run low;
low.detector.SetSensitivity(1);
low.Flick(Gyro(6.0f, 0.0f, 0.0f));
Check(low.fired.empty(), "sensitivity 1 fired on a small flick");
Run high;
high.detector.SetSensitivity(5);
high.Flick(Gyro(6.0f, 0.0f, 0.0f));
Check(high.fired.size() == 1, "sensitivity 5 missed a small flick");
Run clamped;
clamped.detector.SetSensitivity(99);
clamped.Flick(Gyro(6.0f, 0.0f, 0.0f));
Check(clamped.fired.size() == 1, "out of range sensitivity was not clamped");
}
void TestAccelOnlyController() {
Run run;
MotionSample rest;
rest.hasAccel = true;
rest.accel[1] = 9.80665f;
run.Feed(rest, 120);
Check(run.fired.empty(), "gravity alone fired a trick");
MotionSample jolt = rest;
jolt.accel[1] = 35.0f;
run.Feed(jolt);
run.Feed(rest, 60);
Check(run.fired.size() == 1 && run.fired[0] == Direction::Up, "accelerometer jolt did not give Up");
}
void TestGyroOnlyController() {
Run run;
MotionSample flick = Gyro(10.0f, 0.0f, 0.0f);
flick.hasAccel = false;
MotionSample rest = AtRest();
rest.hasAccel = false;
run.Flick(flick);
run.Feed(rest, 60);
run.Flick(flick);
run.Feed(rest, 60);
Check(run.fired.size() == 2 && run.fired[0] == Direction::Up && run.fired[1] == Direction::Up,
"repeated gyro-only flicks did not give two Up tricks");
}
void TestGyroPadIgnoresAccel() {
Run bump;
MotionSample jolt = AtRest();
jolt.accel[1] = 40.0f;
bump.Feed(jolt, 3);
bump.Feed(AtRest(), 60);
Check(bump.fired.empty(), "an accelerometer bump on a gyro pad fired a trick");
Run flick;
MotionSample sideways = Gyro(0.0f, 10.0f, 0.0f);
sideways.accel[1] = 40.0f;
flick.Feed(sideways);
Check(flick.fired.size() == 1 && flick.fired[0] == Direction::Left,
"a jolt changed the direction of a sideways flick");
}
void TestNoSensorsNeverFires() {
Run run;
MotionSample none;
none.gyro[0] = 50.0f;
none.accel[1] = 80.0f;
run.Feed(none, 60);
Check(run.fired.empty(), "a sample without sensors fired a trick");
}
void TestBadDataIgnored() {
Run run;
for (const uint32_t bits : {0x7fc00000u, 0x7f800000u}) {
for (int axis = 0; axis < 3; ++axis) {
MotionSample gyro = AtRest();
SetBits(gyro.gyro[axis], bits);
run.Feed(gyro, 10);
MotionSample accel;
accel.hasAccel = true;
SetBits(accel.accel[axis], bits);
run.Feed(accel, 10);
}
}
Check(run.fired.empty(), "NaN or infinite sensor data fired a trick");
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
Check(run.fired.size() == 1, "detector did not recover after NaN data");
}
} // namespace
int main() {
TestRestNeverFires();
TestSteeringWobbleNeverFires();
TestOneFlickOneTrick();
TestReturnStrokeIsIgnored();
TestSeparateFlicksBothFire();
TestSustainedShakeFiresOnce();
TestDirections();
TestSensitivity();
TestAccelOnlyController();
TestGyroOnlyController();
TestGyroPadIgnoresAccel();
TestNoSensorsNeverFires();
TestBadDataIgnored();
if (g_failures != 0) {
std::printf("%d failure(s)\n", g_failures);
return 1;
}
std::printf("motion trick tests passed\n");
return 0;
}
+39
View File
@@ -51,6 +51,45 @@ int main() {
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "invalid.toml");
if (!Require(!config.metalFxSpatialUpscaling, "invalid MetalFX setting was accepted")) return 1;
}
{
std::istringstream input("[video]\nwidescreen = false\nforce_16_9 = true\n");
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "legacy.toml");
if (!Require(config.forceAspect == std::optional<bool>{true} &&
config.widescreen == std::optional<bool>{true} && config.legacyForceAspect,
"legacy force_16_9 did not force a 16:9 game")) return 1;
}
{
std::istringstream input("[video]\nwidescreen = false\nforce_aspect = true\nforce_16_9 = true\n");
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "migrated.toml");
if (!Require(config.forceAspect == std::optional<bool>{true} &&
config.widescreen == std::optional<bool>{false} && !config.legacyForceAspect,
"legacy force_16_9 overrode force_aspect")) return 1;
}
{
std::istringstream input("[controller]\nmotion_trick = true\nmotion_trick_sensitivity = 5\n");
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "motion.toml");
if (!Require(config.motionTrick == std::optional<bool>{true} &&
config.motionTrickSensitivity == std::optional<int32_t>{5},
"shake to trick settings were not loaded")) return 1;
}
{
std::istringstream input("[controller]\nmotion_trick_sensitivity = 9\n");
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "motion-range.toml");
if (!Require(!config.motionTrick && !config.motionTrickSensitivity,
"out of range shake to trick sensitivity was accepted")) return 1;
}
{
std::istringstream input("[controller]\nsteam_input_override = false\nsteam_input_app_id = 1234\n");
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "steam.toml");
if (!Require(config.steamInputOverride == std::optional<bool>{false} &&
config.steamInputAppId == std::optional<uint32_t>{1234},
"Steam Input settings were not loaded")) return 1;
}
{
std::istringstream input("[controller]\nsteam_input_app_id = 0\n");
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "steam-zero.toml");
if (!Require(!config.steamInputAppId, "zero Steam Input app id was accepted")) return 1;
}
if (!SetsFps("[video]\nshow_fps = false\n", "bare key was not replaced")) return 1;
if (!SetsFps("[video]\n\"show_fps\" = false\n", "basic-quoted key was not replaced")) return 1;
if (!SetsFps("[video]\n'show_fps'=false\n", "literal-quoted key was not replaced")) return 1;
-348
View File
@@ -1,348 +0,0 @@
"""Analyze MKW_FRAME_TRACE CSV files. No third-party packages required."""
import argparse
import bisect
import collections
import csv
import json
import math
import io
import re
import statistics
from pathlib import Path
NS_MS = 1_000_000
NATIVE_FPS = 60
COLUMNS = ("event", "timestamp_ns", "frame_id", "slot", "duration_ns", "deadline_ns",
"interval_ns", "detail")
INTEGER_COLUMNS = COLUMNS[1:]
SIGNED_COLUMNS = {"slot"}
DROP_SIGNALS = {"drop_late": "late_slot_dropped", "drop_display": "display_slot_dropped",
"present_unavailable": "present_unavailable", "present_failed": "present_failed"}
ANCHORS = ("auto", "first_row", "first_real_present", "first_transform", "benchmark_ready")
DISPLAY_LIMITED_INVALID_FRACTION = .01
class TraceFormatError(ValueError):
pass
def percentile(values, fraction):
if not values:
return 0.0
values = sorted(values)
return values[max(0, math.ceil(len(values) * fraction) - 1)]
def distribution(values):
return {"count": len(values), "median_ms": percentile(values, .5),
"p95_ms": percentile(values, .95), "p99_ms": percentile(values, .99),
"max_ms": max(values, default=0)}
def parse_trace(path):
"""Returns (rows, lost_samples, truncated_tail); rows exclude trace_lost markers."""
path = Path(path)
try:
content = path.read_bytes().decode("utf-8-sig")
except UnicodeDecodeError as error:
raise TraceFormatError(f"{path}: not UTF-8 text ({error})") from None
truncated_tail = bool(content) and not content.endswith("\n")
if truncated_tail:
content = content[:content.rfind("\n") + 1]
reader = csv.reader(io.StringIO(content, newline=""))
try:
return _parse_rows(path, reader, truncated_tail)
except csv.Error as error:
raise TraceFormatError(f"{path}: line {reader.line_num}: {error}") from None
def _parse_rows(path, reader, truncated_tail):
header = next(reader, None)
if header is None:
raise TraceFormatError(f"{path}: no complete header line")
duplicate = sorted({name for name in header if header.count(name) > 1})
missing = [name for name in COLUMNS if name not in header]
foreign = sorted({name for name in header if name not in COLUMNS})
if duplicate or missing or foreign:
problems = [f"{label} {names}" for label, names in
(("duplicate columns", duplicate), ("missing columns", missing),
("unexpected columns", foreign)) if names]
raise TraceFormatError(f"{path}: line 1: not a frame trace header: {'; '.join(problems)}")
index = {name: header.index(name) for name in COLUMNS}
rows, lost = [], 0
for fields in reader:
line = reader.line_num
if len(fields) != len(header):
raise TraceFormatError(f"{path}: line {line}: expected {len(header)} fields, got {len(fields)}")
row = {"event": fields[index["event"]], "line": line}
if not row["event"]:
raise TraceFormatError(f"{path}: line {line}: empty event name")
for name in INTEGER_COLUMNS:
text = fields[index[name]]
if not re.fullmatch(r"-?[0-9]{1,20}", text):
raise TraceFormatError(f"{path}: line {line}: column '{name}' is not an integer: {text!r}")
row[name] = int(text)
if row[name] < 0 and name not in SIGNED_COLUMNS:
raise TraceFormatError(f"{path}: line {line}: column '{name}' must not be negative: {text}")
if row["event"] == "trace_lost":
lost += row["detail"]
else:
rows.append(row)
return rows, lost, truncated_tail
def trace_state(path):
"""Cheap readiness probe for a trace still being written; None until the header is complete."""
if b"\n" not in Path(path).read_bytes():
return None
rows = parse_trace(path)[0]
state = {"benchmark_ready_ns": None, "real_present_ns": None, "transform_ns": None,
"display_limited": False}
for row in rows:
if row["event"] == "benchmark_ready" and state["benchmark_ready_ns"] is None:
state["benchmark_ready_ns"] = row["timestamp_ns"]
elif row["event"] == "present_real" and state["real_present_ns"] is None:
state["real_present_ns"] = row["timestamp_ns"]
elif row["event"] == "interp_uniforms" and row["detail"] and state["transform_ns"] is None:
state["transform_ns"] = row["timestamp_ns"]
elif row["event"] == "display_limited":
state["display_limited"] = True
return state
def native_period_ms(rows):
"""Scheduled native VI period from the seal events; 60 Hz only when the trace has none."""
intervals = [row["interval_ns"] for row in rows if row["event"] == "seal" and row["interval_ns"]]
return statistics.median(intervals) / NS_MS if intervals else 1000 / NATIVE_FPS
def timing_anchor(rows, requested):
if requested == "auto":
requested = "benchmark_ready"
matches = {"first_row": lambda row: True,
"benchmark_ready": lambda row: row["event"] == "benchmark_ready",
"first_real_present": lambda row: row["event"] == "present_real",
"first_transform": lambda row: row["event"] == "interp_uniforms" and row["detail"] > 0}
for used in dict.fromkeys((requested, "first_real_present", "first_row")):
row = next((row for row in rows if matches[used](row)), None)
if row:
return {"requested": requested, "used": used, "start_ns": row["timestamp_ns"],
"found": used == requested}
raise AssertionError("first_row always matches")
def analyze(path, warmup=15, fps=60, anchor="auto"):
if not math.isfinite(warmup) or warmup < 0:
raise ValueError(f"warmup must be a finite number of seconds >= 0, got {warmup}")
if isinstance(fps, bool) or not isinstance(fps, int) or fps <= 0:
raise ValueError(f"fps must be a positive integer, got {fps!r}")
if anchor not in ANCHORS:
raise ValueError(f"anchor must be one of {ANCHORS}, got {anchor!r}")
rows, lost, truncated_tail = parse_trace(path)
if not rows:
raise TraceFormatError(f"{path}: no timing samples ({lost} trace_lost samples, no events)")
rows.sort(key=lambda row: row["timestamp_ns"])
start = rows[0]["timestamp_ns"]
timing = timing_anchor(rows, anchor)
native_ms = native_period_ms(rows)
cutoff = timing["start_ns"] + warmup * 1_000_000_000
measured = [row for row in rows if row["timestamp_ns"] >= cutoff]
frames = collections.defaultdict(list)
for row in rows:
frames[row["frame_id"]].append(row)
counts = collections.Counter(row["event"] for row in measured)
stages = collections.defaultdict(list)
for row in measured:
if row["duration_ns"]:
stages[row["event"]].append(row["duration_ns"] / NS_MS)
drops = [row for row in measured if row["event"] in DROP_SIGNALS]
drop_times = [row["timestamp_ns"] for row in drops]
limited_ms = {}
for row in rows:
if row["event"] == "display_limited" and row["detail"]:
limited_ms[row["frame_id"]] = max(limited_ms.get(row["frame_id"], 0),
row["interval_ns"] / NS_MS or native_ms)
def evidence(frame_ids, window, attributed):
timings = collections.defaultdict(float)
pending = 0
wake_late = 0
signals = []
window_drops = drops[bisect.bisect_left(drop_times, window[0]):bisect.bisect_right(drop_times, window[1])]
for row in window_drops:
attributed.add(id(row))
signals.append(DROP_SIGNALS[row["event"]])
for frame_id in frame_ids:
for row in frames[frame_id]:
timings[row["event"]] = max(timings[row["event"]], row["duration_ns"] / NS_MS)
if row["event"] == "pipelines":
pending = max(pending, row["detail"])
if row["event"] == "pacer_wait" and row["duration_ns"] > NS_MS and row["deadline_ns"]:
wake_late = max(wake_late, (row["timestamp_ns"] - row["deadline_ns"]) / NS_MS)
for stage, threshold, label in [
("surface_wait", 2, "surface_or_driver_wait"),
("seal", 2, "interpolation_seal"),
("pipeline_wait", 1, "pipeline_compilation_wait"),
("worker_queue", 2, "render_worker_queue_wait"),
("resource_wait", 2, "frame_resources_wait"),
("replay_encode", 2, "interpolation_replay_encode"),
("present_work", 2, "submit_or_present_cpu_work"),
("produce", native_ms * 1.25, "producer_wall_time"),
("game_busy", native_ms, "game_thread_busy_over_budget"),
]:
if timings[stage] > threshold:
signals.append(label)
if pending:
signals.append("pipelines_pending")
if wake_late > 1:
signals.append("pacer_woke_late")
for frame_id in frame_ids:
for row in frames[frame_id]:
if row["event"] in DROP_SIGNALS:
attributed.add(id(row))
signals.append(DROP_SIGNALS[row["event"]])
if row["event"] == "display_limited":
signals.append("display_refresh_limited")
if row["event"] == "interp_health" and fps > NATIVE_FPS:
health = row["detail"]
if not health & (1 << 18):
signals.append("producer_missed_retrace")
if not health & (1 << 17):
signals.append("interpolation_replay_unsafe")
if not health & (1 << 16):
signals.append("interpolation_ineligible")
if row["event"] == "interp_matches" and fps > NATIVE_FPS:
matchable, matches = row["detail"] >> 32, row["detail"] & 0xffffffff
if matchable and matches * 100 < matchable * 55:
signals.append("low_transform_match_coverage")
signals = sorted(set(signals))
return {"signals": signals or ["unattributed"], "stage_ms": dict(timings),
"queued_pipelines": pending, "pacer_wake_late_ms": wake_late}
def cadence(real_only):
presents = [row for row in rows if row["event"] in (
("present_real",) if real_only else ("present_real", "present_interp"))]
budget = native_ms if real_only or fps <= NATIVE_FPS else min(native_ms, 1000 / fps)
tolerance = max(1, budget * .25)
intervals, spikes, effective_spikes, lateness = [], [], [], []
transitions = collections.defaultdict(list)
attributed = set()
limited_gaps = 0
effective_budget = budget
for previous, row in zip(presents, presents[1:]):
if previous["timestamp_ns"] < cutoff:
continue
gap = (row["timestamp_ns"] - previous["timestamp_ns"]) / NS_MS
intervals.append(gap)
transitions[previous["event"] + "->" + row["event"]].append(gap)
if row["deadline_ns"]:
lateness.append(max(0, row["timestamp_ns"] - row["deadline_ns"]) / NS_MS)
limit = max(limited_ms.get(previous["frame_id"], 0), limited_ms.get(row["frame_id"], 0))
expected = max(budget, limit)
limited_gaps += expected > budget
effective_budget = max(effective_budget, expected)
if gap > budget + tolerance:
nearby = set()
spike = {"seconds": (row["timestamp_ns"] - start) / 1e9,
"frame_id": row["frame_id"], "kind": row["event"],
"gap_ms": gap, "excess_ms": gap - budget,
**evidence({previous["frame_id"], row["frame_id"]},
(previous["timestamp_ns"], row["timestamp_ns"]), nearby)}
spikes.append(spike)
if gap > expected + max(1, expected * .25):
effective_spikes.append({**spike, "excess_ms": gap - expected})
attributed |= nearby
worst = lambda found: sorted(found, key=lambda item: item["gap_ms"], reverse=True)[:20]
counted = lambda found: dict(collections.Counter(
signal for spike in found for signal in spike["signals"]))
return {**distribution(intervals), "budget_ms": budget, "tolerance_ms": tolerance,
"stutters": len(spikes), "signals": counted(spikes),
"effective_budget_ms": effective_budget, "display_limited_gaps": limited_gaps,
"effective_stutters": len(effective_spikes),
"effective_signals": counted(effective_spikes),
"transitions": {key: distribution(values) for key, values in transitions.items()},
"deadline_lateness": distribution(lateness),
"worst": worst(spikes), "effective_worst": worst(effective_spikes)}, attributed
real, _ = cadence(True)
all_presentations, attributed = cadence(False)
isolated = [row for row in drops if id(row) not in attributed]
match_ratios = [100 * (row["detail"] & 0xffffffff) / (row["detail"] >> 32)
for row in measured if row["event"] == "interp_matches" and row["detail"] >> 32]
real_presents = counts["present_real"]
limited_frames = len({row["frame_id"] for row in measured
if row["event"] == "display_limited" and row["detail"]})
limited_fraction = limited_frames / real_presents if real_presents else 0.0
if fps <= NATIVE_FPS:
status = "not_requested"
elif not real_presents:
status = "no_real_presents"
elif limited_fraction >= DISPLAY_LIMITED_INVALID_FRACTION:
status = "display_limited"
elif not any(row["event"] == "interp_uniforms" and row["detail"] > 0 for row in measured):
status = "no_transform_work"
elif not counts["present_interp"]:
status = "no_interpolated_presents"
else:
status = "ok"
return {"trace": str(path), "warmup_seconds": warmup, "target_fps": fps,
"timing_anchor": timing, "native_period_ms": native_ms,
"native_period_source": "seal" if any(row["event"] == "seal" and row["interval_ns"]
for row in rows) else "default_60hz",
"interpolation": {"status": status, "target_valid": status in ("ok", "not_requested"),
"display_limited": limited_fraction >= DISPLAY_LIMITED_INVALID_FRACTION,
"display_limited_frames": limited_frames,
"display_limited_fraction": limited_fraction,
"interpolated_presents": counts["present_interp"]},
"measured_seconds": max(0, (rows[-1]["timestamp_ns"] - cutoff) / 1e9),
"lost_trace_samples": lost, "truncated_final_row": truncated_tail, "events": dict(counts),
"isolated_drops": len(isolated),
"isolated_drop_examples": [{"seconds": (row["timestamp_ns"] - start) / 1e9, "event": row["event"],
"frame_id": row["frame_id"], "slot": row["slot"]}
for row in isolated[:5]],
"unassociated_game_timing_samples": sum(row["event"] == "game_busy" and row["frame_id"] == 0
for row in measured),
"real": real, "all_presentations": all_presentations,
"stages": {key: distribution(values) for key, values in stages.items()},
"transform_matches": {"frames": len(match_ratios),
"low_match_frames": sum(value < 55 for value in match_ratios),
"median_percent": percentile(match_ratios, .5),
"p05_percent": percentile(match_ratios, .05)},
"limitations": ["Present timestamps measure CPU surface submission, not physical scanout or GPU execution.",
"Signals correlate slow stages with gaps; they do not prove causation.",
"Producer wall time includes guest waits. Hidden-window results can differ from visible VSync.",
"A forced stop can discard the final 100 ms of trace samples.",
"stutters/signals use the requested fps budget; effective_* also accept deliberate "
"display-limited suppression. Check interpolation.target_valid before comparing targets."]}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("trace", type=Path)
parser.add_argument("--warmup", type=float, default=15)
parser.add_argument("--fps", type=int, default=60)
parser.add_argument("--anchor", choices=ANCHORS, default="auto",
help="warmup start: auto uses the renderer's benchmark_ready event (first CPU racer "
"initialized, independent of --fps), else the first real present")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
try:
result = analyze(args.trace, args.warmup, args.fps, args.anchor)
except (OSError, ValueError) as error:
raise SystemExit(f"error: {error}")
if args.output:
args.output.write_text(json.dumps(result, indent=2), encoding="utf-8")
for key in ("real", "all_presentations"):
entry = result[key]
print(f"{key}: {entry['count']} intervals, p99 {entry['p99_ms']:.3f} ms, "
f"max {entry['max_ms']:.3f} ms, {entry['stutters']} requested / "
f"{entry['effective_stutters']} effective stutters; {entry['effective_signals']}")
interpolation = result["interpolation"]
print(f"interpolation: {interpolation['status']}, anchor {result['timing_anchor']['used']}")
print(f"late slots: {result['events'].get('drop_late', 0)}, "
f"display drops: {result['events'].get('drop_display', 0)}, "
f"isolated drops: {result['isolated_drops']}, trace loss: {result['lost_trace_samples']}")
if __name__ == "__main__":
main()
-354
View File
@@ -1,354 +0,0 @@
"""Windows hidden-race benchmark with inherited affinity and isolated portable settings.
Every case gets its own portable directory, seeded from one pristine snapshot of the NAND and
(optionally) the cache, so a case never inherits another case's changed cache or saves.
"""
import argparse
import ctypes
from ctypes import wintypes
import itertools
import json
import math
import os
import re
from pathlib import Path
import shutil
import subprocess
import time
import tomllib
from analyze_stutters import analyze, trace_state
SUPPORTED_RESOLUTIONS = (0, 1, 1.5, 2, 3, 4, 6, 8)
GRAPHICS_APIS = ("auto", "d3d12", "vulkan")
FPS_TARGETS = (0, 120, 180)
HELPER_COUNTS = ("auto", *map(str, range(7)))
STARTUP_TIMEOUT = 60
class ConfigError(ValueError):
pass
def cpu_topology():
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.GetCurrentProcess.restype = wintypes.HANDLE
kernel.GetProcessAffinityMask.argtypes = [wintypes.HANDLE, ctypes.POINTER(ctypes.c_size_t),
ctypes.POINTER(ctypes.c_size_t)]
kernel.SetProcessAffinityMask.argtypes = [wintypes.HANDLE, ctypes.c_size_t]
process, system = ctypes.c_size_t(), ctypes.c_size_t()
handle = kernel.GetCurrentProcess()
if not kernel.GetProcessAffinityMask(handle, ctypes.byref(process), ctypes.byref(system)):
raise ctypes.WinError(ctypes.get_last_error())
# SYSTEM_LOGICAL_PROCESSOR_INFORMATION has an affinity mask, relationship enum,
# and a 16-byte union. RelationProcessorCore (0) gives each core's SMT mask.
class ProcessorInfo(ctypes.Structure):
_fields_ = [("mask", ctypes.c_size_t), ("relationship", ctypes.c_int),
("data", ctypes.c_ulonglong * 2)]
size = wintypes.DWORD()
kernel.GetLogicalProcessorInformation(None, ctypes.byref(size))
buffer = ctypes.create_string_buffer(size.value)
if not kernel.GetLogicalProcessorInformation(buffer, ctypes.byref(size)):
raise ctypes.WinError(ctypes.get_last_error())
cores = []
for offset in range(0, size.value, ctypes.sizeof(ProcessorInfo)):
info = ProcessorInfo.from_buffer(buffer, offset)
available = info.mask & process.value
if info.relationship == 0 and available:
cores.append(available & -available) # one logical processor per physical core
return kernel, handle, process.value, cores
def quoted(value):
# JSON basic strings are valid TOML except for raw DEL.
return json.dumps(str(value), ensure_ascii=False).replace("\x7f", "\\u007f")
def interpolation_helpers(core_count, requested):
if requested != "auto":
return int(requested)
if not core_count:
return None
return min(core_count - 1, 6) if core_count > 2 else 0
def parse_list(text, name, parse, allowed=None):
"""Comma-separated matrix axis; rejects malformed, unsupported and duplicate entries."""
values = []
for item in text.split(","):
try:
values.append(parse(item.strip()))
except ValueError:
raise ValueError(f"--{name}: invalid entry {item!r}") from None
if allowed is not None and (bad := [value for value in values if value not in allowed]):
raise ValueError(f"--{name}: unsupported {bad}; allowed {list(allowed)}")
if len(set(values)) != len(values):
raise ValueError(f"--{name}: duplicate entries in {text!r}")
return values
def parse_helper(text):
return text if text == "auto" else str(int(text))
class SourceConfig:
def __init__(self, dvd_root, retro_rewind_root, nand_root, graphics_api):
self.dvd_root, self.retro_rewind_root = dvd_root, retro_rewind_root
self.nand_root, self.graphics_api = nand_root, graphics_api
def load_source_config(config_path):
"""Reads and validates the user's Config.toml before anything is staged."""
config_path = Path(config_path)
try:
with config_path.open("rb") as source:
config = tomllib.load(source)
except OSError as error:
raise ConfigError(f"cannot read config {config_path}: {error}") from None
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as error:
raise ConfigError(f"{config_path}: invalid TOML: {error}") from None
def table(name):
value = config.get(name, {})
if not isinstance(value, dict):
raise ConfigError(f"{config_path}: [{name}] must be a table, got {type(value).__name__}")
return value
paths, video = table("paths"), table("video")
base = config_path.resolve().parent
def source_path(key, directory=False):
value = paths.get(key)
if value is None or value == "":
return None
if not isinstance(value, str):
raise ConfigError(f"{config_path}: paths.{key} must be a string, got {type(value).__name__}")
path = Path(value)
path = path if path.is_absolute() else (base / path).resolve()
if not (path.is_dir() if directory else path.exists()):
raise ConfigError(f"{config_path}: paths.{key} {'is not a directory' if directory else 'does not exist'}: {path}")
return path
api = video.get("graphics_api", "auto")
if not isinstance(api, str) or api not in GRAPHICS_APIS:
raise ConfigError(f"{config_path}: video.graphics_api must be one of {list(GRAPHICS_APIS)}, got {api!r}")
return SourceConfig(source_path("dvd_root"), source_path("retro_rewind_root"),
source_path("nand_root", directory=True), api)
def isolated_config_text(source, nand_root, fps, resolution, vsync):
lines = ["[paths]"]
for key in ("dvd_root", "retro_rewind_root"):
if path := getattr(source, key):
lines.append(f"{key} = {quoted(path)}")
lines.append(f"nand_root = {quoted(nand_root)}")
# No physical controller scans or presence/network traffic during measurements.
# Keep the audio mixer running; --hidden retains normal audio behavior.
lines += ["[controller]", "wii_remotes = false", "[network]", "enabled = false",
"[discord]", "enabled = false", "[audio]", "volume = 0.0",
"attenuate_music_when_media_plays = false",
"[video]", "window_width = 854", "window_height = 480", 'display_mode = "windowed"',
f"graphics_api = {quoted(source.graphics_api)}",
f"resolution_multiplier = {float(resolution)}", f"frame_interpolation_fps = {fps}",
f"vsync = {'true' if vsync else 'false'}", "show_fps = false"]
return "\n".join(lines) + "\n"
def wait_ready(process, trace, label):
"""Waits for the renderer's benchmark_ready marker (first CPU racer initialized)."""
deadline = time.monotonic() + STARTUP_TIMEOUT
while True:
if process.poll() is not None:
raise RuntimeError(f"{label}: game exited during startup ({process.returncode}); see log")
state = trace_state(trace) if trace.exists() else None
if state and state["benchmark_ready_ns"] is not None:
return state
if time.monotonic() >= deadline:
raise RuntimeError(f"{label}: no benchmark_ready trace event within {STARTUP_TIMEOUT}s of "
"startup; the race did not start or the executable predates the marker; see log")
time.sleep(.25)
def stop_process(process, label):
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=10)
return
except subprocess.TimeoutExpired:
process.kill()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
raise RuntimeError(f"{label}: game did not exit after terminate and kill") from None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--exe", type=Path, required=True)
parser.add_argument("--config", type=Path,
default=Path(os.environ.get("LOCALAPPDATA", ".")) / "WiiCompiled/Config.toml")
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--cpus", default="0,4", help="physical cores without SMT; 0 keeps full inherited affinity")
parser.add_argument("--fps", default="0,120")
parser.add_argument("--helpers", default="auto", help="auto or comma-separated counts 0..6")
parser.add_argument("--seconds", type=float, default=60, help="measurement time, excluding warmup")
parser.add_argument("--warmup", type=float, default=15)
parser.add_argument("--repeats", type=int, default=1)
parser.add_argument("--course", type=lambda value: int(value, 0), default=8)
parser.add_argument("--resolution", type=float, default=1)
parser.add_argument("--vsync", action="store_true")
parser.add_argument("--no-shared-cache-affinity", action="store_true",
help="keep all threads within the process mask, without additional cache-domain pinning")
parser.add_argument("--cache-from", type=Path,
help="snapshot this Cache directory once and seed every case from the snapshot; "
"without it every case starts cold")
parser.add_argument("--allow-display-limited", action="store_true",
help="exit 0 even when a requested interpolation target was suppressed by the display")
args = parser.parse_args()
if os.name != "nt":
parser.error("The runner requires Windows; the trace analyzer is cross-platform.")
try:
cpus = parse_list(args.cpus, "cpus", int)
fps_targets = parse_list(args.fps, "fps", int, FPS_TARGETS)
helpers = parse_list(args.helpers, "helpers", parse_helper, HELPER_COUNTS)
except ValueError as error:
parser.error(str(error))
if not (math.isfinite(args.seconds) and args.seconds > 0 and math.isfinite(args.warmup) and args.warmup >= 0):
parser.error("seconds must be finite and > 0; warmup must be finite and >= 0")
if args.repeats < 1 or not 0 <= args.course <= 31:
parser.error("invalid repeats or course")
if not math.isfinite(args.resolution) or args.resolution not in SUPPORTED_RESOLUTIONS:
parser.error(f"resolution must be one of {list(SUPPORTED_RESOLUTIONS)}")
kernel, handle, original_mask, cores = cpu_topology()
if any(value < 0 or value > len(cores) for value in cpus):
parser.error(f"cpus must be between 0 and {len(cores)}")
try:
exe = args.exe.resolve(strict=True)
source = load_source_config(args.config)
except (OSError, ConfigError) as error:
parser.error(str(error))
output = args.output.resolve()
if source.nand_root and output.is_relative_to(source.nand_root.resolve()):
parser.error("--output must not be inside the source nand_root; the NAND snapshot would copy itself")
cache_from = args.cache_from.resolve() if args.cache_from else None
if cache_from and (not cache_from.is_dir() or output.is_relative_to(cache_from)
or cache_from.is_relative_to(output)):
parser.error("--cache-from must be an existing directory outside the output directory")
if (output / "portable").exists() or (output / "seeds").exists():
parser.error("output already contains portable/ or seeds/; use a fresh output directory")
output.mkdir(parents=True, exist_ok=True)
seeds = output / "seeds"
nand_seed, cache_seed = seeds / "NAND", seeds / "Cache"
if source.nand_root:
shutil.copytree(source.nand_root, nand_seed, ignore=shutil.ignore_patterns("*.nandsafe.tmp"))
else:
nand_seed.mkdir(parents=True)
if cache_from:
shutil.copytree(cache_from, cache_seed)
cache_mode = "seeded" if cache_from else "cold"
print(f"cache: {cache_mode}" + (f" (every case copies the snapshot of {cache_from})" if cache_from
else " (every case starts with an empty cache; results include shader/pipeline compilation)"),
flush=True)
runtime_files = [exe, *sorted(itertools.chain(exe.parent.glob("*.dll"), exe.parent.glob("*.bin"),
exe.parent.glob("initial_pipeline_cache.db")))]
summaries, invalid = [], []
for repeat, core_count, fps, helper in itertools.product(range(args.repeats), cpus, fps_targets, helpers):
effective_helpers = interpolation_helpers(core_count, helper)
label = f"r{repeat}-cpu{core_count}-fps{fps}-helpers{helper}"
mask = original_mask if core_count == 0 else sum(cores[:core_count])
trace = output / f"{label}.csv"
stage = output / "portable" / label
user = stage / "UserData"
user.mkdir(parents=True)
(stage / "portable.txt").write_text("stutter benchmark\n")
for runtime_file in dict.fromkeys(runtime_files):
shutil.copy2(runtime_file, stage / runtime_file.name)
shutil.copytree(nand_seed, user / "NAND")
if cache_from:
shutil.copytree(cache_seed, user / "Cache")
(user / "Config.toml").write_text(
isolated_config_text(source, user / "NAND", fps, args.resolution, args.vsync), encoding="utf-8")
print(f"running {label}, affinity=0x{mask:x}, cache={cache_mode}", flush=True)
env = os.environ.copy()
env["MKW_FRAME_TRACE"] = str(trace)
env["MKW_PERF_STATS"] = "1"
env.pop("MKW_INTERPOLATION_HELPERS", None)
env.pop("MKW_SHARED_CACHE_AFFINITY", None)
if args.no_shared_cache_affinity:
env["MKW_SHARED_CACHE_AFFINITY"] = "0"
if effective_helpers is not None:
env["MKW_INTERPOLATION_HELPERS"] = str(effective_helpers)
with (output / f"{label}.log").open("wb") as log:
try:
if not kernel.SetProcessAffinityMask(handle, mask):
raise ctypes.WinError(ctypes.get_last_error())
# The child inherits affinity at creation, before any worker pool sizes itself.
process = subprocess.Popen([str(stage / exe.name), "--hidden", "--race-cpu",
f"--race={args.course}"], cwd=stage, env=env,
stdout=log, stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NO_WINDOW)
finally:
if not kernel.SetProcessAffinityMask(handle, original_mask):
raise ctypes.WinError(ctypes.get_last_error())
forced_stop = False
try:
# The analyzer anchors warmup at the same event for every fps target, so
# boot and loading never consume the warmup/measurement interval.
started = time.monotonic()
ready = wait_ready(process, trace, label)
startup_seconds = time.monotonic() - started
process.wait(timeout=args.warmup + args.seconds + 1)
raise RuntimeError(f"{label}: game exited early ({process.returncode}); see log")
except subprocess.TimeoutExpired:
forced_stop = True
finally:
stop_process(process, label)
result = analyze(trace, args.warmup, fps or 60)
if result["lost_trace_samples"] or result["truncated_final_row"]:
raise RuntimeError(f"{label}: incomplete trace; see log")
log_text = (output / f"{label}.log").read_text(encoding="utf-8", errors="replace")
affinity_counts = {name: int(count) for name, count in re.findall(
r"\[aurora::thread\] (.+?): (\d+) available CPUs after affinity", log_text)}
if result["real"]["count"] == 0:
raise RuntimeError(f"{label}: no real frame presentations after warmup; see log")
interpolation = result["interpolation"]
if fps and interpolation["status"] == "no_transform_work":
raise RuntimeError(f"{label}: no transform interpolation work after warmup; verify the race loaded")
if result["measured_seconds"] < args.seconds * .9:
raise RuntimeError(f"{label}: measured only {result['measured_seconds']:.1f}s of {args.seconds}s; see log")
if fps and not interpolation["target_valid"]:
invalid.append(label)
print(f" WARNING {label}: requested {fps} fps target is {interpolation['status']} "
f"({interpolation['display_limited_fraction']:.0%} of frames display-limited); "
"use effective_* statistics and do not compare it with a real 120/180 fps run", flush=True)
result.update(label=label, affinity_mask=hex(mask), physical_core_limit=core_count,
helpers=helper, repeat=repeat, forced_stop=forced_stop,
effective_helpers=effective_helpers, host_logical_processors=os.cpu_count(),
resolution=args.resolution, vsync=args.vsync, course=args.course,
shared_cache_affinity=not args.no_shared_cache_affinity,
cache_mode=cache_mode, cache_seed=str(cache_from) if cache_from else None,
startup_seconds=startup_seconds, startup_ready=ready,
thread_affinity_cpu_counts=affinity_counts,
affinity_narrowed=bool(core_count and any(
count < core_count for count in affinity_counts.values())),
executable=str(exe), available_core_masks=[hex(core) for core in cores])
(output / f"{label}.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
summaries.append(result)
(output / "summary.json").write_text(json.dumps(summaries, indent=2), encoding="utf-8")
for kind in ("real", "all_presentations"):
entry = result[kind]
print(f" {kind}: p99={entry['p99_ms']:.2f} max={entry['max_ms']:.2f} ms "
f"stutters={entry['stutters']} effective={entry['effective_stutters']} "
f"signals={entry['effective_signals']}", flush=True)
print(f" interpolation={interpolation['status']} isolated_drops={result['isolated_drops']}", flush=True)
if invalid and not args.allow_display_limited:
raise SystemExit(f"display-limited or invalid interpolation targets: {', '.join(invalid)}")
if __name__ == "__main__":
main()
-332
View File
@@ -1,332 +0,0 @@
import csv
from pathlib import Path
import tempfile
import unittest
from analyze_stutters import COLUMNS, TraceFormatError, analyze, trace_state
HEADER = ",".join(COLUMNS) + "\n"
class AnalyzeStuttersTests(unittest.TestCase):
def analyze_rows(self, rows, fps=120, warmup=0, tail="", **options):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "trace.csv"
with path.open("w", newline="") as stream:
writer = csv.writer(stream)
writer.writerow(["event", "timestamp_ns", "frame_id", "slot", "duration_ns",
"deadline_ns", "interval_ns", "detail"])
writer.writerows(rows)
stream.write(tail)
return analyze(path, warmup=warmup, fps=fps, **options)
def analyze_text(self, text, fps=60, warmup=0):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "trace.csv"
path.write_bytes(text.encode("utf-8"))
return analyze(path, warmup=warmup, fps=fps)
@staticmethod
def present(ms, frame, real=True):
at = round(ms * 1_000_000)
return ["present_real" if real else "present_interp", at, frame, 0, 0, at, 8_333_333, 0]
@staticmethod
def limited(ms, frame):
return ["display_limited", round(ms * 1_000_000), frame, -1, 0, 0, 16_666_667, 1]
@staticmethod
def seal(ms, frame, period_ns):
return ["seal", round(ms * 1_000_000), frame, -1, 1_000_000, 0, period_ns, 0]
@staticmethod
def ready(ms):
return ["benchmark_ready", round(ms * 1_000_000), 0, -1, 0, 0, 0, 0]
def pal_rows(self, gaps_ms, start=100):
rows, at = [], start
for frame, gap in enumerate([0, *gaps_ms], 1):
at += gap
rows += [self.present(at, frame), self.seal(at - 1, frame, 20_000_000)]
return rows
def display_limited_rows(self, frames=40):
rows = []
for i in range(frames):
rows += [self.present(100 + i * 1000 / 60, i + 1), self.limited(100 + i * 1000 / 60, i + 1)]
return rows
def test_regular_interpolation_has_no_stutters(self):
rows = [self.present(100 + i * 1000 / 120, i // 2, i % 2 == 1) for i in range(40)]
result = self.analyze_rows(rows)
self.assertEqual(result["real"]["stutters"], 0)
self.assertEqual(result["all_presentations"]["stutters"], 0)
self.assertAlmostEqual(result["all_presentations"]["p99_ms"], 1000 / 120, places=5)
def test_missing_interpolation_slot_does_not_mean_real_frame_stutter(self):
rows = [self.present(100, 1), self.present(108.333333, 2, False),
self.present(116.666667, 2), self.present(133.333333, 3)]
rows.append(["seal", 130_000_000, 3, -1, 4_000_000, 0, 0, 0])
rows.append(["drop_late", 131_000_000, 3, 0, 0, 125_000_000, 8_333_333, 0])
result = self.analyze_rows(rows)
self.assertEqual(result["real"]["stutters"], 0)
self.assertEqual(result["all_presentations"]["stutters"], 1)
self.assertEqual(result["all_presentations"]["effective_stutters"], 1)
self.assertIn("interpolation_seal", result["all_presentations"]["signals"])
self.assertIn("late_slot_dropped", result["all_presentations"]["effective_signals"])
self.assertEqual(result["events"]["drop_late"], 1)
self.assertEqual(result["isolated_drops"], 0)
def test_real_frame_gap_and_driver_wait(self):
rows = [self.present(100, 1), self.present(140, 2),
["surface_wait", 140_000_001, 2, 0, 20_000_000, 0, 0, 0]]
result = self.analyze_rows(rows, fps=60)
self.assertEqual(result["real"]["stutters"], 1)
self.assertIn("surface_or_driver_wait", result["real"]["signals"])
self.assertEqual(result["interpolation"]["status"], "not_requested")
self.assertTrue(result["interpolation"]["target_valid"])
def test_game_work_is_distinct_from_gx_producer_wall_time(self):
rows = [self.present(100, 1), self.present(140, 2),
["game_busy", 139_000_000, 2, -1, 30_000_000, 0, 40_000_000, 10_000_000],
["produce", 138_000_000, 2, -1, 5_000_000, 0, 0, 0]]
result = self.analyze_rows(rows, fps=60)
self.assertIn("game_thread_busy_over_budget", result["real"]["signals"])
self.assertNotIn("producer_wall_time", result["real"]["signals"])
self.assertEqual(result["unassociated_game_timing_samples"], 0)
def test_warmup_excludes_cross_boundary_gap(self):
rows = [self.present(100, 1), self.present(2100, 2), self.present(2116.666667, 3)]
result = self.analyze_rows(rows, fps=60, warmup=1)
self.assertEqual(result["real"]["count"], 1)
self.assertEqual(result["real"]["stutters"], 0)
def test_loss_and_incomplete_final_write_are_visible(self):
rows = [self.present(100, 1), self.present(116.666667, 2),
["trace_lost", 0, 0, -1, 0, 0, 0, 3]]
result = self.analyze_rows(rows, fps=60, tail="present_real,123")
self.assertEqual(result["lost_trace_samples"], 3)
self.assertTrue(result["truncated_final_row"])
def test_display_limit_is_reported_separately_from_cpu_signals(self):
rows = [self.present(100, 1), self.present(116.666667, 2),
["display_limited", 116_000_000, 2, -1, 0, 0, 16_666_667, 1]]
result = self.analyze_rows(rows)
self.assertIn("display_refresh_limited", result["all_presentations"]["signals"])
def test_average_fps_does_not_hide_alternating_intervals(self):
rows = [self.present(100, 1), self.present(115, 2, False),
self.present(116.666667, 2), self.present(131.666667, 3, False),
self.present(133.333333, 3)]
result = self.analyze_rows(rows)
self.assertEqual(result["real"]["stutters"], 0)
self.assertEqual(result["all_presentations"]["stutters"], 2)
transitions = result["all_presentations"]["transitions"]
self.assertAlmostEqual(transitions["present_real->present_interp"]["median_ms"], 15)
self.assertAlmostEqual(transitions["present_interp->present_real"]["median_ms"], 1000 / 600, places=5)
def test_low_transform_coverage_is_visible_even_with_regular_presents(self):
rows = [self.present(100, 1), self.present(108.333333, 2, False),
self.present(116.666667, 2),
["interp_matches", 116_000_000, 2, -1, 0, 0, 0, (100 << 32) | 20]]
result = self.analyze_rows(rows)
self.assertEqual(result["all_presentations"]["stutters"], 0)
self.assertEqual(result["transform_matches"]["low_match_frames"], 1)
self.assertEqual(result["transform_matches"]["median_percent"], 20)
def test_healthy_60hz_display_requested_at_120_is_not_a_stutter_regression(self):
result = self.analyze_rows(self.display_limited_rows())
self.assertEqual(result["real"]["stutters"], 0)
self.assertGreater(result["all_presentations"]["stutters"], 0)
self.assertEqual(result["all_presentations"]["effective_stutters"], 0)
self.assertEqual(result["all_presentations"]["effective_signals"], {})
self.assertGreater(result["all_presentations"]["display_limited_gaps"], 0)
self.assertEqual(result["interpolation"]["status"], "display_limited")
self.assertTrue(result["interpolation"]["display_limited"])
self.assertFalse(result["interpolation"]["target_valid"])
self.assertEqual(result["isolated_drops"], 0)
def test_real_gap_on_display_limited_stream_is_still_a_stutter(self):
rows = self.display_limited_rows(10)
rows += [self.present(100 + 9 * 1000 / 60 + 50, 11), self.limited(100 + 9 * 1000 / 60 + 50, 11)]
result = self.analyze_rows(rows)
self.assertEqual(result["all_presentations"]["effective_stutters"], 1)
self.assertEqual(result["real"]["stutters"], 1)
def test_isolated_drop_late_survives_healthy_cadence(self):
rows = [self.present(100 + i * 1000 / 120, i // 2, i % 2 == 1) for i in range(40)]
rows.append(["drop_late", 150_000_000, 5, 0, 0, 149_000_000, 8_333_333, 0])
result = self.analyze_rows(rows)
self.assertEqual(result["all_presentations"]["effective_stutters"], 0)
self.assertEqual(result["isolated_drops"], 1)
self.assertEqual(result["isolated_drop_examples"][0]["event"], "drop_late")
def test_display_and_present_failures_map_to_signals_in_the_gap_window(self):
rows = [self.present(100, 1), self.present(150, 4),
["drop_display", 110_000_000, 2, 0, 0, 0, 0, 0],
["present_unavailable", 120_000_000, 3, -1, 0, 0, 0, 0],
["present_failed", 130_000_000, 3, -1, 0, 0, 0, 0]]
result = self.analyze_rows(rows, fps=60)
signals = result["all_presentations"]["effective_signals"]
for name in ("display_slot_dropped", "present_unavailable", "present_failed"):
self.assertIn(name, signals)
self.assertEqual(result["isolated_drops"], 0)
def test_first_transform_task_anchors_warmup(self):
rows = [self.present(100 + i * 1000 / 60, i) for i in range(600)]
rows += [["interp_uniforms", 200_000_000, 0, -1, 1000, 0, 0, 0],
["interp_uniforms", 5_000_000_000, 0, -1, 1000, 0, 0, 12]]
result = self.analyze_rows(rows, warmup=1, anchor="first_transform")
self.assertEqual(result["timing_anchor"]["used"], "first_transform")
self.assertEqual(result["timing_anchor"]["start_ns"], 5_000_000_000)
self.assertLess(result["measured_seconds"], 4.2)
def test_missing_transform_falls_back_and_is_flagged(self):
rows = [self.present(100 + i * 1000 / 120, i // 2, i % 2 == 1) for i in range(40)]
result = self.analyze_rows(rows)
self.assertEqual(result["timing_anchor"]["used"], "first_real_present")
self.assertFalse(result["timing_anchor"]["found"])
self.assertEqual(result["interpolation"]["status"], "no_transform_work")
self.assertTrue(self.analyze_rows(rows, anchor="first_row")["timing_anchor"]["found"])
def test_benchmark_ready_anchors_auto_for_every_fps(self):
rows = [self.present(100 + i * 1000 / 60, i) for i in range(600)]
rows += [self.ready(3000), ["interp_uniforms", 5_000_000_000, 0, -1, 1000, 0, 0, 12]]
for fps in (60, 120, 180):
with self.subTest(fps=fps):
timing = self.analyze_rows(rows, fps=fps, warmup=1)["timing_anchor"]
self.assertEqual((timing["used"], timing["start_ns"], timing["found"]),
("benchmark_ready", 3_000_000_000, True))
def test_explicit_anchors_still_select_their_event(self):
rows = [self.present(100 + i * 1000 / 60, i) for i in range(600)]
rows += [self.ready(3000), ["interp_uniforms", 5_000_000_000, 0, -1, 1000, 0, 0, 12]]
for anchor, start in (("first_transform", 5_000_000_000), ("first_row", 100_000_000),
("first_real_present", 100_000_000), ("benchmark_ready", 3_000_000_000)):
with self.subTest(anchor=anchor):
timing = self.analyze_rows(rows, warmup=1, anchor=anchor)["timing_anchor"]
self.assertEqual((timing["used"], timing["start_ns"], timing["found"]), (anchor, start, True))
def test_missing_benchmark_ready_falls_back_for_legacy_traces(self):
rows = [self.present(100 + i * 1000 / 60, i) for i in range(20)]
for anchor in ("auto", "benchmark_ready"):
with self.subTest(anchor=anchor):
timing = self.analyze_rows(rows, fps=60, anchor=anchor)["timing_anchor"]
self.assertEqual((timing["used"], timing["found"]), ("first_real_present", False))
def test_transform_work_status_does_not_depend_on_the_anchor(self):
rows = [self.ready(90), *[self.present(100 + i * 1000 / 120, i // 2, i % 2 == 1) for i in range(40)]]
self.assertEqual(self.analyze_rows(rows)["interpolation"]["status"], "no_transform_work")
rows.append(["interp_uniforms", 150_000_000, 0, -1, 1000, 0, 0, 12])
result = self.analyze_rows(rows)
self.assertEqual(result["timing_anchor"]["used"], "benchmark_ready")
self.assertEqual(result["interpolation"]["status"], "ok")
def test_native_period_comes_from_seal_intervals_not_present_gaps(self):
result = self.analyze_rows(self.pal_rows([17, 23, 19, 22, 18, 21]), fps=60)
self.assertEqual(result["native_period_ms"], 20)
self.assertEqual(result["native_period_source"], "seal")
self.assertEqual(result["real"]["budget_ms"], 20)
self.assertEqual(self.analyze_rows([self.present(100, 1)], fps=60)["native_period_source"], "default_60hz")
def test_pal_cadence_is_healthy_and_a_late_frame_is_not(self):
result = self.analyze_rows(self.pal_rows([20] * 20), fps=60)
self.assertEqual(result["real"]["stutters"], 0)
rows = self.pal_rows([20] * 10 + [45] + [20] * 10)
rows.append(["game_busy", 330_000_000, 12, -1, 19_000_000, 0, 20_000_000, 1_000_000])
late = self.analyze_rows(rows, fps=60)["real"]
self.assertEqual(late["stutters"], 1)
self.assertEqual(late["worst"][0]["gap_ms"], 45)
self.assertEqual(late["signals"], {"unattributed": 1})
def test_pal_budgets_scale_stage_thresholds(self):
rows = self.pal_rows([20, 45])
rows += [["game_busy", 164_000_000, 3, -1, 30_000_000, 0, 0, 0],
["produce", 160_000_000, 3, -1, 22_000_000, 0, 0, 0]]
signals = self.analyze_rows(rows, fps=60)["real"]["signals"]
self.assertIn("game_thread_busy_over_budget", signals)
self.assertNotIn("producer_wall_time", signals)
def test_pal_display_limit_without_interval_uses_the_native_period(self):
rows = []
for i in range(40):
at = 100 + i * 20
rows += [self.present(at, i + 1), self.seal(at - 1, i + 1, 20_000_000),
["display_limited", at * 1_000_000, i + 1, -1, 0, 0, 0, 1]]
result = self.analyze_rows(rows, fps=120)
self.assertEqual(result["all_presentations"]["budget_ms"], 1000 / 120)
self.assertEqual(result["all_presentations"]["effective_budget_ms"], 20)
self.assertEqual(result["all_presentations"]["effective_stutters"], 0)
self.assertEqual(result["interpolation"]["status"], "display_limited")
def test_oversized_csv_field_is_a_line_numbered_format_error(self):
text = HEADER + "present_real,1,1,0,0,0,0,0\n" + "present_real,2,2,0,0,0,0," + "9" * 200_000 + "\n"
with self.assertRaisesRegex(TraceFormatError, r"line 3.*field larger"):
self.analyze_text(text)
def test_overlong_integer_is_a_line_numbered_format_error(self):
with self.assertRaisesRegex(TraceFormatError, r"line 2.*'detail' is not an integer"):
self.analyze_text(HEADER + "present_real,1,1,0,0,0,0," + "9" * 5000 + "\n")
def test_invalid_arguments_are_rejected(self):
rows = [self.present(100, 1)]
for options in ({"warmup": float("nan")}, {"warmup": -1}, {"fps": 0}, {"fps": 60.5}):
with self.assertRaises(ValueError, msg=str(options)):
self.analyze_rows(rows, **{"fps": 60, **options})
with self.assertRaises(ValueError):
self.analyze_rows(rows, anchor="bogus")
def test_header_problems_name_line_one(self):
cases = {
"duplicate": (HEADER.strip() + ",detail\n", r"line 1.*duplicate columns \['detail'\]"),
"missing": (HEADER.replace(",detail", ""), r"line 1.*missing columns \['detail'\]"),
"foreign": ("a,b,c\n1,2,3\n", r"line 1.*missing columns.*unexpected columns \['a', 'b', 'c'\]"),
}
for name, (text, pattern) in cases.items():
with self.subTest(name), self.assertRaisesRegex(TraceFormatError, pattern):
self.analyze_text(text)
def test_row_problems_name_the_physical_line(self):
cases = {
"non-integer": ("present_real,1,1,0,0,0,0,0\npresent_real,x,2,0,0,0,0,0\npresent_real,3,3,0,0,0,0,0\n",
r"line 3.*'timestamp_ns'.*'x'"),
"short": ("present_real,1,1\npresent_real,2,2,0,0,0,0,0\n", r"line 2.*expected 8 fields, got 3"),
"negative": ("present_real,1,1,0,-5,0,0,0\npresent_real,2,2,0,0,0,0,0\n",
r"line 2.*'duration_ns'.*negative"),
"blank": ("present_real,1,1,0,0,0,0,0\n\npresent_real,2,2,0,0,0,0,0\n", r"line 3.*got 0"),
"empty event": (",1,1,0,0,0,0,0\n", r"line 2.*empty event"),
}
for name, (rows, pattern) in cases.items():
with self.subTest(name), self.assertRaisesRegex(TraceFormatError, pattern):
self.analyze_text(HEADER + rows)
def test_trace_without_timing_samples_is_rejected(self):
for name, text in {"lost only": HEADER + "trace_lost,0,0,-1,0,0,0,3\n", "header only": HEADER}.items():
with self.subTest(name), self.assertRaisesRegex(TraceFormatError, "no timing samples"):
self.analyze_text(text)
for name, text in {"empty": "", "partial header": "event,timestamp_ns"}.items():
with self.subTest(name), self.assertRaisesRegex(TraceFormatError, "no complete header"):
self.analyze_text(text)
def test_truncated_final_row_is_ignored_even_when_malformed(self):
result = self.analyze_text(HEADER + "present_real,100,1,0,0,0,0,0\npresent_real,2x")
self.assertTrue(result["truncated_final_row"])
self.assertEqual(result["events"]["present_real"], 1)
def test_trace_state_reports_readiness(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "trace.csv"
path.write_bytes(b"event,timestamp_ns")
self.assertIsNone(trace_state(path))
path.write_bytes((HEADER + "present_real,5,1,0,0,0,0,0\ninterp_uniforms,9,0,-1,1,0,0,0\n"
"interp_uniforms,11,0,-1,1,0,0,4\ndisplay_limited,12,1,-1,0,0,0,1\n"
"present_real,13").encode())
self.assertEqual(trace_state(path), {"benchmark_ready_ns": None, "real_present_ns": 5,
"transform_ns": 11, "display_limited": True})
path.write_bytes((HEADER + "present_real,5,1,0,0,0,0,0\nbenchmark_ready,7,0,-1,0,0,0,0\n"
"benchmark_ready,8,0,-1,0,0,0,0\n").encode())
self.assertEqual(trace_state(path)["benchmark_ready_ns"], 7)
if __name__ == "__main__":
unittest.main()
-209
View File
@@ -1,209 +0,0 @@
from pathlib import Path
from contextlib import nullcontext
import io
import os
import subprocess
import tempfile
import tomllib
import unittest
from unittest import mock
from types import SimpleNamespace
import benchmark_stutters
from benchmark_stutters import (ConfigError, SourceConfig, isolated_config_text, load_source_config,
interpolation_helpers, parse_helper, parse_list, quoted, stop_process,
wait_ready)
from analyze_stutters import COLUMNS
HEADER = ",".join(COLUMNS) + "\n"
class QuotedTests(unittest.TestCase):
def test_unicode_and_control_characters_roundtrip_through_toml(self):
for value in ["C:\\Users\\Jürgen\\日本語\\Wii", 'a"b', "tab\there", "del\x7fchar", "\u2028 end", ""]:
with self.subTest(value=value):
self.assertEqual(tomllib.loads(f"x = {quoted(value)}")["x"], value)
def test_isolated_config_roundtrips_paths_and_video_settings(self):
source = SourceConfig(Path("C:/Spiele/Mario Kart é"), Path("C:/RR/日本"), None, "vulkan")
text = isolated_config_text(source, Path("C:/Out/ünï/NAND"), 120, 1.5, True)
config = tomllib.loads(text)
self.assertEqual(config["paths"]["dvd_root"], str(source.dvd_root))
self.assertEqual(config["paths"]["retro_rewind_root"], str(source.retro_rewind_root))
self.assertEqual(config["paths"]["nand_root"], str(Path("C:/Out/ünï/NAND")))
self.assertEqual(config["video"]["frame_interpolation_fps"], 120)
self.assertEqual(config["video"]["resolution_multiplier"], 1.5)
self.assertEqual(config["video"]["graphics_api"], "vulkan")
self.assertTrue(config["video"]["vsync"])
self.assertEqual(config["network"], {"enabled": False})
def test_optional_paths_are_omitted(self):
config = tomllib.loads(isolated_config_text(SourceConfig(None, None, None, "auto"), Path("N"), 0, 1, False))
self.assertEqual(config["paths"], {"nand_root": "N"})
class ParseListTests(unittest.TestCase):
def test_valid_lists_keep_order(self):
self.assertEqual(parse_list("4, 0,2", "cpus", int), [4, 0, 2])
self.assertEqual(parse_list("auto,2", "helpers", parse_helper, ("auto", "2")), ["auto", "2"])
def test_rejects_duplicates_including_equivalent_spellings(self):
for text, parse in [("0,0", int), ("1,01", int)]:
with self.subTest(text), self.assertRaisesRegex(ValueError, "duplicate"):
parse_list(text, "cpus", parse)
with self.assertRaisesRegex(ValueError, "duplicate"):
parse_list("auto,0,00", "helpers", parse_helper, ("auto", "0"))
def test_rejects_malformed_and_unsupported_entries(self):
for text in ("1,", "", "a"):
with self.subTest(text), self.assertRaisesRegex(ValueError, "invalid entry"):
parse_list(text, "cpus", int)
with self.assertRaisesRegex(ValueError, "unsupported"):
parse_list("0,60", "fps", int, (0, 120, 180))
with self.assertRaisesRegex(ValueError, "unsupported"):
parse_list("7", "helpers", parse_helper, ("auto", "0"))
class SourceConfigTests(unittest.TestCase):
def load(self, text, create=()):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for name in create:
(root / name).mkdir()
path = root / "Config.toml"
path.write_text(text.replace("{root}", quoted(root)[1:-1]), encoding="utf-8")
return load_source_config(path), root
def test_relative_paths_resolve_against_the_config_directory(self):
source, root = self.load('[paths]\ndvd_root = "game"\nnand_root = "nand"\n', create=("game", "nand"))
self.assertEqual(source.dvd_root, (root / "game").resolve())
self.assertEqual(source.nand_root, (root / "nand").resolve())
self.assertEqual(source.graphics_api, "auto")
def test_unicode_directory_names_roundtrip(self):
name = "Wii é日本"
source, root = self.load('[paths]\ndvd_root = "{root}\\\\' + name + '"\n', create=(name,))
self.assertEqual(source.dvd_root, root / name)
def test_empty_and_missing_values_are_unset(self):
source, _ = self.load('[paths]\ndvd_root = ""\n')
self.assertIsNone(source.dvd_root)
self.assertIsNone(source.nand_root)
def test_odd_config_types_and_values_are_rejected(self):
cases = {
"paths not table": ('paths = "x"\n', "\\[paths\\] must be a table"),
"video not table": ("video = 3\n", "\\[video\\] must be a table"),
"path not string": ("[paths]\ndvd_root = 5\n", "paths.dvd_root must be a string"),
"path list": ('[paths]\nnand_root = ["a"]\n', "paths.nand_root must be a string"),
"missing path": ('[paths]\ndvd_root = "nope"\n', "does not exist"),
"api not string": ("[video]\ngraphics_api = 3\n", "graphics_api"),
"api unsupported": ('[video]\ngraphics_api = "dx11"\n', "graphics_api"),
"invalid toml": ("[paths\n", "invalid TOML"),
}
for name, (text, pattern) in cases.items():
with self.subTest(name), self.assertRaisesRegex(ConfigError, pattern):
self.load(text)
def test_nand_root_must_be_a_directory(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "nand").write_text("file")
(root / "Config.toml").write_text('[paths]\nnand_root = "nand"\n')
with self.assertRaisesRegex(ConfigError, "not a directory"):
load_source_config(root / "Config.toml")
def test_missing_config_file_is_a_config_error(self):
with tempfile.TemporaryDirectory() as directory, self.assertRaisesRegex(ConfigError, "cannot read config"):
load_source_config(Path(directory) / "missing.toml")
class HelperCountTests(unittest.TestCase):
def test_limited_runs_use_the_requested_cpu_capacity(self):
for count, expected in ((1, 0), (2, 0), (3, 2), (4, 3), (12, 6)):
with self.subTest(count=count):
self.assertEqual(interpolation_helpers(count, "auto"), expected)
def test_full_cpu_runs_keep_the_runtime_default(self):
self.assertIsNone(interpolation_helpers(0, "auto"))
def test_explicit_helper_controls_are_preserved(self):
self.assertEqual(interpolation_helpers(4, "6"), 6)
self.assertEqual(interpolation_helpers(4, "0"), 0)
class WaitReadyTests(unittest.TestCase):
@staticmethod
def running():
return mock.Mock(**{"poll.return_value": None})
def test_requires_benchmark_ready_regardless_of_other_events(self):
with tempfile.TemporaryDirectory() as directory:
trace = Path(directory) / "trace.csv"
trace.write_text(HEADER + "present_real,5,1,0,0,0,0,0\ninterp_uniforms,9,0,-1,1,0,0,4\n")
with mock.patch.object(benchmark_stutters, "STARTUP_TIMEOUT", 0), \
self.assertRaisesRegex(RuntimeError, "no benchmark_ready"):
wait_ready(self.running(), trace, "case")
trace.write_text(HEADER + "benchmark_ready,7,0,-1,0,0,0,0\n")
self.assertEqual(wait_ready(self.running(), trace, "case")["benchmark_ready_ns"], 7)
class StopProcessTests(unittest.TestCase):
def test_kills_after_terminate_times_out(self):
process = mock.Mock(**{"poll.return_value": None})
process.wait.side_effect = [subprocess.TimeoutExpired("game", 10), 0]
stop_process(process, "case")
process.terminate.assert_called_once()
process.kill.assert_called_once()
def test_unkillable_process_is_a_clear_failure(self):
process = mock.Mock(**{"poll.return_value": None})
process.wait.side_effect = subprocess.TimeoutExpired("game", 10)
with self.assertRaisesRegex(RuntimeError, "did not exit after terminate and kill"):
stop_process(process, "case")
class TraceCompletenessTests(unittest.TestCase):
def run_case(self, tail, incomplete):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
exe, config, output = root / "game.exe", root / "Config.toml", root / "out"
exe.write_bytes(b"")
config.write_text("")
trace = HEADER + "benchmark_ready,1,0,-1,0,0,0,0\n"
trace += "".join(f"present_real,{1 + i * 16_666_667},{i + 1},0,0,0,16666667,0\n"
for i in range(80)) + tail
process = mock.Mock(**{"poll.return_value": None})
process.wait.side_effect = [subprocess.TimeoutExpired("game", 1), 0]
def launch(*args, env, **kwargs):
Path(env["MKW_FRAME_TRACE"]).write_text(trace)
return process
arguments = ["benchmark", "--exe", str(exe), "--config", str(config), "--output", str(output),
"--cpus", "1", "--fps", "0", "--helpers", "0", "--warmup", "0", "--seconds", "1"]
platform = SimpleNamespace(name="nt", environ=os.environ, cpu_count=os.cpu_count)
expectation = self.assertRaisesRegex(RuntimeError, "incomplete trace") if incomplete else nullcontext()
with mock.patch("sys.argv", arguments), mock.patch("sys.stdout", new_callable=io.StringIO), \
mock.patch.object(benchmark_stutters, "os", platform), \
mock.patch.object(benchmark_stutters, "cpu_topology",
return_value=(mock.Mock(), 1, 1, [1])), \
mock.patch.object(subprocess, "CREATE_NO_WINDOW", 0, create=True), \
mock.patch.object(subprocess, "Popen", side_effect=launch), expectation:
benchmark_stutters.main()
process.terminate.assert_called_once()
self.assertEqual((output / "summary.json").exists(), not incomplete)
self.assertEqual((output / "r0-cpu1-fps0-helpers0.json").exists(), not incomplete)
def test_complete_trace_writes_results(self):
self.run_case("", incomplete=False)
def test_incomplete_traces_do_not_write_results(self):
for tail in ("trace_lost,0,0,-1,0,0,0,3\n", "present_real,",
"trace_lost,0,0,-1,0,0,0,3\npresent_real,"):
with self.subTest(tail=tail):
self.run_case(tail, incomplete=True)
if __name__ == "__main__":
unittest.main()