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

+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";
}