Implement log export functionality and OpenXR diagnostics

This commit is contained in:
iChris4 committed 2026-09-17 02:05:12 +02:00
1 parent 19485b9e8b
commit f222eedf6b
14 files changed
+1939 -12

No files matched your search

+65
View File
@@ -317,6 +317,71 @@ Kart Wii's minimap are treated as game art and remain eligible for the screen. A
uses the full eye viewport and scissor because its recorded rectangle no longer describes where it uses the full eye viewport and scissor because its recorded rectangle no longer describes where it
ended up; its original viewport is folded into the projection instead. ended up; its original viewport is folded into the projection instead.
## Diagnostics
**F10 > Diagnostics** holds two bug-report aids.
**OpenXR diagnostic logging** is off by default. When it is off, each hook on the pacing thread is
one atomic test. It applies immediately and is remembered as:
```toml
[diagnostics]
openxr_logging = false
```
When it is on, `console.log` receives lines tagged `[runtime] [xr-diag]`
(`runtime/src/vr/openxr_diagnostics.cpp`). They cover both the D3D12 and the Vulkan backend.
- **Session description.** Written when logging starts and again for every new OpenXR session. It
gives the runtime and system names and versions, vendor id, tracking support, backend, reference
space, blend mode, enabled extensions, recommended and maximum eye sizes, `render_scale`, swapchain
sizes, display period, and the VR frame interpolation setting.
- **View geometry.** Written on the first located views and again whenever they change by more
than 0.5° or 0.5 mm. It gives per-eye FOV half-angles, the eye cant (the angle between the two
eyes' forward axes: 0 for parallel displays, non-zero for canted ones such as Pimax without
parallel projections), and the IPD.
- **A one-second summary.** Timings are `median/worst` in milliseconds; for `end-margin`, worst is
the minimum.
| Field | Meaning |
| --- | --- |
| `Hz`, `cycles` | Display rate from the predicted display period; compositor cycles (xrWaitFrame/xrEndFrame pairs, repeats included). |
| `skipped-slots` | Display slots the predicted display time jumped over: the runtime throttled or dropped frames. |
| `late` | Frames whose xrEndFrame came after their predicted display time (needs `XR_KHR_win32_convert_performance_counter_time` or `XR_KHR_convert_timespec_time`). |
| `layers new/repeat/empty` | Cycles ending with a newly rendered layer, the retained layer again, or no layer at all (black). |
| `discarded`, `layer-rejected` | Retained layers dropped by a session or reference-space change; rendered layers not submitted (invalid pose or views, failed release). |
| `wait-frame`, `open`, `end-call` | Time blocked in xrWaitFrame, from xrBeginFrame to xrEndFrame, and inside xrEndFrame. |
| `end-margin`, `end-gap` | Predicted display time minus the xrEndFrame time; interval between xrEndFrame calls. |
| `pickup`, `render` | Stereo packet published until Aurora's frame worker takes it (without interpolation this includes waiting for the next 60 Hz game frame); taken until the eye copy is submitted. |
| `acquire`, `release` | Swapchain image acquire+wait and release. |
| `keepalive` | Retained-layer repeats while Aurora was still encoding past the 50 ms keep-alive. |
| `packet-unused`, `packet-rejected`, `submit-failed` | Packets no game frame took within 50 ms; packets Aurora took but rendered mono (content tag or transform check); failed stereo copies. |
| `interp-skip` | Cycles the VR interpolation rate cap chose not to render. |
| `frames immersive/screen` | Cycles per presentation mode; `not-rendered` counts cycles without views to render. |
| `no-orientation`, `no-position` | Cycles whose head orientation or position was not valid. |
| `suppressed` | Event lines dropped by the rate limit. |
- **Event lines.** At most 8 per second; the rest are counted in `suppressed`. They report late
frames, skipped display slots, stalls (more than 2.5 display periods, and at least 25 ms, between
xrEndFrame calls), empty frames and their reason, discarded retained layers, rejected layers,
withdrawn or rejected packets, failed submissions, and head-tracking loss and recovery.
Reference-space change events are never rate-limited.
- **Presentation changes.** While logging is on, every `[mkw-vr] presentation=` transition is
logged, not just the first 16.
**Export Logs** opens the system folder picker. It then creates a
`WiiCompiled-logs-YYYYMMDD-HHMMSS` folder at the chosen location, containing:
- `Logs/`: every retained run folder, the current session included. The runtime prunes run
folders after four days.
- `Config.toml`.
- `export-info.txt`: the export time, the exporting process id (whose run folder ends in `_pid<id>`),
and the OpenXR state.
The current `console.log` is copied through a shared-read stream while it is still being written.
The copy runs on SDL's dialog thread (`runtime/src/log_export.cpp`), and the outcome is shown under
the button. `mkw_openxr_diagnostics_tests` and `mkw_log_export_tests` cover both without a headset.
## Backend status ## Backend status
| Backend | Status | | Backend | Status |
+17 -1
View File
@@ -418,9 +418,25 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
endif() endif()
add_test(NAME mkw_vr_policy_tests COMMAND mkw_vr_policy_tests) add_test(NAME mkw_vr_policy_tests COMMAND mkw_vr_policy_tests)
# F10 > Diagnostics: the OpenXR frame-diagnostics collector is free of OpenXR
# and guest state, so its window accounting runs on a synthetic clock here.
add_executable(mkw_openxr_diagnostics_tests tests/openxr_diagnostics_tests.cpp src/vr/openxr_diagnostics.cpp)
target_include_directories(mkw_openxr_diagnostics_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_link_libraries(mkw_openxr_diagnostics_tests PRIVATE mkw::toml11)
target_compile_features(mkw_openxr_diagnostics_tests PRIVATE cxx_std_20)
set_target_properties(mkw_openxr_diagnostics_tests PROPERTIES UNITY_BUILD OFF)
add_test(NAME mkw_openxr_diagnostics_tests COMMAND mkw_openxr_diagnostics_tests)
# F10 > Diagnostics > Export Logs, against temporary folders.
add_executable(mkw_log_export_tests tests/log_export_tests.cpp src/log_export.cpp)
target_include_directories(mkw_log_export_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_log_export_tests PRIVATE cxx_std_20)
set_target_properties(mkw_log_export_tests PROPERTIES UNITY_BUILD OFF)
add_test(NAME mkw_log_export_tests COMMAND mkw_log_export_tests)
if(MKW_ENABLE_OPENXR AND MKW_PLATFORM_WINDOWS) if(MKW_ENABLE_OPENXR AND MKW_PLATFORM_WINDOWS)
add_executable(mkw_openxr_replay_tests add_executable(mkw_openxr_replay_tests
tests/openxr_d3d12_replay_tests.cpp src/vr/openxr_d3d12.cpp) tests/openxr_d3d12_replay_tests.cpp src/vr/openxr_d3d12.cpp src/vr/openxr_diagnostics.cpp)
target_include_directories(mkw_openxr_replay_tests PRIVATE target_include_directories(mkw_openxr_replay_tests PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include" "${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include" "${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include"
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <chrono>
#include <cstddef>
#include <filesystem>
#include <string>
#include <string_view>
// F10 > Diagnostics > Export Logs: gathers what a bug report needs into one
// folder the player chose, ready to zip and attach.
namespace log_export {
inline constexpr std::string_view kExportFolderPrefix = "WiiCompiled-logs-";
struct Result {
// The folder created for this export; empty when it could not be created.
std::filesystem::path destination;
size_t files_copied = 0;
size_t files_failed = 0;
// The first problem met, suitable for display.
std::string error;
bool Succeeded() const { return !destination.empty() && files_failed == 0 && error.empty(); }
};
// Creates "WiiCompiled-logs-YYYYMMDD-HHMMSS" (local time, with a numeric suffix
// if that name exists) inside `parent` and copies into it:
// Logs/ the whole run-log tree, one folder per run, current run included
// Config.toml the player's configuration
// export-info.txt the export time followed by `note`
// A missing logs directory or config file is skipped, not an error, as long as
// something was exported.
//
// Files are copied through ordinary shared-read streams rather than a platform
// copy call, so the running session's console.log, which is still open for
// writing, is exported up to its current end.
Result ExportLogs(const std::filesystem::path& logs_directory, const std::filesystem::path& config_file,
const std::filesystem::path& parent, std::string_view note,
std::chrono::system_clock::time_point now);
} // namespace log_export
+13
View File
@@ -70,6 +70,9 @@ struct RuntimeUserConfig {
std::optional<std::string> vrFirstPersonRotation; std::optional<std::string> vrFirstPersonRotation;
std::optional<std::string> vrRecenterKey; std::optional<std::string> vrRecenterKey;
std::optional<float> vrLeanBackDegrees; std::optional<float> vrLeanBackDegrees;
// F10 > Diagnostics: OpenXR pacing and presentation logging in console.log.
// Off unless set; it is a bug-report aid, not something to leave running.
std::optional<bool> diagnosticsOpenXRLogging;
std::optional<float> audioVolume; std::optional<float> audioVolume;
std::optional<float> audioMusicVolume; std::optional<float> audioMusicVolume;
std::optional<float> audioSoundEffectsVolume; std::optional<float> audioSoundEffectsVolume;
@@ -684,6 +687,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
value && *value >= -1 && *value <= 31) { value && *value >= -1 && *value <= 31) {
config.vrFirstPersonHiddenModel = static_cast<int32_t>(*value); config.vrFirstPersonHiddenModel = static_cast<int32_t>(*value);
} }
config.diagnosticsOpenXRLogging = FindConfigValue<bool>(document, "diagnostics", "openxr_logging");
auto readVolume = [&](std::string_view key) -> std::optional<float> { auto readVolume = [&](std::string_view key) -> std::optional<float> {
auto value = FindConfigFloat(document, "audio", key); auto value = FindConfigFloat(document, "audio", key);
@@ -1338,6 +1342,15 @@ inline uint32_t VrFrameInterpolationFps() {
return mkw::vr::NormalizeFrameInterpolationFps(Get().vrFrameInterpolationFps.value_or(0)); return mkw::vr::NormalizeFrameInterpolationFps(Get().vrFrameInterpolationFps.value_or(0));
} }
inline bool DiagnosticsOpenXRLogging(bool fallback = false) {
return Get().diagnosticsOpenXRLogging.value_or(fallback);
}
inline bool SetDiagnosticsOpenXRLogging(bool value) {
Mutable().diagnosticsOpenXRLogging = value;
return WriteSetting("diagnostics", "openxr_logging", value ? "true" : "false");
}
inline std::string VrFirstPersonRotation(std::string fallback = kVrFirstPersonRotationDefault) { inline std::string VrFirstPersonRotation(std::string fallback = kVrFirstPersonRotationDefault) {
const auto& value = Get().vrFirstPersonRotation; const auto& value = Get().vrFirstPersonRotation;
return value && IsSupportedVrFirstPersonRotation(*value) ? *value : std::move(fallback); return value && IsSupportedVrFirstPersonRotation(*value) ? *value : std::move(fallback);
+303
View File
@@ -0,0 +1,303 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <array>
#include <atomic>
#include <cstdint>
#include <functional>
#include <string>
#include <string_view>
#include <vector>
// Opt-in OpenXR pacing and presentation diagnostics (F10 > Diagnostics).
//
// Off by default. Every hook below is an inline test of one atomic that returns
// at once while logging is off, so carrying them costs the XR pacing thread
// nothing measurable. When on, one-second windows are accumulated and written to
// console.log as a "[xr-diag]" summary line, plus rate-limited event lines for
// late, empty or discarded frames. OPENXR.md documents every field.
//
// All hooks except SetEnabled/Enabled and NotePacketConsumed run on the XR
// pacing thread, the collector's only writer. Nothing here includes OpenXR, so
// the settings overlay and the headless tests can use it in any build.
namespace mkw::vr::diagnostics {
enum class EmptyFrameReason : uint8_t {
NoRetainedLayer,
ShouldRenderOff,
};
enum class DiscardReason : uint8_t {
SessionRestarted,
ReferenceSpaceChanged,
};
// Why a layer Aurora finished rendering was not submitted.
enum class RejectReason : uint8_t {
ReleaseFailed,
ShouldRenderOff,
ViewsInvalid,
PositionInvalid,
};
// The backends' submit condition, checked in the same order: release, then
// should_render, then views; a layer passing all three failed on head position.
inline RejectReason ClassifyRejectedLayer(bool release_ok, bool should_render, bool views_valid) noexcept {
if (!release_ok) {
return RejectReason::ReleaseFailed;
}
if (!should_render) {
return RejectReason::ShouldRenderOff;
}
return views_valid ? RejectReason::PositionInvalid : RejectReason::ViewsInvalid;
}
struct ViewGeometry {
// Per eye: left, right, up, down half-angles, in degrees.
std::array<std::array<float, 4>, 2> fov_degrees{};
// Angle between the two eyes' forward axes; non-zero on canted displays.
float cant_degrees = 0.0f;
// Negative when the runtime reported no valid eye positions.
float ipd_millimeters = -1.0f;
std::array<uint32_t, 2> width{};
std::array<uint32_t, 2> height{};
};
// Accumulates one window of frame measurements and formats it. Times are
// steady-clock nanoseconds passed in by the caller, so tests can drive it with
// a synthetic clock. Not synchronized: one thread owns an instance.
class FrameDiagnostics {
public:
using Sink = std::function<void(std::string_view line)>;
static constexpr int64_t kWindowNs = 1'000'000'000;
static constexpr uint32_t kMaxEventsPerWindow = 8;
explicit FrameDiagnostics(Sink sink = {});
void SetSink(Sink sink);
// Starts a fresh window and forgets per-session state (display-time grid,
// tracking, logged geometry), and asks for the session description again.
void Reset(int64_t now_ns);
// True once after each Reset: the owner should describe the session.
bool ConsumeSessionInfoRequest();
// Compositor cycle: xrWaitFrame returned. deadline_ns is the predicted
// display time on the steady clock, or 0 when it cannot be converted.
void OnWaitFrame(int64_t now_ns, int64_t wait_ns, int64_t display_time,
int64_t display_period, int64_t deadline_ns);
void OnBeginFrame(int64_t now_ns);
// xrEndFrame was entered at submit_ns and took call_ns.
void OnEndFrame(int64_t submit_ns, int64_t call_ns);
void OnFrameBegun(bool immersive, bool should_render, bool views_valid,
bool orientation_valid, bool position_valid);
void OnViewGeometry(const ViewGeometry& geometry);
void OnSwapchainAcquire(int64_t ns);
void OnSwapchainRelease(int64_t ns);
void OnLayer(bool fresh);
void OnEmptyFrame(EmptyFrameReason reason);
void OnRetainedLayerDiscarded(DiscardReason reason);
void OnLayerRejected(RejectReason reason);
void OnInterpolationSkip();
void OnPacketPublished(int64_t now_ns);
// consumed_ns is when Aurora took the packet, or 0 if it never did.
void OnPacketCanceled(int64_t consumed_ns);
void OnKeepaliveRepeat();
void OnSubmission(int64_t now_ns, int64_t consumed_ns, bool success);
void OnReferenceSpaceChange(std::string_view space, bool pose_valid, bool effective_known,
double effective_in_ms);
// Unconditional line (session description); not rate-limited.
void Info(std::string_view text);
// Emits the summary when the window has lasted kWindowNs. Called from
// OnEndFrame, so any compositor cycle, repeated or not, flushes it.
void Tick(int64_t now_ns);
private:
struct Samples {
std::vector<float> values;
void Add(double ms);
void Clear() { values.clear(); }
};
void Event(std::string_view text);
void ClearWindow(int64_t now_ns);
void EmitSummary(int64_t now_ns);
Sink sink_;
int64_t window_start_ns_ = 0;
bool session_info_requested_ = true;
int64_t last_display_time_ = 0;
int64_t display_period_ = 0;
int64_t deadline_ns_ = 0;
int64_t begin_ns_ = 0;
int64_t last_submit_ns_ = 0;
int64_t published_ns_ = 0;
bool tracking_known_ = false;
bool orientation_valid_ = false;
bool position_valid_ = false;
bool geometry_logged_ = false;
ViewGeometry geometry_{};
uint32_t cycles_ = 0;
uint32_t skipped_slots_ = 0;
uint32_t late_ = 0;
uint32_t layers_new_ = 0;
uint32_t layers_repeat_ = 0;
uint32_t empty_ = 0;
uint32_t discarded_ = 0;
uint32_t layer_rejected_ = 0;
uint32_t keepalive_ = 0;
uint32_t packet_unused_ = 0;
uint32_t packet_rejected_ = 0;
uint32_t submit_failed_ = 0;
uint32_t interp_skip_ = 0;
uint32_t immersive_ = 0;
uint32_t screen_ = 0;
uint32_t not_rendered_ = 0;
uint32_t no_orientation_ = 0;
uint32_t no_position_ = 0;
uint32_t events_ = 0;
uint32_t suppressed_ = 0;
Samples wait_frame_ms_;
Samples open_ms_;
Samples margin_ms_;
Samples end_gap_ms_;
Samples end_call_ms_;
Samples acquire_ms_;
Samples release_ms_;
Samples game_wait_ms_;
Samples encode_ms_;
};
namespace detail {
inline std::atomic_bool g_enabled{false};
inline std::atomic_int64_t g_packet_consumed_ns{0};
int64_t NowNs() noexcept;
void OnWaitFrame(int64_t wait_ns, int64_t display_time, int64_t display_period);
void OnBeginFrame();
void OnEndFrame(int64_t submit_ns, int64_t call_ns);
void OnFrameBegun(bool immersive, bool should_render, bool views_valid, bool orientation_valid,
bool position_valid);
void OnViewGeometry(const ViewGeometry& geometry);
void OnSwapchainAcquire(int64_t ns);
void OnSwapchainRelease(int64_t ns);
void OnLayer(bool fresh);
void OnEmptyFrame(EmptyFrameReason reason);
void OnRetainedLayerDiscarded(DiscardReason reason);
void OnLayerRejected(RejectReason reason);
void OnInterpolationSkip();
void OnPacketPublished();
void OnPacketCanceled();
void OnKeepaliveRepeat();
void OnSubmission(bool success);
void OnReferenceSpaceChange(std::string_view space, bool pose_valid, int64_t change_time);
void OnSessionStarted();
bool ConsumeSessionInfoRequest();
void Info(std::string_view text);
} // namespace detail
inline bool Enabled() noexcept { return detail::g_enabled.load(std::memory_order_relaxed); }
// Live switch. Turning logging on starts a new window and re-describes the
// session on the next frame.
void SetEnabled(bool enabled) noexcept;
// Where lines go; installed by the OpenXR integration. Set it only while the
// pacing thread is not running.
void SetLogSink(FrameDiagnostics::Sink sink);
// Converts an XrTime to steady-clock nanoseconds, or returns 0. Set it only
// while the pacing thread is not running, and clear it before its runtime dies.
void SetDisplayTimeConverter(std::function<int64_t(int64_t xr_time)> converter);
// Measures a span only when logging was on at its start.
class Stopwatch {
public:
Stopwatch() noexcept : start_ns_(Enabled() ? detail::NowNs() : 0) {}
int64_t StartNs() const noexcept { return start_ns_; }
// -1 when the stopwatch did not start.
int64_t ElapsedNs() const noexcept { return start_ns_ == 0 ? -1 : detail::NowNs() - start_ns_; }
private:
int64_t start_ns_;
};
inline void OnWaitFrame(const Stopwatch& wait, int64_t display_time, int64_t display_period) {
if (const int64_t ns = wait.ElapsedNs(); ns >= 0 && Enabled()) detail::OnWaitFrame(ns, display_time, display_period);
}
inline void OnBeginFrame() {
if (Enabled()) detail::OnBeginFrame();
}
inline void OnEndFrame(const Stopwatch& call) {
if (const int64_t ns = call.ElapsedNs(); ns >= 0 && Enabled()) detail::OnEndFrame(call.StartNs(), ns);
}
inline void OnFrameBegun(bool immersive, bool should_render, bool views_valid, bool orientation_valid,
bool position_valid) {
if (Enabled()) detail::OnFrameBegun(immersive, should_render, views_valid, orientation_valid, position_valid);
}
inline void OnSwapchainAcquire(const Stopwatch& acquire) {
if (const int64_t ns = acquire.ElapsedNs(); ns >= 0 && Enabled()) detail::OnSwapchainAcquire(ns);
}
inline void OnSwapchainRelease(const Stopwatch& release) {
if (const int64_t ns = release.ElapsedNs(); ns >= 0 && Enabled()) detail::OnSwapchainRelease(ns);
}
inline void OnLayer(bool fresh) {
if (Enabled()) detail::OnLayer(fresh);
}
inline void OnEmptyFrame(EmptyFrameReason reason) {
if (Enabled()) detail::OnEmptyFrame(reason);
}
inline void OnRetainedLayerDiscarded(DiscardReason reason) {
if (Enabled()) detail::OnRetainedLayerDiscarded(reason);
}
inline void OnLayerRejected(RejectReason reason) {
if (Enabled()) detail::OnLayerRejected(reason);
}
inline void OnInterpolationSkip() {
if (Enabled()) detail::OnInterpolationSkip();
}
inline void OnPacketPublished() {
if (Enabled()) detail::OnPacketPublished();
}
// Aurora's frame worker took the published packet. Any thread.
inline void NotePacketConsumed() {
if (Enabled()) detail::g_packet_consumed_ns.store(detail::NowNs(), std::memory_order_relaxed);
}
inline void OnPacketCanceled() {
if (Enabled()) detail::OnPacketCanceled();
}
inline void OnKeepaliveRepeat() {
if (Enabled()) detail::OnKeepaliveRepeat();
}
inline void OnSubmission(bool success) {
if (Enabled()) detail::OnSubmission(success);
}
inline void OnReferenceSpaceChange(std::string_view space, bool pose_valid, int64_t change_time) {
if (Enabled()) detail::OnReferenceSpaceChange(space, pose_valid, change_time);
}
inline void OnSessionStarted() {
if (Enabled()) detail::OnSessionStarted();
}
inline void OnViewGeometry(const ViewGeometry& geometry) {
if (Enabled()) detail::OnViewGeometry(geometry);
}
// True once after logging starts or a session begins: describe the session.
inline bool ConsumeSessionInfoRequest() {
return Enabled() && detail::ConsumeSessionInfoRequest();
}
inline void Info(std::string_view text) {
if (Enabled()) detail::Info(text);
}
} // namespace mkw::vr::diagnostics
+153
View File
@@ -0,0 +1,153 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "log_export.h"
#include <ctime>
#include <fstream>
#include <system_error>
#include <vector>
namespace log_export {
namespace {
// UTF-8, like every narrow path string in the runtime.
std::string ExportPathText(const std::filesystem::path& path) {
const std::u8string text = path.u8string();
return std::string(text.begin(), text.end());
}
std::tm ExportLocalTime(std::chrono::system_clock::time_point now) {
const std::time_t seconds = std::chrono::system_clock::to_time_t(now);
std::tm local{};
#if defined(_WIN32)
localtime_s(&local, &seconds);
#else
localtime_r(&seconds, &local);
#endif
return local;
}
std::string FormatExportTime(std::chrono::system_clock::time_point now, const char* format) {
const std::tm local = ExportLocalTime(now);
char buffer[64];
const size_t length = std::strftime(buffer, sizeof(buffer), format, &local);
return std::string(buffer, length);
}
bool CopyExportFile(const std::filesystem::path& source, const std::filesystem::path& destination,
std::string& error) {
std::ifstream input(source, std::ios::binary);
if (!input) {
error = "could not read " + ExportPathText(source);
return false;
}
std::ofstream output(destination, std::ios::binary | std::ios::trunc);
if (!output) {
error = "could not write " + ExportPathText(destination);
return false;
}
// A loop rather than `output << input.rdbuf()`, which flags an empty file
// as a failed insertion.
std::vector<char> buffer(64 * 1024);
while (input) {
input.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));
const std::streamsize count = input.gcount();
if (count > 0) {
output.write(buffer.data(), count);
}
}
if (input.bad() || !output) {
error = "could not copy " + ExportPathText(source);
return false;
}
return true;
}
} // namespace
Result ExportLogs(const std::filesystem::path& logs_directory, const std::filesystem::path& config_file,
const std::filesystem::path& parent, std::string_view note,
std::chrono::system_clock::time_point now) {
Result result;
std::error_code ec;
if (!std::filesystem::is_directory(parent, ec)) {
result.error = "the chosen folder does not exist: " + ExportPathText(parent);
return result;
}
const std::string base = std::string(kExportFolderPrefix) + FormatExportTime(now, "%Y%m%d-%H%M%S");
std::filesystem::path destination = parent / base;
for (int suffix = 2; std::filesystem::exists(destination, ec); ++suffix) {
if (suffix > 99) {
result.error = "too many exports named " + base + " in " + ExportPathText(parent);
return result;
}
destination = parent / (base + "-" + std::to_string(suffix));
}
if (!std::filesystem::create_directories(destination, ec)) {
result.error = "could not create " + ExportPathText(destination) + (ec ? ": " + ec.message() : "");
return result;
}
result.destination = destination;
const auto copy = [&](const std::filesystem::path& from, const std::filesystem::path& to) {
std::string error;
if (CopyExportFile(from, to, error)) {
++result.files_copied;
} else {
++result.files_failed;
if (result.error.empty()) {
result.error = std::move(error);
}
}
};
if (std::filesystem::is_directory(logs_directory, ec)) {
const std::filesystem::path logs_target = destination / "Logs";
std::filesystem::create_directories(logs_target, ec);
// Choosing the Logs folder itself as the destination must not copy the
// export into itself.
const std::filesystem::path own_folder = std::filesystem::weakly_canonical(destination, ec);
std::filesystem::recursive_directory_iterator entry(
logs_directory, std::filesystem::directory_options::skip_permission_denied, ec);
for (; !ec && entry != std::filesystem::recursive_directory_iterator(); entry.increment(ec)) {
const std::filesystem::path target = logs_target / entry->path().lexically_relative(logs_directory);
std::error_code entry_ec;
if (entry->is_directory(entry_ec)) {
if (std::filesystem::weakly_canonical(entry->path(), entry_ec) == own_folder) {
entry.disable_recursion_pending();
continue;
}
std::filesystem::create_directories(target, entry_ec);
continue;
}
if (!entry->is_regular_file(entry_ec)) {
continue;
}
std::filesystem::create_directories(target.parent_path(), entry_ec);
copy(entry->path(), target);
}
if (ec) {
++result.files_failed;
if (result.error.empty()) {
result.error = "could not list " + ExportPathText(logs_directory) + ": " + ec.message();
}
}
}
if (std::filesystem::is_regular_file(config_file, ec)) {
copy(config_file, destination / config_file.filename());
}
std::ofstream info(destination / "export-info.txt", std::ios::binary | std::ios::trunc);
info << "Exported " << FormatExportTime(now, "%Y-%m-%d %H:%M:%S") << " (local time)\n" << note;
if (!note.empty() && note.back() != '\n') {
info << '\n';
}
if (result.files_copied == 0 && result.files_failed == 0) {
result.error = "no logs or configuration were found to export";
}
return result;
}
} // namespace log_export
+157
View File
@@ -4,16 +4,20 @@
#include "controller_mapping_wizard.h" #include "controller_mapping_wizard.h"
#include "input_bindings.h" #include "input_bindings.h"
#include "game_graphics_options.h" #include "game_graphics_options.h"
#include "log_export.h"
#include "music_attenuation.h" #include "music_attenuation.h"
#include "runtime_config.h" #include "runtime_config.h"
#include "runtime_log.h" #include "runtime_log.h"
#include "vr/mkw_vr_first_person.h" #include "vr/mkw_vr_first_person.h"
#include "vr/mkw_vr_policy.h" #include "vr/mkw_vr_policy.h"
#include "vr/openxr_diagnostics.h"
#include "vr/openxr_integration.h" #include "vr/openxr_integration.h"
#include "vr/openxr_wii_remote.h" #include "vr/openxr_wii_remote.h"
#include "wii_remote_input.h" #include "wii_remote_input.h"
#include <imgui.h> #include <imgui.h>
#include <SDL3/SDL_dialog.h>
#include <SDL3/SDL_error.h>
#include <SDL3/SDL_events.h> #include <SDL3/SDL_events.h>
#include <SDL3/SDL_gamepad.h> #include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_keyboard.h> #include <SDL3/SDL_keyboard.h>
@@ -27,8 +31,11 @@
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <exception>
#include <limits> #include <limits>
#include <iostream> #include <iostream>
#include <mutex>
#include <sstream>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <utility> #include <utility>
@@ -37,6 +44,8 @@
#define WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN
#include <Windows.h> #include <Windows.h>
#include <shellapi.h> #include <shellapi.h>
#else
#include <unistd.h>
#endif #endif
#include <dolphin/pad.h> #include <dolphin/pad.h>
@@ -126,6 +135,7 @@ int g_vrFrameInterpolationMode = [] {
kVrInterpolationFps.begin()); kVrInterpolationFps.begin());
}(); }();
int g_vrFirstPersonHiddenModel = RuntimeConfigFile::VrFirstPersonHiddenModel(); int g_vrFirstPersonHiddenModel = RuntimeConfigFile::VrFirstPersonHiddenModel();
bool g_openxrDiagnosticsLogging = RuntimeConfigFile::DiagnosticsOpenXRLogging(false);
// Config spellings and menu labels for the desktop mirror, index-matched to // Config spellings and menu labels for the desktop mirror, index-matched to
// AuroraStereoMirrorView so the combo selection converts to either directly. // AuroraStereoMirrorView so the combo selection converts to either directly.
constexpr std::array<const char*, 5> kVrMirrorViewNames{"normal", "both", "left", "right", "none"}; constexpr std::array<const char*, 5> kVrMirrorViewNames{"normal", "both", "left", "right", "none"};
@@ -1196,6 +1206,147 @@ void DrawVrSettings() {
} }
} }
// Export Logs. SDL shows the folder picker without blocking the game and calls
// back on a thread of its choosing (its own dialog thread on Windows), where the
// copy then runs; the menu only reads the outcome through this state.
struct LogExportState {
std::mutex mutex;
std::string note;
std::string message;
bool failed = false;
};
LogExportState g_logExport;
std::atomic_bool g_logExportInProgress{false};
void SetLogExportMessage(std::string message, bool failed) {
std::lock_guard lock(g_logExport.mutex);
g_logExport.message = std::move(message);
g_logExport.failed = failed;
}
// Captured on the click, so the export describes the moment the player asked.
std::string BuildLogExportNote() {
#if defined(_WIN32)
const unsigned long pid = ::GetCurrentProcessId();
#else
const auto pid = static_cast<unsigned long>(::getpid());
#endif
std::ostringstream note;
note << "Exported by process " << pid << "; its run folder under Logs ends in _pid" << pid << ".\n"
<< "OpenXR diagnostic logging: " << (g_openxrDiagnosticsLogging ? "on" : "off") << '\n'
<< "OpenXR running: " << (mkw::vr::OpenXRIsRunning() ? "yes" : "no") << '\n';
if (const auto xrError = mkw::vr::OpenXRLastError(); !xrError.empty()) {
note << "OpenXR last error: " << xrError << '\n';
}
return note.str();
}
void SDLCALL OnLogExportFolderChosen(void*, const char* const* filelist, int) {
// SDL's C caller must never see an exception.
try {
if (filelist == nullptr) {
SetLogExportMessage(std::string("Could not open the folder picker: ") + SDL_GetError(), true);
} else if (filelist[0] == nullptr) {
SetLogExportMessage("Export canceled.", false);
} else {
std::string note;
{
std::lock_guard lock(g_logExport.mutex);
note = g_logExport.note;
}
SetLogExportMessage("Exporting...", false);
const auto result = log_export::ExportLogs(
RuntimeConfigFile::ApplicationDataDirectory() / "Logs", RuntimeConfigFile::ResolveConfigPath(),
RuntimeConfigFile::PathFromUtf8(filelist[0]), note, std::chrono::system_clock::now());
const std::string destination = RuntimeConfigFile::PathToUtf8(result.destination);
if (result.Succeeded()) {
SetLogExportMessage("Exported " + std::to_string(result.files_copied) + " files to " + destination,
false);
} else if (!result.destination.empty()) {
SetLogExportMessage("Exported " + std::to_string(result.files_copied) + " files to " + destination +
", but " + std::to_string(result.files_failed) +
" could not be copied: " + result.error,
true);
} else {
SetLogExportMessage("Export failed: " + result.error, true);
}
RT_LOG(RT_TAG_RUNTIME) << "Log export to " << destination << ": " << result.files_copied
<< " file(s) copied, " << result.files_failed << " failed"
<< (result.error.empty() ? "" : " (" + result.error + ")") << std::endl;
}
} catch (const std::exception& exception) {
SetLogExportMessage(std::string("Export failed: ") + exception.what(), true);
} catch (...) {
SetLogExportMessage("Export failed.", true);
}
g_logExportInProgress.store(false, std::memory_order_release);
}
void StartLogExport() {
bool expected = false;
if (!g_logExportInProgress.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
return;
}
{
std::lock_guard lock(g_logExport.mutex);
g_logExport.note = BuildLogExportNote();
}
SetLogExportMessage("Choose the folder to export the logs into.", false);
// Parented to the game window so the picker opens in front of it. SDL may
// call back before returning if the dialog cannot be shown at all.
SDL_ShowOpenFolderDialog(&OnLogExportFolderChosen, nullptr, SDL_GetKeyboardFocus(), nullptr, false);
}
void DrawDiagnosticsSettings() {
if (ImGui::Checkbox("OpenXR diagnostic logging", &g_openxrDiagnosticsLogging)) {
mkw::vr::diagnostics::SetEnabled(g_openxrDiagnosticsLogging);
RuntimeConfigFile::SetDiagnosticsOpenXRLogging(g_openxrDiagnosticsLogging);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Writes VR frame timing to console.log once per second: late, skipped, repeated and\n"
"empty (black) frames, how long each frame waited for the game and for rendering,\n"
"head-tracking loss, reference-space changes, and the headset's view layout.\n"
"Turn it on to report stutter or black frames in VR, and off again afterwards.\n"
"Off by default. Applies immediately and is remembered.");
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
if (g_openxrDiagnosticsLogging && !mkw::vr::OpenXRIsRunning()) {
ImGui::TextDisabled("OpenXR is not running, so nothing is logged until a VR session starts.");
}
ImGui::PopTextWrapPos();
ImGui::Separator();
const bool exporting = g_logExportInProgress.load(std::memory_order_acquire);
ImGui::BeginDisabled(exporting);
if (ImGui::Button("Export Logs")) {
StartLogExport();
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip(
"Choose a folder, and the logs of recent sessions (the last four days, this one\n"
"included) are copied into a new WiiCompiled-logs folder there, together with\n"
"Config.toml. Zip that folder to attach it to a bug report.");
}
std::string message;
bool failed = false;
{
std::lock_guard lock(g_logExport.mutex);
message = g_logExport.message;
failed = g_logExport.failed;
}
if (!message.empty()) {
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
if (failed) {
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", message.c_str());
} else {
ImGui::TextDisabled("%s", message.c_str());
}
ImGui::PopTextWrapPos();
}
}
void DrawFpsOverlay() { void DrawFpsOverlay() {
AuroraPresentTiming presentTiming{}; AuroraPresentTiming presentTiming{};
aurora_get_present_timing(&presentTiming); aurora_get_present_timing(&presentTiming);
@@ -1346,6 +1497,11 @@ void DrawTopBar() {
ImGui::EndMenu(); ImGui::EndMenu();
} }
if (ImGui::BeginMenu("Diagnostics")) {
DrawDiagnosticsSettings();
ImGui::EndMenu();
}
const float hideWidth = ImGui::CalcTextSize("Hide (F10)").x + ImGui::GetStyle().FramePadding.x * 2.0f; const float hideWidth = ImGui::CalcTextSize("Hide (F10)").x + ImGui::GetStyle().FramePadding.x * 2.0f;
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowWidth() - hideWidth - 8.0f)); ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowWidth() - hideWidth - 8.0f));
if (ImGui::MenuItem("Hide (F10)")) { if (ImGui::MenuItem("Hide (F10)")) {
@@ -1446,6 +1602,7 @@ void InitializeRuntimeSettings() noexcept {
ApplyVrHudVirtualScreen(); ApplyVrHudVirtualScreen();
aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines); aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings(); mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
mkw::vr::diagnostics::SetEnabled(g_openxrDiagnosticsLogging);
g_strapInputAccepted.store(false, std::memory_order_relaxed); g_strapInputAccepted.store(false, std::memory_order_relaxed);
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed); g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
PADBlockInput(false); PADBlockInput(false);
+25 -5
View File
@@ -9,6 +9,7 @@
#endif #endif
#include "vr/openxr_d3d12.h" #include "vr/openxr_d3d12.h"
#include "vr/openxr_diagnostics.h"
#include <aurora/d3d12_interop.h> #include <aurora/d3d12_interop.h>
@@ -290,6 +291,7 @@ public:
} }
std::array<AuroraD3D12StereoTarget, kOpenXREyeCount> targets{}; std::array<AuroraD3D12StereoTarget, kOpenXREyeCount> targets{};
const diagnostics::Stopwatch acquire_timer;
for (uint32_t eye = 0; eye < target_count; ++eye) { for (uint32_t eye = 0; eye < target_count; ++eye) {
auto& swapchain = eye_swapchains_[eye]; auto& swapchain = eye_swapchains_[eye];
if (!AcquireSwapchain(swapchain)) { if (!AcquireSwapchain(swapchain)) {
@@ -304,6 +306,7 @@ public:
static_cast<int64_t>(swapchain_format_), static_cast<int64_t>(swapchain_format_),
}; };
} }
diagnostics::OnSwapchainAcquire(acquire_timer);
{ {
std::lock_guard lock(submission_mutex_); std::lock_guard lock(submission_mutex_);
@@ -387,7 +390,11 @@ public:
AbandonAcquiredSwapchains(); AbandonAcquiredSwapchains();
Fail("Aurora's D3D12 stereo submission failed after GPU work may have been queued"); Fail("Aurora's D3D12 stereo submission failed after GPU work may have been queued");
} }
const diagnostics::Stopwatch release_timer;
bool release_ok = ReleaseAcquiredSwapchains(); bool release_ok = ReleaseAcquiredSwapchains();
if (frame.xr_frame.should_render && frame.xr_frame.views_valid) {
diagnostics::OnSwapchainRelease(release_timer);
}
const bool position_valid = const bool position_valid =
(frame.xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0; (frame.xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0;
const bool composition_pose_valid = const bool composition_pose_valid =
@@ -395,6 +402,10 @@ public:
const bool can_submit = submit_layer && release_ok && frame.xr_frame.should_render && const bool can_submit = submit_layer && release_ok && frame.xr_frame.should_render &&
frame.xr_frame.views_valid && frame.expects_gpu_submission && frame.xr_frame.views_valid && frame.expects_gpu_submission &&
composition_pose_valid; composition_pose_valid;
if (submit_layer && !can_submit) {
diagnostics::OnLayerRejected(diagnostics::ClassifyRejectedLayer(
release_ok, frame.xr_frame.should_render, frame.xr_frame.views_valid));
}
if (can_submit) { if (can_submit) {
// xrEndFrame references the MOST RECENTLY RELEASED image of a // xrEndFrame references the MOST RECENTLY RELEASED image of a
// swapchain, not an explicit image index. Keep the displayed pair // swapchain, not an explicit image index. Keep the displayed pair
@@ -405,7 +416,7 @@ public:
retained_space_serial_ = render_space_serial_; retained_space_serial_ = render_space_serial_;
have_retained_frame_ = true; have_retained_frame_ = true;
} }
const bool end_ok = EndRetainedFrame(); const bool end_ok = EndRetainedFrame(can_submit);
frame_active_ = false; frame_active_ = false;
active_frame_serial_ = 0; active_frame_serial_ = 0;
@@ -426,7 +437,7 @@ public:
frame.xr_frame.serial != active_frame_serial_) { frame.xr_frame.serial != active_frame_serial_) {
return Fail("RepeatFrame received a stale or inactive render token"); return Fail("RepeatFrame received a stale or inactive render token");
} }
const bool end_ok = EndRetainedFrame(); const bool end_ok = EndRetainedFrame(false);
// EndFrame consumes the compositor token even when submission fails. // EndFrame consumes the compositor token even when submission fails.
// Teardown must not try to end that same token again. // Teardown must not try to end that same token again.
frame_active_ = false; frame_active_ = false;
@@ -447,7 +458,8 @@ public:
return true; return true;
} }
bool EndRetainedFrame() { // fresh: the retained layer was completed for this call rather than repeated.
bool EndRetainedFrame(bool fresh) {
if (!runtime_->IsSessionRunning()) { if (!runtime_->IsSessionRunning()) {
// A session that is no longer running needs no compositor frame // A session that is no longer running needs no compositor frame
// completion call. Preserve the original backend failure instead // completion call. Preserve the original backend failure instead
@@ -456,13 +468,21 @@ public:
} }
// Old poses cannot be reused after the runtime changes their coordinate // Old poses cannot be reused after the runtime changes their coordinate
// system. Also discard content across session restarts. // system. Also discard content across session restarts.
if (retained_session_serial_ != runtime_->SessionRunSerial() || const bool session_changed = retained_session_serial_ != runtime_->SessionRunSerial();
retained_space_serial_ != runtime_->LastReferenceSpaceChange().serial) { if (session_changed || retained_space_serial_ != runtime_->LastReferenceSpaceChange().serial) {
if (have_retained_frame_) {
diagnostics::OnRetainedLayerDiscarded(session_changed
? diagnostics::DiscardReason::SessionRestarted
: diagnostics::DiscardReason::ReferenceSpaceChanged);
}
have_retained_frame_ = false; have_retained_frame_ = false;
} }
if (!have_retained_frame_ || !active_frame_.should_render) { if (!have_retained_frame_ || !active_frame_.should_render) {
diagnostics::OnEmptyFrame(!active_frame_.should_render ? diagnostics::EmptyFrameReason::ShouldRenderOff
: diagnostics::EmptyFrameReason::NoRetainedLayer);
return runtime_->EndFrameWithoutLayers(active_frame_); return runtime_->EndFrameWithoutLayers(active_frame_);
} }
diagnostics::OnLayer(fresh);
const auto& frame = retained_frame_; const auto& frame = retained_frame_;
if (frame.presentation.mode == OpenXRD3D12FrameMode::VirtualScreen) { if (frame.presentation.mode == OpenXRD3D12FrameMode::VirtualScreen) {
XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD}; XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD};
+559
View File
@@ -0,0 +1,559 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vr/openxr_diagnostics.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <limits>
#include <mutex>
#include <utility>
namespace mkw::vr::diagnostics {
namespace {
constexpr double kNanosecondsPerMillisecond = 1'000'000.0;
double Milliseconds(int64_t nanoseconds) noexcept {
return static_cast<double>(nanoseconds) / kNanosecondsPerMillisecond;
}
// Appends printf-formatted text. Each call is one short field, so a fixed
// buffer is enough; the summary is assembled from many of them.
void AppendFormat(std::string& output, const char* format, ...) {
char buffer[256];
va_list arguments;
va_start(arguments, format);
const int length = std::vsnprintf(buffer, sizeof(buffer), format, arguments);
va_end(arguments);
if (length > 0) {
output.append(buffer, std::min(static_cast<size_t>(length), sizeof(buffer) - 1));
}
}
// "median/worst" in milliseconds, where worst is the maximum, or the minimum
// for a quantity (the display-time margin) where smaller is worse.
void AppendStat(std::string& output, const char* name, std::vector<float>& values, bool worst_is_min) {
if (values.empty()) {
AppendFormat(output, " %s=-", name);
return;
}
std::sort(values.begin(), values.end());
const float median = values[values.size() / 2];
const float worst = worst_is_min ? values.front() : values.back();
AppendFormat(output, " %s=%.1f/%.1f", name, median, worst);
}
const char* EmptyFrameText(EmptyFrameReason reason) noexcept {
switch (reason) {
case EmptyFrameReason::NoRetainedLayer:
return "no completed layer is retained, so the headset shows black";
case EmptyFrameReason::ShouldRenderOff:
return "the runtime set shouldRender to false";
}
return "unknown reason";
}
const char* DiscardText(DiscardReason reason) noexcept {
switch (reason) {
case DiscardReason::SessionRestarted:
return "the OpenXR session restarted";
case DiscardReason::ReferenceSpaceChanged:
return "the reference space changed";
}
return "unknown reason";
}
const char* RejectText(RejectReason reason) noexcept {
switch (reason) {
case RejectReason::ReleaseFailed:
return "releasing its swapchain images failed";
case RejectReason::ShouldRenderOff:
return "the runtime set shouldRender to false";
case RejectReason::ViewsInvalid:
return "its views were not valid";
case RejectReason::PositionInvalid:
return "the head position was not valid";
}
return "unknown reason";
}
bool GeometryChanged(const ViewGeometry& before, const ViewGeometry& after) noexcept {
constexpr float kAngleTolerance = 0.5f;
constexpr float kIpdTolerance = 0.5f;
for (size_t eye = 0; eye < 2; ++eye) {
for (size_t side = 0; side < 4; ++side) {
if (std::fabs(before.fov_degrees[eye][side] - after.fov_degrees[eye][side]) > kAngleTolerance) {
return true;
}
}
if (before.width[eye] != after.width[eye] || before.height[eye] != after.height[eye]) {
return true;
}
}
if (std::fabs(before.cant_degrees - after.cant_degrees) > kAngleTolerance) {
return true;
}
if ((before.ipd_millimeters < 0.0f) != (after.ipd_millimeters < 0.0f)) {
return true;
}
return after.ipd_millimeters >= 0.0f &&
std::fabs(before.ipd_millimeters - after.ipd_millimeters) > kIpdTolerance;
}
} // namespace
FrameDiagnostics::FrameDiagnostics(Sink sink) : sink_(std::move(sink)) {}
void FrameDiagnostics::SetSink(Sink sink) {
sink_ = std::move(sink);
}
void FrameDiagnostics::Samples::Add(double ms) {
// A window at a 144 Hz display holds a few hundred samples at most; the
// cap only guards a runaway caller.
if (values.size() < 4096) {
values.push_back(static_cast<float>(ms));
}
}
void FrameDiagnostics::Reset(int64_t now_ns) {
Sink sink = std::move(sink_);
*this = FrameDiagnostics(std::move(sink));
window_start_ns_ = now_ns;
session_info_requested_ = true;
}
bool FrameDiagnostics::ConsumeSessionInfoRequest() {
return std::exchange(session_info_requested_, false);
}
void FrameDiagnostics::OnWaitFrame(int64_t now_ns, int64_t wait_ns, int64_t display_time,
int64_t display_period, int64_t deadline_ns) {
if (window_start_ns_ == 0) {
window_start_ns_ = now_ns;
}
++cycles_;
wait_frame_ms_.Add(Milliseconds(wait_ns));
if (display_period > 0) {
display_period_ = display_period;
if (last_display_time_ != 0 && display_time > last_display_time_) {
const int64_t advance = display_time - last_display_time_;
const int64_t slots = (advance + display_period / 2) / display_period;
if (slots > 1) {
skipped_slots_ += static_cast<uint32_t>(slots - 1);
std::string text;
AppendFormat(text,
"compositor skipped %lld display slot(s): the predicted display time advanced "
"%.1f ms at %.1f Hz",
static_cast<long long>(slots - 1), Milliseconds(advance),
1.0e9 / static_cast<double>(display_period));
Event(text);
}
}
}
last_display_time_ = display_time;
deadline_ns_ = deadline_ns;
}
void FrameDiagnostics::OnBeginFrame(int64_t now_ns) {
begin_ns_ = now_ns;
}
void FrameDiagnostics::OnEndFrame(int64_t submit_ns, int64_t call_ns) {
end_call_ms_.Add(Milliseconds(call_ns));
const double open_ms = begin_ns_ != 0 ? Milliseconds(submit_ns - begin_ns_) : -1.0;
if (open_ms >= 0.0) {
open_ms_.Add(open_ms);
}
if (deadline_ns_ != 0) {
const double margin_ms = Milliseconds(deadline_ns_ - submit_ns);
margin_ms_.Add(margin_ms);
if (margin_ms < 0.0) {
++late_;
std::string text;
AppendFormat(text,
"late frame: xrEndFrame came %.1f ms after the predicted display time "
"(frame open %.1f ms)",
-margin_ms, open_ms);
Event(text);
}
}
if (last_submit_ns_ != 0) {
const double gap_ms = Milliseconds(submit_ns - last_submit_ns_);
end_gap_ms_.Add(gap_ms);
const double stall_ms = std::max(25.0, 2.5 * Milliseconds(display_period_));
if (gap_ms > stall_ms) {
std::string text;
AppendFormat(text, "stall: %.1f ms since the previous xrEndFrame", gap_ms);
Event(text);
}
}
last_submit_ns_ = submit_ns;
begin_ns_ = 0;
deadline_ns_ = 0;
Tick(submit_ns + call_ns);
}
void FrameDiagnostics::OnFrameBegun(bool immersive, bool should_render, bool views_valid,
bool orientation_valid, bool position_valid) {
++(immersive ? immersive_ : screen_);
if (!should_render || !views_valid) {
++not_rendered_;
}
if (!should_render) {
// Views are only located when the runtime wants rendering.
return;
}
no_orientation_ += orientation_valid ? 0 : 1;
no_position_ += position_valid ? 0 : 1;
if (tracking_known_ && orientation_valid != orientation_valid_) {
Event(orientation_valid ? "tracking: head orientation regained" : "tracking: head orientation lost");
}
if (tracking_known_ && position_valid != position_valid_) {
Event(position_valid ? "tracking: head position regained" : "tracking: head position lost");
}
tracking_known_ = true;
orientation_valid_ = orientation_valid;
position_valid_ = position_valid;
}
void FrameDiagnostics::OnViewGeometry(const ViewGeometry& geometry) {
if (geometry_logged_ && !GeometryChanged(geometry_, geometry)) {
return;
}
geometry_logged_ = true;
geometry_ = geometry;
std::string text = "view geometry: swapchains";
AppendFormat(text, " %ux%u / %ux%u", geometry.width[0], geometry.height[0], geometry.width[1],
geometry.height[1]);
for (size_t eye = 0; eye < 2; ++eye) {
const auto& fov = geometry.fov_degrees[eye];
AppendFormat(text, " | %s eye fov left %+.1f right %+.1f up %+.1f down %+.1f deg",
eye == 0 ? "left" : "right", fov[0], fov[1], fov[2], fov[3]);
}
AppendFormat(text, " | eye cant %.1f deg (%s)", geometry.cant_degrees,
geometry.cant_degrees > 1.0f ? "canted displays" : "parallel");
if (geometry.ipd_millimeters >= 0.0f) {
AppendFormat(text, " | ipd %.1f mm", geometry.ipd_millimeters);
} else {
text += " | ipd unknown";
}
Info(text);
}
void FrameDiagnostics::OnSwapchainAcquire(int64_t ns) {
acquire_ms_.Add(Milliseconds(ns));
}
void FrameDiagnostics::OnSwapchainRelease(int64_t ns) {
release_ms_.Add(Milliseconds(ns));
}
void FrameDiagnostics::OnLayer(bool fresh) {
++(fresh ? layers_new_ : layers_repeat_);
}
void FrameDiagnostics::OnEmptyFrame(EmptyFrameReason reason) {
++empty_;
Event(std::string("empty frame submitted (no layers): ") +
EmptyFrameText(reason));
}
void FrameDiagnostics::OnRetainedLayerDiscarded(DiscardReason reason) {
++discarded_;
Event(std::string("retained layer discarded: ") + DiscardText(reason));
}
void FrameDiagnostics::OnLayerRejected(RejectReason reason) {
++layer_rejected_;
Event(std::string("rendered layer not submitted: ") + RejectText(reason));
}
void FrameDiagnostics::OnInterpolationSkip() {
++interp_skip_;
}
void FrameDiagnostics::OnPacketPublished(int64_t now_ns) {
published_ns_ = now_ns;
}
void FrameDiagnostics::OnPacketCanceled(int64_t consumed_ns) {
if (consumed_ns == 0 || consumed_ns < published_ns_) {
++packet_unused_;
Event("stereo packet withdrawn: no game frame picked it up within 50 ms");
} else {
++packet_rejected_;
Event("stereo packet canceled: Aurora picked it up but rendered that frame without it "
"(content tag or transform check)");
}
published_ns_ = 0;
}
void FrameDiagnostics::OnKeepaliveRepeat() {
++keepalive_;
}
void FrameDiagnostics::OnSubmission(int64_t now_ns, int64_t consumed_ns, bool success) {
if (!success) {
++submit_failed_;
Event("stereo submission failed");
}
if (published_ns_ != 0 && consumed_ns >= published_ns_) {
game_wait_ms_.Add(Milliseconds(consumed_ns - published_ns_));
encode_ms_.Add(Milliseconds(now_ns - consumed_ns));
}
published_ns_ = 0;
}
void FrameDiagnostics::OnReferenceSpaceChange(std::string_view space, bool pose_valid, bool effective_known,
double effective_in_ms) {
std::string text = "reference space change pending: ";
text.append(space);
text += pose_valid ? ", previous pose valid" : ", previous pose not valid";
if (effective_known) {
AppendFormat(text, ", takes effect in %.1f ms", effective_in_ms);
}
Info(text);
}
void FrameDiagnostics::Info(std::string_view text) {
if (!sink_) {
return;
}
std::string line = "[xr-diag] ";
line.append(text);
sink_(line);
}
void FrameDiagnostics::Event(std::string_view text) {
if (events_ >= kMaxEventsPerWindow) {
++suppressed_;
return;
}
++events_;
Info(text);
}
void FrameDiagnostics::Tick(int64_t now_ns) {
if (window_start_ns_ == 0) {
window_start_ns_ = now_ns;
return;
}
if (now_ns - window_start_ns_ >= kWindowNs) {
EmitSummary(now_ns);
ClearWindow(now_ns);
}
}
void FrameDiagnostics::ClearWindow(int64_t now_ns) {
window_start_ns_ = now_ns;
cycles_ = skipped_slots_ = late_ = 0;
layers_new_ = layers_repeat_ = empty_ = discarded_ = layer_rejected_ = 0;
keepalive_ = packet_unused_ = packet_rejected_ = submit_failed_ = interp_skip_ = 0;
immersive_ = screen_ = not_rendered_ = no_orientation_ = no_position_ = 0;
events_ = suppressed_ = 0;
for (Samples* samples : {&wait_frame_ms_, &open_ms_, &margin_ms_, &end_gap_ms_, &end_call_ms_,
&acquire_ms_, &release_ms_, &game_wait_ms_, &encode_ms_}) {
samples->Clear();
}
}
void FrameDiagnostics::EmitSummary(int64_t now_ns) {
std::string text;
AppendFormat(text, "%.2fs", Milliseconds(now_ns - window_start_ns_) / 1000.0);
if (display_period_ > 0) {
AppendFormat(text, " %.1fHz", 1.0e9 / static_cast<double>(display_period_));
}
AppendFormat(text, " cycles=%u skipped-slots=%u late=%u", cycles_, skipped_slots_, late_);
AppendFormat(text, " | layers new=%u repeat=%u empty=%u discarded=%u layer-rejected=%u", layers_new_,
layers_repeat_, empty_, discarded_, layer_rejected_);
text += " | ms";
AppendStat(text, "wait-frame", wait_frame_ms_.values, false);
AppendStat(text, "open", open_ms_.values, false);
AppendStat(text, "end-margin", margin_ms_.values, true);
AppendStat(text, "end-gap", end_gap_ms_.values, false);
AppendStat(text, "end-call", end_call_ms_.values, false);
AppendStat(text, "pickup", game_wait_ms_.values, false);
AppendStat(text, "render", encode_ms_.values, false);
AppendStat(text, "acquire", acquire_ms_.values, false);
AppendStat(text, "release", release_ms_.values, false);
AppendFormat(text, " | keepalive=%u packet-unused=%u packet-rejected=%u submit-failed=%u interp-skip=%u",
keepalive_, packet_unused_, packet_rejected_, submit_failed_, interp_skip_);
AppendFormat(text, " | frames immersive=%u screen=%u not-rendered=%u no-orientation=%u no-position=%u",
immersive_, screen_, not_rendered_, no_orientation_, no_position_);
AppendFormat(text, " | suppressed=%u", suppressed_);
Info(text);
}
namespace {
struct GlobalDiagnostics {
std::mutex sink_mutex;
FrameDiagnostics::Sink sink;
std::function<int64_t(int64_t)> converter;
FrameDiagnostics collector{[](std::string_view line) {
auto& state = Global();
std::lock_guard lock(state.sink_mutex);
if (state.sink) {
state.sink(line);
}
}};
static GlobalDiagnostics& Global() {
static GlobalDiagnostics state;
return state;
}
};
std::atomic_bool g_reset_pending{true};
void WriteLine(std::string_view line) {
auto& state = GlobalDiagnostics::Global();
std::lock_guard lock(state.sink_mutex);
if (state.sink) {
state.sink(line);
}
}
// The pacing thread's view of the collector. A switch-on made elsewhere is
// applied here, on the owning thread, before the next measurement.
FrameDiagnostics& Collector() {
auto& collector = GlobalDiagnostics::Global().collector;
if (g_reset_pending.exchange(false, std::memory_order_acq_rel)) {
collector.Reset(detail::NowNs());
}
return collector;
}
int64_t ConvertDisplayTime(int64_t xr_time) {
const auto& converter = GlobalDiagnostics::Global().converter;
return converter ? converter(xr_time) : 0;
}
} // namespace
void SetEnabled(bool enabled) noexcept {
const bool was_enabled = detail::g_enabled.exchange(enabled, std::memory_order_acq_rel);
if (enabled == was_enabled) {
return;
}
if (enabled) {
g_reset_pending.store(true, std::memory_order_release);
}
try {
WriteLine(enabled ? "[xr-diag] logging enabled" : "[xr-diag] logging disabled");
} catch (...) {
// A diagnostic line must never take the settings path down.
}
}
void SetLogSink(FrameDiagnostics::Sink sink) {
auto& state = GlobalDiagnostics::Global();
std::lock_guard lock(state.sink_mutex);
state.sink = std::move(sink);
}
void SetDisplayTimeConverter(std::function<int64_t(int64_t xr_time)> converter) {
GlobalDiagnostics::Global().converter = std::move(converter);
}
namespace detail {
int64_t NowNs() noexcept {
return std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
void OnWaitFrame(int64_t wait_ns, int64_t display_time, int64_t display_period) {
auto& collector = Collector();
collector.OnWaitFrame(NowNs(), wait_ns, display_time, display_period, ConvertDisplayTime(display_time));
}
void OnBeginFrame() {
Collector().OnBeginFrame(NowNs());
}
void OnEndFrame(int64_t submit_ns, int64_t call_ns) {
Collector().OnEndFrame(submit_ns, call_ns);
}
void OnFrameBegun(bool immersive, bool should_render, bool views_valid, bool orientation_valid,
bool position_valid) {
Collector().OnFrameBegun(immersive, should_render, views_valid, orientation_valid, position_valid);
}
void OnViewGeometry(const ViewGeometry& geometry) {
Collector().OnViewGeometry(geometry);
}
void OnSwapchainAcquire(int64_t ns) {
Collector().OnSwapchainAcquire(ns);
}
void OnSwapchainRelease(int64_t ns) {
Collector().OnSwapchainRelease(ns);
}
void OnLayer(bool fresh) {
Collector().OnLayer(fresh);
}
void OnEmptyFrame(EmptyFrameReason reason) {
Collector().OnEmptyFrame(reason);
}
void OnRetainedLayerDiscarded(DiscardReason reason) {
Collector().OnRetainedLayerDiscarded(reason);
}
void OnLayerRejected(RejectReason reason) {
Collector().OnLayerRejected(reason);
}
void OnInterpolationSkip() {
Collector().OnInterpolationSkip();
}
void OnPacketPublished() {
g_packet_consumed_ns.store(0, std::memory_order_relaxed);
Collector().OnPacketPublished(NowNs());
}
void OnPacketCanceled() {
Collector().OnPacketCanceled(g_packet_consumed_ns.load(std::memory_order_relaxed));
}
void OnKeepaliveRepeat() {
Collector().OnKeepaliveRepeat();
}
void OnSubmission(bool success) {
Collector().OnSubmission(NowNs(), g_packet_consumed_ns.load(std::memory_order_relaxed), success);
}
void OnReferenceSpaceChange(std::string_view space, bool pose_valid, int64_t change_time) {
// The runtime reports 0 when the change applies immediately; the converter
// returns 0 when it has no clock to convert with.
const int64_t effective_ns = change_time != 0 ? ConvertDisplayTime(change_time) : 0;
const double effective_in_ms = effective_ns != 0 ? Milliseconds(effective_ns - NowNs()) : 0.0;
Collector().OnReferenceSpaceChange(space, pose_valid, effective_ns != 0, effective_in_ms);
}
void OnSessionStarted() {
g_reset_pending.store(true, std::memory_order_release);
}
bool ConsumeSessionInfoRequest() {
return Collector().ConsumeSessionInfoRequest();
}
void Info(std::string_view text) {
Collector().Info(text);
}
} // namespace detail
} // namespace mkw::vr::diagnostics
+151 -1
View File
@@ -11,6 +11,7 @@
#include "vr/mkw_vr_first_person.h" #include "vr/mkw_vr_first_person.h"
#include "vr/mkw_vr_policy.h" #include "vr/mkw_vr_policy.h"
#include "vr/mkw_vr_instrumentation.h" #include "vr/mkw_vr_instrumentation.h"
#include "vr/openxr_diagnostics.h"
#include <aurora/gfx.h> #include <aurora/gfx.h>
#include <algorithm> #include <algorithm>
@@ -23,6 +24,7 @@
#include <cstring> #include <cstring>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <sstream>
#include <string> #include <string>
#include <thread> #include <thread>
@@ -254,6 +256,64 @@ void ViewFromBase(const XrPosef& eye_pose, const std::array<float, 3>& base_posi
output[11] = translation[2] * units_per_meter; output[11] = translation[2] * units_per_meter;
} }
// Distinct names from openxr_runtime.cpp's helpers: both files can share a
// unity-build translation unit and the same anonymous namespace.
const char* DiagnosticSpaceName(XrReferenceSpaceType type) noexcept {
switch (type) {
case XR_REFERENCE_SPACE_TYPE_VIEW:
return "VIEW";
case XR_REFERENCE_SPACE_TYPE_LOCAL:
return "LOCAL";
case XR_REFERENCE_SPACE_TYPE_STAGE:
return "STAGE";
default:
return "OTHER";
}
}
const char* DiagnosticBlendModeName(XrEnvironmentBlendMode mode) noexcept {
switch (mode) {
case XR_ENVIRONMENT_BLEND_MODE_OPAQUE:
return "OPAQUE";
case XR_ENVIRONMENT_BLEND_MODE_ADDITIVE:
return "ADDITIVE";
case XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND:
return "ALPHA_BLEND";
default:
return "OTHER";
}
}
// What the runtime reported about the eyes this frame. The cant is the angle
// between the two eyes' forward axes: zero for parallel displays, and the
// headset's display tilt on canted ones (Pimax) unless the runtime is asked for
// parallel projections.
diagnostics::ViewGeometry DiagnosticViewGeometry(const OpenXRBackendFrame& frame) noexcept {
constexpr float kRadiansToDegrees = 57.29577951f;
diagnostics::ViewGeometry geometry{};
std::array<std::array<float, 3>, kOpenXREyeCount> forward{};
for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
const XrView& view = frame.xr_frame.views[eye];
geometry.fov_degrees[eye] = {view.fov.angleLeft * kRadiansToDegrees, view.fov.angleRight * kRadiansToDegrees,
view.fov.angleUp * kRadiansToDegrees, view.fov.angleDown * kRadiansToDegrees};
const auto& q = view.pose.orientation;
forward[eye] = Rotate(Normalize({q.x, q.y, q.z, q.w}), {0.0f, 0.0f, -1.0f});
geometry.width[eye] = frame.render_width[eye];
geometry.height[eye] = frame.render_height[eye];
}
const float dot = forward[0][0] * forward[1][0] + forward[0][1] * forward[1][1] + forward[0][2] * forward[1][2];
geometry.cant_degrees = std::acos(std::clamp(dot, -1.0f, 1.0f)) * kRadiansToDegrees;
if ((frame.xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0) {
const auto& left = frame.xr_frame.views[0].pose.position;
const auto& right = frame.xr_frame.views[1].pose.position;
const float dx = right.x - left.x;
const float dy = right.y - left.y;
const float dz = right.z - left.z;
geometry.ipd_millimeters = std::sqrt(dx * dx + dy * dy + dz * dz) * 1000.0f;
}
return geometry;
}
class OpenXRIntegration final { class OpenXRIntegration final {
public: public:
static OpenXRIntegration& Get() { static OpenXRIntegration& Get() {
@@ -272,6 +332,11 @@ public:
kGraphicsBackendName + " submission"); kGraphicsBackendName + " submission");
return OpenXRStartupResult::Unavailable; return OpenXRStartupResult::Unavailable;
} }
// The pacing thread is not running here (Shutdown above joined it), so
// the sink may be replaced.
diagnostics::SetLogSink(
[](std::string_view line) { RT_LOG(RT_TAG_RUNTIME) << line << std::endl; });
diagnostics::SetEnabled(RuntimeConfigFile::DiagnosticsOpenXRLogging(false));
requested_ = RuntimeConfigFile::VrEnabled(kVrEnabledDefault); requested_ = RuntimeConfigFile::VrEnabled(kVrEnabledDefault);
ConfigurePolicy(requested_); ConfigurePolicy(requested_);
if (!requested_) { if (!requested_) {
@@ -381,6 +446,10 @@ public:
WithdrawPublishedFrame(); WithdrawPublishedFrame();
aurora_set_stereo_frame_provider(&OpenXRIntegration::ProvideStereoFrame, this); aurora_set_stereo_frame_provider(&OpenXRIntegration::ProvideStereoFrame, this);
provider_registered_ = true; provider_registered_ = true;
if (convert_display_time_ != nullptr) {
diagnostics::SetDisplayTimeConverter(
[this](int64_t xr_time) { return static_cast<int64_t>(DisplayTimeNanos(xr_time)); });
}
running_.store(true, std::memory_order_release); running_.store(true, std::memory_order_release);
try { try {
pacing_thread_ = std::thread([this] { PacingThread(); }); pacing_thread_ = std::thread([this] { PacingThread(); });
@@ -431,6 +500,8 @@ public:
} }
running_.store(false, std::memory_order_release); running_.store(false, std::memory_order_release);
MkwVRPolicySetSessionActive(false); MkwVRPolicySetSessionActive(false);
// The converter reads runtime_; the pacing thread has stopped using it.
diagnostics::SetDisplayTimeConverter({});
backend_.reset(); backend_.reset();
runtime_.reset(); runtime_.reset();
prepared_ = false; prepared_ = false;
@@ -513,6 +584,7 @@ private:
} }
void ResetPreparedObjects() { void ResetPreparedObjects() {
diagnostics::SetDisplayTimeConverter({});
ShutdownOrRetainGraphicsObjects(); ShutdownOrRetainGraphicsObjects();
input_.reset(); input_.reset();
backend_.reset(); backend_.reset();
@@ -556,6 +628,7 @@ private:
if (frame == nullptr) { if (frame == nullptr) {
return false; return false;
} }
diagnostics::NotePacketConsumed();
*output = frame->frame; *output = frame->frame;
return true; return true;
} }
@@ -579,6 +652,7 @@ private:
if (session_run_serial != applied_session_run_serial_) { if (session_run_serial != applied_session_run_serial_) {
applied_session_run_serial_ = session_run_serial; applied_session_run_serial_ = session_run_serial;
ResetTrackingOrigin(); ResetTrackingOrigin();
diagnostics::OnSessionStarted();
} }
if (session_active != session_was_active_) { if (session_active != session_was_active_) {
session_was_active_ = session_active; session_was_active_ = session_active;
@@ -606,8 +680,10 @@ private:
} }
const MkwVRPolicySnapshot policy = MkwVRPolicyGetSnapshot(); const MkwVRPolicySnapshot policy = MkwVRPolicyGetSnapshot();
// Diagnostics lift the cap: a presentation flickering between the
// race and the virtual screen is exactly what a report needs to show.
if ((!presentation_logged || policy.presentation != logged_presentation) && if ((!presentation_logged || policy.presentation != logged_presentation) &&
presentation_log_count < 16) { (presentation_log_count < 16 || diagnostics::Enabled())) {
presentation_logged = true; presentation_logged = true;
logged_presentation = policy.presentation; logged_presentation = policy.presentation;
++presentation_log_count; ++presentation_log_count;
@@ -655,6 +731,9 @@ private:
} }
UpdateFrameTiming(frame.xr_frame); UpdateFrameTiming(frame.xr_frame);
if (diagnostics::Enabled()) {
NoteFrameDiagnostics(frame, immersive);
}
// Both of these read this frame's located head pose and must run // Both of these read this frame's located head pose and must run
// before FinishFrame submits a layer built from it. // before FinishFrame submits a layer built from it.
ServiceRecenterRequest(); ServiceRecenterRequest();
@@ -675,6 +754,7 @@ private:
if (aurora_get_stereo_frame_interpolation() && if (aurora_get_stereo_frame_interpolation() &&
!interpolation_pacing_.ShouldRender(frame.xr_frame.predicted_display_time, interpolation_target)) { !interpolation_pacing_.ShouldRender(frame.xr_frame.predicted_display_time, interpolation_target)) {
diagnostics::OnInterpolationSkip();
if (!backend_->TryCancelPendingFrame(frame) || !backend_->FinishFrame(frame, false)) { if (!backend_->TryCancelPendingFrame(frame) || !backend_->FinishFrame(frame, false)) {
SetError(backend_->LastError()); SetError(backend_->LastError());
fatal = true; fatal = true;
@@ -690,6 +770,7 @@ private:
// the camera's own switch is not observable. // the camera's own switch is not observable.
BuildPublishedFrame(frame, immersive, policy.EffectiveUnitsPerMeter(), BuildPublishedFrame(frame, immersive, policy.EffectiveUnitsPerMeter(),
policy.content_tag); policy.content_tag);
diagnostics::OnPacketPublished();
published_.store(&published_frame_, std::memory_order_release); published_.store(&published_frame_, std::memory_order_release);
} }
aurora_notify_stereo_frame(); aurora_notify_stereo_frame();
@@ -711,9 +792,11 @@ private:
WithdrawPublishedFrame(); WithdrawPublishedFrame();
canceled_before_encode = backend_->TryCancelPendingFrame(frame); canceled_before_encode = backend_->TryCancelPendingFrame(frame);
if (canceled_before_encode) { if (canceled_before_encode) {
diagnostics::OnPacketCanceled();
break; break;
} }
} }
diagnostics::OnKeepaliveRepeat();
if (!backend_->RepeatFrame(frame)) { if (!backend_->RepeatFrame(frame)) {
SetError(backend_->LastError()); SetError(backend_->LastError());
fatal = true; fatal = true;
@@ -739,6 +822,7 @@ private:
break; break;
} }
const bool submit = submission == OpenXRSubmissionStatus::Success; const bool submit = submission == OpenXRSubmissionStatus::Success;
diagnostics::OnSubmission(submit);
if (!backend_->FinishFrame(frame, submit)) { if (!backend_->FinishFrame(frame, submit)) {
SetError(backend_->LastError()); SetError(backend_->LastError());
fatal = true; fatal = true;
@@ -965,6 +1049,72 @@ private:
} }
} }
// Pacing thread, only while diagnostics are on.
void NoteFrameDiagnostics(const OpenXRBackendFrame& frame, bool immersive) {
const XrViewStateFlags flags = frame.xr_frame.view_state_flags;
diagnostics::OnFrameBegun(immersive, frame.xr_frame.should_render, frame.xr_frame.views_valid,
(flags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) != 0,
(flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0);
if (diagnostics::ConsumeSessionInfoRequest()) {
LogDiagnosticSession(frame);
}
if (frame.xr_frame.should_render && frame.xr_frame.views_valid) {
diagnostics::OnViewGeometry(DiagnosticViewGeometry(frame));
}
}
// Everything about the headset and runtime that a pacing report is read
// against. Written when logging starts and again for every new session.
void LogDiagnosticSession(const OpenXRBackendFrame& frame) const {
const auto& info = runtime_->RuntimeInfo();
std::ostringstream line;
line << "session: runtime '" << info.runtime_name << "' " << XR_VERSION_MAJOR(info.runtime_version) << '.'
<< XR_VERSION_MINOR(info.runtime_version) << '.' << XR_VERSION_PATCH(info.runtime_version)
<< ", system '" << info.system_name << "', vendor 0x" << std::hex << info.vendor_id << std::dec
<< ", orientation tracking " << info.supports_orientation_tracking << ", position tracking "
<< info.supports_position_tracking << ", max layers " << info.max_layer_count;
diagnostics::Info(line.str());
line.str({});
line << "session: " << kGraphicsBackendName << " backend, reference space "
<< DiagnosticSpaceName(runtime_->AppSpaceType()) << ", blend mode "
<< DiagnosticBlendModeName(runtime_->EnvironmentBlendMode()) << ", extensions";
for (const std::string& extension : runtime_->EnabledExtensions()) {
line << ' ' << extension;
}
diagnostics::Info(line.str());
line.str({});
const auto& views = runtime_->ViewConfiguration();
line << "session: recommended eye size " << views[0].properties.recommendedImageRectWidth << 'x'
<< views[0].properties.recommendedImageRectHeight << " / "
<< views[1].properties.recommendedImageRectWidth << 'x'
<< views[1].properties.recommendedImageRectHeight << ", max "
<< views[0].properties.maxImageRectWidth << 'x' << views[0].properties.maxImageRectHeight
<< ", render_scale " << runtime_->Config().resolution_scale << ", swapchains "
<< views[0].render_width << 'x' << views[0].render_height << " / " << views[1].render_width << 'x'
<< views[1].render_height;
diagnostics::Info(line.str());
line.str({});
const uint32_t interpolation = frame_interpolation_fps_.load(std::memory_order_relaxed);
line << "session: display period ";
if (frame.xr_frame.predicted_display_period > 0) {
const double period_ms = static_cast<double>(frame.xr_frame.predicted_display_period) / 1.0e6;
line << period_ms << " ms (" << 1000.0 / period_ms << " Hz)";
} else {
line << "unknown";
}
line << ", refresh-rate extension " << (get_display_refresh_rate_ != nullptr ? "yes" : "no")
<< ", display-time conversion " << (convert_display_time_ != nullptr ? "yes" : "no")
<< ", VR frame interpolation "
<< (interpolation == 0 ? std::string("off")
: interpolation == 1 ? std::string("auto")
: std::to_string(interpolation))
<< (FrameInterpolationAvailable() ? "" : " (unavailable)");
diagnostics::Info(line.str());
}
// Converts the compositor's predicted display time onto the runtime's // Converts the compositor's predicted display time onto the runtime's
// steady clock, which is what Aurora's interpolation deadlines are paced by. // steady clock, which is what Aurora's interpolation deadlines are paced by.
uint64_t DisplayTimeNanos(XrTime display_time) noexcept { uint64_t DisplayTimeNanos(XrTime display_time) noexcept {
+24
View File
@@ -6,6 +6,7 @@
#if defined(MKW_ENABLE_OPENXR) #if defined(MKW_ENABLE_OPENXR)
#include "vr/openxr_runtime.h" #include "vr/openxr_runtime.h"
#include "vr/openxr_diagnostics.h"
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
@@ -55,6 +56,19 @@ uint32_t ScaledDimension(uint32_t recommended, uint32_t maximum, float scale) {
return static_cast<uint32_t>(clamped); return static_cast<uint32_t>(clamped);
} }
const char* ReferenceSpaceName(XrReferenceSpaceType type) {
switch (type) {
case XR_REFERENCE_SPACE_TYPE_VIEW:
return "VIEW";
case XR_REFERENCE_SPACE_TYPE_LOCAL:
return "LOCAL";
case XR_REFERENCE_SPACE_TYPE_STAGE:
return "STAGE";
default:
return "OTHER";
}
}
const char* SessionStateName(XrSessionState state) { const char* SessionStateName(XrSessionState state) {
switch (state) { switch (state) {
case XR_SESSION_STATE_UNKNOWN: case XR_SESSION_STATE_UNKNOWN:
@@ -520,6 +534,11 @@ OpenXREventStatus OpenXRRuntime::PollEvents() {
case XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING: { case XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING: {
const auto& space_event = const auto& space_event =
*reinterpret_cast<const XrEventDataReferenceSpaceChangePending*>(&event); *reinterpret_cast<const XrEventDataReferenceSpaceChangePending*>(&event);
if (space_event.session == m_session) {
diagnostics::OnReferenceSpaceChange(ReferenceSpaceName(space_event.referenceSpaceType),
space_event.poseValid == XR_TRUE,
space_event.changeTime);
}
// This slot is consumed to invalidate transforms located in the // This slot is consumed to invalidate transforms located in the
// application space. Events for VIEW or another supported type // application space. Events for VIEW or another supported type
// must not overwrite a pending LOCAL/STAGE change. // must not overwrite a pending LOCAL/STAGE change.
@@ -647,9 +666,11 @@ OpenXRFrameStatus OpenXRRuntime::WaitFrame(OpenXRFrame& frame) {
XrFrameWaitInfo wait_info{XR_TYPE_FRAME_WAIT_INFO}; XrFrameWaitInfo wait_info{XR_TYPE_FRAME_WAIT_INFO};
XrFrameState state{XR_TYPE_FRAME_STATE}; XrFrameState state{XR_TYPE_FRAME_STATE};
const diagnostics::Stopwatch wait_timer;
if (!Check(xrWaitFrame(m_session, &wait_info, &state), "xrWaitFrame")) { if (!Check(xrWaitFrame(m_session, &wait_info, &state), "xrWaitFrame")) {
return OpenXRFrameStatus::Error; return OpenXRFrameStatus::Error;
} }
diagnostics::OnWaitFrame(wait_timer, state.predictedDisplayTime, state.predictedDisplayPeriod);
frame = {}; frame = {};
frame.serial = m_next_frame_serial++; frame.serial = m_next_frame_serial++;
@@ -678,6 +699,7 @@ bool OpenXRRuntime::BeginFrame(const OpenXRFrame& frame) {
return Check(result, "xrBeginFrame"); return Check(result, "xrBeginFrame");
} }
m_frame_phase = FramePhase::Begun; m_frame_phase = FramePhase::Begun;
diagnostics::OnBeginFrame();
return true; return true;
} }
@@ -744,7 +766,9 @@ bool OpenXRRuntime::EndFrame(
end_info.environmentBlendMode = m_blend_mode; end_info.environmentBlendMode = m_blend_mode;
end_info.layerCount = layer_count; end_info.layerCount = layer_count;
end_info.layers = layers; end_info.layers = layers;
const diagnostics::Stopwatch end_timer;
const XrResult result = xrEndFrame(m_session, &end_info); const XrResult result = xrEndFrame(m_session, &end_info);
diagnostics::OnEndFrame(end_timer);
m_frame_phase = FramePhase::Idle; m_frame_phase = FramePhase::Idle;
m_active_frame_serial = 0; m_active_frame_serial = 0;
m_active_frame_display_time = 0; m_active_frame_display_time = 0;
+25 -5
View File
@@ -8,6 +8,7 @@
#define XR_USE_PLATFORM_ANDROID #define XR_USE_PLATFORM_ANDROID
#include "vr/openxr_vulkan.h" #include "vr/openxr_vulkan.h"
#include "vr/openxr_diagnostics.h"
#include <aurora/vulkan_interop.h> #include <aurora/vulkan_interop.h>
@@ -360,6 +361,7 @@ public:
frame.render_height[1] = frame.render_height[0]; frame.render_height[1] = frame.render_height[0];
} }
const diagnostics::Stopwatch acquire_timer;
for (uint32_t eye = 0; eye < target_count; ++eye) { for (uint32_t eye = 0; eye < target_count; ++eye) {
if (!AcquireSwapchain(eye_swapchains_[eye])) { if (!AcquireSwapchain(eye_swapchains_[eye])) {
ReleaseAcquiredSwapchains(); ReleaseAcquiredSwapchains();
@@ -367,6 +369,7 @@ public:
return OpenXRBeginStatus::Error; return OpenXRBeginStatus::Error;
} }
} }
diagnostics::OnSwapchainAcquire(acquire_timer);
std::array<AuroraVulkanStereoTarget, kOpenXREyeCount> targets{}; std::array<AuroraVulkanStereoTarget, kOpenXREyeCount> targets{};
const uint32_t slot = next_slot_; const uint32_t slot = next_slot_;
@@ -475,7 +478,11 @@ public:
AbandonAcquiredSwapchains(); AbandonAcquiredSwapchains();
Fail("Aurora's stereo submission failed after GPU work may have been queued"); Fail("Aurora's stereo submission failed after GPU work may have been queued");
} }
const diagnostics::Stopwatch release_timer;
bool release_ok = ReleaseAcquiredSwapchains(); bool release_ok = ReleaseAcquiredSwapchains();
if (frame.xr_frame.should_render && frame.xr_frame.views_valid) {
diagnostics::OnSwapchainRelease(release_timer);
}
const bool position_valid = const bool position_valid =
(frame.xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0; (frame.xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0;
const bool composition_pose_valid = const bool composition_pose_valid =
@@ -483,6 +490,10 @@ public:
const bool can_submit = submit_layer && release_ok && frame.xr_frame.should_render && const bool can_submit = submit_layer && release_ok && frame.xr_frame.should_render &&
frame.xr_frame.views_valid && frame.expects_gpu_submission && frame.xr_frame.views_valid && frame.expects_gpu_submission &&
composition_pose_valid; composition_pose_valid;
if (submit_layer && !can_submit) {
diagnostics::OnLayerRejected(diagnostics::ClassifyRejectedLayer(
release_ok, frame.xr_frame.should_render, frame.xr_frame.views_valid));
}
if (can_submit) { if (can_submit) {
// xrEndFrame references the most recently released image of a // xrEndFrame references the most recently released image of a
// swapchain, so keep the displayed pair separate from the pair // swapchain, so keep the displayed pair separate from the pair
@@ -493,7 +504,7 @@ public:
retained_space_serial_ = render_space_serial_; retained_space_serial_ = render_space_serial_;
have_retained_frame_ = true; have_retained_frame_ = true;
} }
const bool end_ok = EndRetainedFrame(); const bool end_ok = EndRetainedFrame(can_submit);
frame_active_ = false; frame_active_ = false;
active_frame_serial_ = 0; active_frame_serial_ = 0;
@@ -514,7 +525,7 @@ public:
frame.xr_frame.serial != active_frame_serial_) { frame.xr_frame.serial != active_frame_serial_) {
return Fail("RepeatFrame received a stale or inactive render token"); return Fail("RepeatFrame received a stale or inactive render token");
} }
const bool end_ok = EndRetainedFrame(); const bool end_ok = EndRetainedFrame(false);
frame_active_ = false; frame_active_ = false;
if (!end_ok) { if (!end_ok) {
return Fail("OpenXR could not resubmit the retained frame"); return Fail("OpenXR could not resubmit the retained frame");
@@ -531,17 +542,26 @@ public:
return true; return true;
} }
bool EndRetainedFrame() { // fresh: the retained layer was completed for this call rather than repeated.
bool EndRetainedFrame(bool fresh) {
if (!runtime_->IsSessionRunning()) { if (!runtime_->IsSessionRunning()) {
return true; return true;
} }
if (retained_session_serial_ != runtime_->SessionRunSerial() || const bool session_changed = retained_session_serial_ != runtime_->SessionRunSerial();
retained_space_serial_ != runtime_->LastReferenceSpaceChange().serial) { if (session_changed || retained_space_serial_ != runtime_->LastReferenceSpaceChange().serial) {
if (have_retained_frame_) {
diagnostics::OnRetainedLayerDiscarded(session_changed
? diagnostics::DiscardReason::SessionRestarted
: diagnostics::DiscardReason::ReferenceSpaceChanged);
}
have_retained_frame_ = false; have_retained_frame_ = false;
} }
if (!have_retained_frame_ || !active_frame_.should_render) { if (!have_retained_frame_ || !active_frame_.should_render) {
diagnostics::OnEmptyFrame(!active_frame_.should_render ? diagnostics::EmptyFrameReason::ShouldRenderOff
: diagnostics::EmptyFrameReason::NoRetainedLayer);
return runtime_->EndFrameWithoutLayers(active_frame_); return runtime_->EndFrameWithoutLayers(active_frame_);
} }
diagnostics::OnLayer(fresh);
const auto& frame = retained_frame_; const auto& frame = retained_frame_;
if (frame.presentation.mode == OpenXRFrameMode::VirtualScreen) { if (frame.presentation.mode == OpenXRFrameMode::VirtualScreen) {
XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD}; XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD};
+113
View File
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// F10 > Diagnostics > Export Logs against a synthetic application data folder.
#include "log_export.h"
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
namespace {
namespace fs = std::filesystem;
void RequireAt(bool condition, int line, const char* expression) {
if (!condition) {
std::cerr << "log_export_tests.cpp:" << line << ": requirement failed: " << expression << '\n';
std::abort();
}
}
#define Require(condition) RequireAt((condition), __LINE__, #condition)
void Write(const fs::path& path, const std::string& text) {
fs::create_directories(path.parent_path());
std::ofstream(path, std::ios::binary) << text;
}
std::string Read(const fs::path& path) {
std::ifstream input(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>());
}
size_t CountExports(const fs::path& parent) {
size_t count = 0;
for (const auto& entry : fs::directory_iterator(parent)) {
count += entry.path().filename().string().rfind(log_export::kExportFolderPrefix, 0) == 0 ? 1 : 0;
}
return count;
}
} // namespace
int main() {
const fs::path root = fs::temp_directory_path() / "wiicompiled_log_export_tests";
fs::remove_all(root);
const fs::path data = root / "WiiCompiledOpenXRVR";
const fs::path logs = data / "Logs";
const fs::path config = data / "Config.toml";
const fs::path target = root / "Chosen folder";
fs::create_directories(target);
Write(logs / "base_100_pid1" / "console.log", "[runtime] first run\n");
Write(logs / "base_200_pid2" / "console.log", "[runtime] [xr-diag] 1.00s 72.0Hz cycles=72\n");
Write(logs / "base_200_pid2" / "crash.txt", "");
Write(config, "[vr]\nenabled = true\n");
// The running session's transcript is still open for writing while it is exported.
fs::create_directories(logs / "base_300_pid3");
std::ofstream live(logs / "base_300_pid3" / "console.log", std::ios::binary);
Require(static_cast<bool>(live));
live << "[runtime] live session\n";
live.flush();
const auto now = std::chrono::system_clock::now();
const auto result = log_export::ExportLogs(logs, config, target, "Exported by process 3.\n", now);
Require(result.Succeeded());
Require(result.files_copied == 5);
Require(result.files_failed == 0);
Require(result.destination.parent_path() == target);
Require(result.destination.filename().string().rfind(log_export::kExportFolderPrefix, 0) == 0);
Require(Read(result.destination / "Logs" / "base_200_pid2" / "console.log") ==
"[runtime] [xr-diag] 1.00s 72.0Hz cycles=72\n");
Require(fs::exists(result.destination / "Logs" / "base_200_pid2" / "crash.txt"));
Require(fs::file_size(result.destination / "Logs" / "base_200_pid2" / "crash.txt") == 0);
Require(Read(result.destination / "Logs" / "base_300_pid3" / "console.log") == "[runtime] live session\n");
Require(Read(result.destination / "Config.toml") == "[vr]\nenabled = true\n");
const std::string info = Read(result.destination / "export-info.txt");
Require(info.rfind("Exported ", 0) == 0);
Require(info.find("Exported by process 3.\n") != std::string::npos);
live.close();
// Exporting twice in the same second makes a second folder, not a merge.
const auto again = log_export::ExportLogs(logs, config, target, {}, now);
Require(again.Succeeded());
Require(again.destination != result.destination);
Require(CountExports(target) == 2);
// Choosing the Logs folder itself must not copy the export into itself.
const auto inside = log_export::ExportLogs(logs, config, logs, {}, now);
Require(inside.Succeeded());
Require(inside.files_copied == 5);
Require(!fs::exists(inside.destination / "Logs" / inside.destination.filename()));
// Missing sources: only the configuration exists.
const auto config_only = log_export::ExportLogs(root / "missing Logs", config, target, {}, now);
Require(config_only.Succeeded());
Require(config_only.files_copied == 1);
const auto nothing = log_export::ExportLogs(root / "missing Logs", root / "missing.toml", target, {}, now);
Require(!nothing.Succeeded());
Require(!nothing.destination.empty());
Require(!nothing.error.empty());
const auto bad_parent = log_export::ExportLogs(logs, config, root / "does not exist", {}, now);
Require(!bad_parent.Succeeded());
Require(bad_parent.destination.empty());
fs::remove_all(root);
std::cout << "Log export tests passed\n";
}
+291
View File
@@ -0,0 +1,291 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Headless checks for the F10 > Diagnostics OpenXR logging: the collector's
// window accounting, event classification and rate limit, driven with a
// synthetic clock, plus the global switch and its Config.toml key.
#include "vr/openxr_diagnostics.h"
#include "runtime_config.h"
#include <cstdlib>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
namespace {
using namespace mkw::vr::diagnostics;
void RequireAt(bool condition, int line, const char* expression) {
if (!condition) {
std::cerr << "openxr_diagnostics_tests.cpp:" << line << ": requirement failed: " << expression << '\n';
std::abort();
}
}
#define Require(condition) RequireAt((condition), __LINE__, #condition)
constexpr int64_t kMs = 1'000'000;
constexpr int64_t kPeriod72Hz = 13'888'889;
struct Capture {
std::vector<std::string> lines;
FrameDiagnostics::Sink Sink() {
return [this](std::string_view line) { lines.emplace_back(line); };
}
size_t Count(std::string_view needle) const {
size_t count = 0;
for (const auto& line : lines) {
count += line.find(needle) != std::string::npos ? 1 : 0;
}
return count;
}
const std::string& Summary() const {
for (auto it = lines.rbegin(); it != lines.rend(); ++it) {
if (it->find("cycles=") != std::string::npos) {
return *it;
}
}
static const std::string none;
return none;
}
};
// One compositor cycle on a 72 Hz grid: waits 1 ms, is begun, and ends
// `open_ns` later, `margin_ns` before its display deadline.
void Cycle(FrameDiagnostics& diagnostics, int64_t& now, int64_t& display_time, int64_t open_ns, int64_t margin_ns,
int64_t slots = 1) {
display_time += kPeriod72Hz * slots;
now += kMs;
const int64_t submit = now + open_ns;
diagnostics.OnWaitFrame(now, kMs, display_time, kPeriod72Hz, submit + margin_ns);
diagnostics.OnBeginFrame(now);
diagnostics.OnEndFrame(submit, kMs / 2);
now = submit + kMs / 2;
}
void TestSteadyWindow() {
Capture capture;
FrameDiagnostics diagnostics(capture.Sink());
int64_t now = 5'000 * kMs;
int64_t display_time = 1'000'000 * kMs;
diagnostics.Reset(now);
Require(diagnostics.ConsumeSessionInfoRequest());
Require(!diagnostics.ConsumeSessionInfoRequest());
const int64_t window_start = now;
for (int frame = 0; frame < 72; ++frame) {
diagnostics.OnFrameBegun(true, true, true, true, true);
diagnostics.OnLayer(true);
Cycle(diagnostics, now, display_time, 12 * kMs, 4 * kMs);
}
// 72 cycles of 13.5 ms fall just short of the window; nothing is emitted early.
Require(capture.lines.empty());
diagnostics.Tick(window_start + FrameDiagnostics::kWindowNs);
const std::string& summary = capture.Summary();
Require(!summary.empty());
Require(summary.rfind("[xr-diag] ", 0) == 0);
Require(summary.find("72.0Hz") != std::string::npos);
Require(summary.find("skipped-slots=0 late=0") != std::string::npos);
Require(summary.find("cycles=72 ") != std::string::npos);
Require(summary.find("layers new=72 repeat=0 empty=0") != std::string::npos);
Require(summary.find("end-margin=4.0/4.0") != std::string::npos);
Require(summary.find("open=12.0/12.0") != std::string::npos);
Require(summary.find("suppressed=0") != std::string::npos);
// A steady run produces no event lines, only the summary.
Require(capture.lines.size() == 1);
}
void TestLateSkippedAndStalledFrames() {
Capture capture;
FrameDiagnostics diagnostics(capture.Sink());
int64_t now = 1'000 * kMs;
int64_t display_time = 500'000 * kMs;
diagnostics.Reset(now);
Cycle(diagnostics, now, display_time, 5 * kMs, 3 * kMs);
// Ends 2 ms after its display time, two slots after the previous frame.
Cycle(diagnostics, now, display_time, 20 * kMs, -2 * kMs, 3);
Require(capture.Count("late frame: xrEndFrame came 2.0 ms after") == 1);
Require(capture.Count("compositor skipped 2 display slot(s)") == 1);
// A 60 ms gap between xrEndFrame calls is a stall at 72 Hz.
Cycle(diagnostics, now, display_time, 60 * kMs, 1 * kMs);
Require(capture.Count("stall:") == 1);
now += 2'000 * kMs;
diagnostics.Tick(now);
const std::string& summary = capture.Summary();
Require(summary.find("cycles=3 skipped-slots=2 late=1") != std::string::npos);
}
void TestEventsAreRateLimited() {
Capture capture;
FrameDiagnostics diagnostics(capture.Sink());
int64_t now = 1'000 * kMs;
diagnostics.Reset(now);
for (int frame = 0; frame < 20; ++frame) {
diagnostics.OnEmptyFrame(EmptyFrameReason::NoRetainedLayer);
}
Require(capture.Count("empty frame submitted (no layers): no completed layer is retained") ==
FrameDiagnostics::kMaxEventsPerWindow);
now += 1'001 * kMs;
diagnostics.Tick(now);
Require(capture.Summary().find("empty=20") != std::string::npos);
Require(capture.Summary().find("suppressed=12") != std::string::npos);
// The next window has a fresh budget.
diagnostics.OnRetainedLayerDiscarded(DiscardReason::ReferenceSpaceChanged);
Require(capture.Count("retained layer discarded: the reference space changed") == 1);
}
void TestTrackingEdgesAndRejections() {
Capture capture;
FrameDiagnostics diagnostics(capture.Sink());
diagnostics.Reset(1);
diagnostics.OnFrameBegun(true, true, true, true, true);
diagnostics.OnFrameBegun(true, true, true, true, false);
diagnostics.OnFrameBegun(true, true, true, true, false);
diagnostics.OnFrameBegun(true, true, true, true, true);
// Views are not located when the runtime does not want rendering.
diagnostics.OnFrameBegun(false, false, false, false, false);
Require(capture.Count("tracking: head position lost") == 1);
Require(capture.Count("tracking: head position regained") == 1);
Require(capture.Count("orientation") == 0);
Require(ClassifyRejectedLayer(false, false, false) == RejectReason::ReleaseFailed);
Require(ClassifyRejectedLayer(true, false, true) == RejectReason::ShouldRenderOff);
Require(ClassifyRejectedLayer(true, true, false) == RejectReason::ViewsInvalid);
Require(ClassifyRejectedLayer(true, true, true) == RejectReason::PositionInvalid);
diagnostics.OnLayerRejected(RejectReason::PositionInvalid);
Require(capture.Count("rendered layer not submitted: the head position was not valid") == 1);
diagnostics.Tick(2'000 * kMs);
const std::string& summary = capture.Summary();
Require(summary.find("immersive=4 screen=1 not-rendered=1") != std::string::npos);
Require(summary.find("no-orientation=0 no-position=2") != std::string::npos);
Require(summary.find("layer-rejected=1") != std::string::npos);
}
void TestPacketAccounting() {
Capture capture;
FrameDiagnostics diagnostics(capture.Sink());
int64_t now = 10'000 * kMs;
diagnostics.Reset(now);
diagnostics.OnPacketPublished(now);
diagnostics.OnSubmission(now + 18 * kMs, now + 15 * kMs, true);
diagnostics.OnPacketPublished(now + 20 * kMs);
diagnostics.OnPacketCanceled(0);
Require(capture.Count("no game frame picked it up within 50 ms") == 1);
diagnostics.OnPacketPublished(now + 80 * kMs);
diagnostics.OnPacketCanceled(now + 90 * kMs);
Require(capture.Count("Aurora picked it up but rendered that frame without it") == 1);
diagnostics.OnPacketPublished(now + 200 * kMs);
diagnostics.OnSubmission(now + 210 * kMs, now + 205 * kMs, false);
Require(capture.Count("stereo submission failed") == 1);
diagnostics.Tick(now + 1'500 * kMs);
const std::string& summary = capture.Summary();
Require(summary.find("packet-unused=1 packet-rejected=1 submit-failed=1") != std::string::npos);
// Two submissions: pickup 15 and 5 ms, render 3 and 5 ms.
Require(summary.find("pickup=15.0/15.0") != std::string::npos);
Require(summary.find("render=5.0/5.0") != std::string::npos);
}
void TestViewGeometry() {
Capture capture;
FrameDiagnostics diagnostics(capture.Sink());
diagnostics.Reset(1);
ViewGeometry pimax{};
pimax.fov_degrees = {{{-60.0f, 45.0f, 50.0f, -50.0f}, {-45.0f, 60.0f, 50.0f, -50.0f}}};
pimax.cant_degrees = 20.0f;
pimax.ipd_millimeters = 64.0f;
pimax.width = {4834, 4834};
pimax.height = {4056, 4056};
diagnostics.OnViewGeometry(pimax);
Require(capture.Count("view geometry: swapchains 4834x4056 / 4834x4056") == 1);
Require(capture.Count("eye cant 20.0 deg (canted displays)") == 1);
Require(capture.Count("ipd 64.0 mm") == 1);
// Tracking noise below the tolerance is not re-logged.
pimax.ipd_millimeters = 64.2f;
pimax.cant_degrees = 20.1f;
diagnostics.OnViewGeometry(pimax);
Require(capture.Count("view geometry") == 1);
// Switching the headset to parallel projections is.
pimax.cant_degrees = 0.0f;
diagnostics.OnViewGeometry(pimax);
Require(capture.Count("view geometry") == 2);
Require(capture.Count("(parallel)") == 1);
// Geometry and session descriptions are never rate-limited away.
for (int event = 0; event < 20; ++event) {
diagnostics.OnEmptyFrame(EmptyFrameReason::ShouldRenderOff);
}
diagnostics.OnReferenceSpaceChange("LOCAL", true, true, 12.5);
Require(capture.Count("reference space change pending: LOCAL, previous pose valid, takes effect in 12.5 ms") == 1);
diagnostics.OnReferenceSpaceChange("STAGE", false, false, 0.0);
Require(capture.Count("reference space change pending: STAGE, previous pose not valid") == 1);
}
void TestResetStartsANewSession() {
Capture capture;
FrameDiagnostics diagnostics(capture.Sink());
int64_t now = 1'000 * kMs;
int64_t display_time = 10'000 * kMs;
diagnostics.Reset(now);
Require(diagnostics.ConsumeSessionInfoRequest());
Cycle(diagnostics, now, display_time, 5 * kMs, 1 * kMs);
diagnostics.Reset(now);
Require(diagnostics.ConsumeSessionInfoRequest());
// A new session's display clock must not read as thousands of skipped slots.
display_time = 5 * kMs;
Cycle(diagnostics, now, display_time, 5 * kMs, 1 * kMs);
Require(capture.Count("skipped") == 0);
Require(capture.Count("stall") == 0);
}
void TestGlobalSwitchAndConfig() {
std::vector<std::string> lines;
SetLogSink([&](std::string_view line) { lines.emplace_back(line); });
Require(!Enabled());
{
const Stopwatch stopwatch;
Require(stopwatch.ElapsedNs() == -1);
}
OnEmptyFrame(EmptyFrameReason::NoRetainedLayer);
Require(lines.empty());
SetEnabled(true);
Require(Enabled());
Require(lines.size() == 1 && lines[0] == "[xr-diag] logging enabled");
{
const Stopwatch stopwatch;
Require(stopwatch.ElapsedNs() >= 0);
}
Require(ConsumeSessionInfoRequest());
Require(!ConsumeSessionInfoRequest());
SetEnabled(true);
Require(lines.size() == 1);
SetEnabled(false);
Require(lines.back() == "[xr-diag] logging disabled");
Require(!ConsumeSessionInfoRequest());
SetLogSink({});
std::istringstream missing("[vr]\nenabled = true\n");
Require(!RuntimeConfigFile::ParseConfig(missing).diagnosticsOpenXRLogging.has_value());
std::istringstream on("[diagnostics]\nopenxr_logging = true\n");
Require(RuntimeConfigFile::ParseConfig(on).diagnosticsOpenXRLogging == true);
std::istringstream off("[diagnostics]\nopenxr_logging = false\n");
Require(RuntimeConfigFile::ParseConfig(off).diagnosticsOpenXRLogging == false);
}
} // namespace
int main() {
TestSteadyWindow();
TestLateSkippedAndStalledFrames();
TestEventsAreRateLimited();
TestTrackingEdgesAndRejections();
TestPacketAccounting();
TestViewGeometry();
TestResetStartsANewSession();
TestGlobalSwitchAndConfig();
std::cout << "OpenXR diagnostics tests passed\n";
}