mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-10-30 08:39:38 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37a0b68d85 | |||
| b748155cdb | |||
| eb99d8652b | |||
| ee8443423d | |||
| 3645c4af1f | |||
| ae54b6e082 | |||
| af5a45e270 | |||
| 896dcef9e3 |
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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?**
|
||||
|
||||
+1
-1
Submodule aurora-main updated: 2edf07b954...33067e34a1
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user