Files
mitch030504--Wiicompiled_VR…/runtime/tests/openxr_d3d12_replay_tests.cpp
T

579 lines
23 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Exercise the real backend against a deterministic compositor and Aurora sink.
// No headset, graphics driver, OpenXR loader, or translated game is required.
#if defined(TEST_WINDOWS_VULKAN)
#define XR_USE_GRAPHICS_API_VULKAN
#include <vulkan/vulkan.h>
#include <openxr/openxr_platform.h>
#include <aurora/vulkan_win32_interop.h>
#include "vr/openxr_vulkan_win32.h"
#define OpenXRD3D12Backend OpenXRWindowsVulkanBackend
#define OpenXRD3D12BeginStatus OpenXRWindowsVulkanBeginStatus
#define OpenXRD3D12SubmissionStatus OpenXRWindowsVulkanSubmissionStatus
#define OpenXRD3D12Frame OpenXRWindowsVulkanFrame
#define OpenXRD3D12Presentation OpenXRWindowsVulkanPresentation
#define OpenXRD3D12FrameMode OpenXRWindowsVulkanFrameMode
#define aurora_d3d12_enable_stereo_bridge aurora_vulkan_win32_enable
#define aurora_d3d12_set_stereo_targets aurora_vulkan_win32_set_targets
#define aurora_d3d12_cancel_stereo_targets aurora_vulkan_win32_cancel
#define aurora_d3d12_disable_stereo_bridge aurora_vulkan_win32_disable
#else
#define CINTERFACE
#define XR_USE_GRAPHICS_API_D3D12
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <d3d12.h>
#include <openxr/openxr_platform.h>
#include <aurora/d3d12_interop.h>
#include "vr/openxr_d3d12.h"
#endif
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <vector>
using namespace mkw::vr;
namespace {
void RequireAt(bool condition, int line, const char* expression) {
if (!condition) {
std::cerr << "OpenXR replay test failed at line " << line << ": " << expression << '\n';
std::abort();
}
}
#define Require(condition) RequireAt((condition), __LINE__, #condition)
struct Image { uint64_t content = 0; };
struct Swapchain {
Image image;
bool acquired = false;
bool waited = false;
bool released = false;
};
std::vector<AuroraD3D12StereoTarget> targets;
AuroraD3D12StereoSubmittedCallback callback = nullptr;
void* callback_data = nullptr;
uint64_t pending_token = 0;
bool encoded = false;
bool compositor_open = false;
bool expect_render_first = false;
bool should_render = true;
bool tracking_valid = true;
bool change_space = false;
bool restart_session = false;
bool stop_session = false;
bool fail_end = false;
uint64_t displayed_content = 0;
uint32_t layer_count = 0;
uint32_t releases = 0;
uint32_t live_swapchains = 0;
XrTime display_time = 0;
XrStructureType layer_type = XR_TYPE_UNKNOWN;
XrPosef quad_pose{};
void Complete(bool success = true) {
Require(pending_token != 0);
if (expect_render_first) Require(!compositor_open);
if (success) {
for (const auto& target : targets)
reinterpret_cast<Image*>(target.resource)->content = pending_token;
}
callback(pending_token, success, callback_data);
pending_token = 0;
encoded = false;
}
#if defined(TEST_WINDOWS_VULKAN)
AuroraDawnVulkanHooks hooks{};
bool queue_locked = false;
XrResult XRAPI_CALL Requirements(XrInstance, XrSystemId, XrGraphicsRequirementsVulkanKHR* out) {
out->minApiVersionSupported = XR_MAKE_VERSION(1, 1, 0);
out->maxApiVersionSupported = XR_MAKE_VERSION(1, 3, 0);
return XR_SUCCESS;
}
XrResult XRAPI_CALL Physical(XrInstance, const XrVulkanGraphicsDeviceGetInfoKHR*, VkPhysicalDevice* out) {
*out = reinterpret_cast<VkPhysicalDevice>(2); return XR_SUCCESS;
}
XrResult XRAPI_CALL CreateInstance(XrInstance, const XrVulkanInstanceCreateInfoKHR* info, VkInstance* out, VkResult* result) {
Require(info->vulkanCreateInfo->pApplicationInfo->apiVersion >= VK_API_VERSION_1_1);
*out = reinterpret_cast<VkInstance>(1); *result = VK_SUCCESS; return XR_SUCCESS;
}
XrResult XRAPI_CALL CreateDevice(XrInstance, const XrVulkanDeviceCreateInfoKHR* info, VkDevice* out, VkResult* result) {
Require(info->vulkanPhysicalDevice == reinterpret_cast<VkPhysicalDevice>(2));
*out = reinterpret_cast<VkDevice>(3); *result = VK_SUCCESS; return XR_SUCCESS;
}
#else
HRESULT STDMETHODCALLTYPE FeatureSupport(ID3D12Device*, D3D12_FEATURE,
void* data, UINT) {
static_cast<D3D12_FEATURE_DATA_FEATURE_LEVELS*>(data)->MaxSupportedFeatureLevel =
D3D_FEATURE_LEVEL_11_0;
return S_OK;
}
XrResult XRAPI_CALL Requirements(XrInstance, XrSystemId,
XrGraphicsRequirementsD3D12KHR* requirements) {
requirements->adapterLuid = {};
requirements->minFeatureLevel = D3D_FEATURE_LEVEL_11_0;
return XR_SUCCESS;
}
#endif
}
// A COM vtable in its C representation supplies the single device operation
// used by BindAurora. The production backend is compiled normally as C++.
#if defined(TEST_WINDOWS_VULKAN)
bool aurora_vulkan_win32_configure(const AuroraDawnVulkanHooks* value) {
hooks = value ? *value : AuroraDawnVulkanHooks{}; return true;
}
bool aurora_vulkan_win32_get_handles(AuroraDawnVulkanHandles* handles, int64_t* format) {
VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
app.apiVersion = VK_API_VERSION_1_0;
VkInstanceCreateInfo instance{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO}; instance.pApplicationInfo = &app;
Require(hooks.createInstance(hooks.userdata, nullptr, &instance, nullptr, &handles->instance) == VK_SUCCESS);
Require(hooks.getPhysicalDevice(hooks.userdata, handles->instance, &handles->physicalDevice) == VK_SUCCESS);
VkDeviceCreateInfo device{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};
Require(hooks.createDevice(hooks.userdata, nullptr, handles->physicalDevice, &device, nullptr, &handles->device) == VK_SUCCESS);
*format = VK_FORMAT_R8G8B8A8_UNORM; return true;
}
void* aurora_vulkan_win32_lock_queue() { Require(!queue_locked); queue_locked = true; return &queue_locked; }
void aurora_vulkan_win32_unlock_queue(void*) { Require(queue_locked); queue_locked = false; }
#else
bool aurora_d3d12_get_native_handles(AuroraD3D12NativeHandles* handles) {
static ID3D12DeviceVtbl vtable{};
vtable.CheckFeatureSupport = FeatureSupport;
static ID3D12Device device{&vtable};
*handles = {&device, &device, DXGI_FORMAT_R8G8B8A8_UNORM, 0, 0};
return true;
}
#endif
bool aurora_d3d12_enable_stereo_bridge(AuroraD3D12StereoSubmittedCallback cb, void* data) {
callback = cb;
callback_data = data;
return true;
}
bool aurora_d3d12_set_stereo_targets(uint64_t token, const AuroraD3D12StereoTarget* data,
uint32_t count) {
Require(pending_token == 0);
pending_token = token;
targets.assign(data, data + count);
return true;
}
bool aurora_d3d12_cancel_stereo_targets(uint64_t token) {
if (encoded || token != pending_token) return false;
pending_token = 0;
return true;
}
bool aurora_d3d12_disable_stereo_bridge() {
pending_token = 0;
encoded = false;
return true; // Simulate a successful queue drain.
}
XrResult XRAPI_CALL xrCreateSwapchain(XrSession, const XrSwapchainCreateInfo*, XrSwapchain* out) {
*out = reinterpret_cast<XrSwapchain>(new Swapchain);
++live_swapchains;
return XR_SUCCESS;
}
XrResult XRAPI_CALL xrEnumerateSwapchainImages(XrSwapchain handle, uint32_t capacity,
uint32_t* count, XrSwapchainImageBaseHeader* images) {
*count = 1; // Single-image swapchains also require a separate retained pair.
#if defined(TEST_WINDOWS_VULKAN)
if (capacity) reinterpret_cast<XrSwapchainImageVulkanKHR*>(images)->image =
reinterpret_cast<VkImage>(&reinterpret_cast<Swapchain*>(handle)->image);
#else
if (capacity) reinterpret_cast<XrSwapchainImageD3D12KHR*>(images)->texture =
reinterpret_cast<ID3D12Resource*>(&reinterpret_cast<Swapchain*>(handle)->image);
#endif
return XR_SUCCESS;
}
XrResult XRAPI_CALL xrAcquireSwapchainImage(XrSwapchain handle,
const XrSwapchainImageAcquireInfo*, uint32_t* index) {
auto& chain = *reinterpret_cast<Swapchain*>(handle);
#if defined(TEST_WINDOWS_VULKAN)
Require(queue_locked);
#endif
Require(!chain.acquired);
chain.acquired = true;
chain.waited = false;
*index = 0;
return XR_SUCCESS;
}
XrResult XRAPI_CALL xrWaitSwapchainImage(XrSwapchain handle, const XrSwapchainImageWaitInfo*) {
auto& chain = *reinterpret_cast<Swapchain*>(handle);
Require(chain.acquired);
chain.waited = true;
return XR_SUCCESS;
}
XrResult XRAPI_CALL xrReleaseSwapchainImage(XrSwapchain handle,
const XrSwapchainImageReleaseInfo*) {
auto& chain = *reinterpret_cast<Swapchain*>(handle);
#if defined(TEST_WINDOWS_VULKAN)
Require(queue_locked); // The runtime may touch Dawn's VkQueue here.
#endif
Require(chain.acquired && chain.waited);
chain.acquired = false;
chain.released = true;
++releases;
return XR_SUCCESS;
}
XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain handle) {
auto* chain = reinterpret_cast<Swapchain*>(handle);
Require(!chain->acquired);
delete chain;
--live_swapchains;
return XR_SUCCESS;
}
namespace mkw::vr {
OpenXRRuntime::OpenXRRuntime(OpenXRLogCallback) {
m_instance = reinterpret_cast<XrInstance>(this);
#if defined(TEST_WINDOWS_VULKAN)
m_swapchain_formats = {VK_FORMAT_R8G8B8A8_SRGB};
#else
m_swapchain_formats = {DXGI_FORMAT_R8G8B8A8_UNORM_SRGB};
#endif
for (auto& view : m_view_configuration) {
view.render_width = 100;
view.render_height = 80;
}
}
OpenXRRuntime::~OpenXRRuntime() = default;
bool OpenXRRuntime::GetInstanceProcAddress(const char* name, PFN_xrVoidFunction* out) {
#if defined(TEST_WINDOWS_VULKAN)
if (std::strcmp(name, "xrCreateVulkanInstanceKHR") == 0) *out = reinterpret_cast<PFN_xrVoidFunction>(::CreateInstance);
else if (std::strcmp(name, "xrCreateVulkanDeviceKHR") == 0) *out = reinterpret_cast<PFN_xrVoidFunction>(CreateDevice);
else if (std::strcmp(name, "xrGetVulkanGraphicsDevice2KHR") == 0) *out = reinterpret_cast<PFN_xrVoidFunction>(Physical);
else
#endif
*out = reinterpret_cast<PFN_xrVoidFunction>(Requirements);
return true;
}
bool OpenXRRuntime::CreateSession(const void*) {
m_session = reinterpret_cast<XrSession>(this);
m_session_running = true;
++m_session_run_serial;
return true;
}
void OpenXRRuntime::DestroySession() { m_session_running = false; compositor_open = false; }
void OpenXRRuntime::ObserveResult(XrResult) noexcept {}
OpenXREventStatus OpenXRRuntime::PollEvents() {
if (change_space) { ++m_reference_space_change.serial; change_space = false; }
if (restart_session) { ++m_session_run_serial; m_session_running = true; restart_session = false; }
if (stop_session) { m_session_running = false; stop_session = false; }
return OpenXREventStatus::Continue;
}
OpenXRFrameStatus OpenXRRuntime::WaitFrame(OpenXRFrame& frame) {
Require(m_frame_phase == FramePhase::Idle);
if (!m_session_running) return OpenXRFrameStatus::SessionNotRunning;
frame = {};
frame.serial = m_next_frame_serial++;
frame.predicted_display_time = frame.serial * 11'111'111;
frame.predicted_display_period = 11'111'111;
frame.should_render = should_render;
m_active_frame_serial = frame.serial;
m_frame_phase = FramePhase::Waited;
return OpenXRFrameStatus::Ready;
}
bool OpenXRRuntime::BeginFrame(const OpenXRFrame& frame) {
Require(m_frame_phase == FramePhase::Waited && frame.serial == m_active_frame_serial);
m_frame_phase = FramePhase::Begun;
compositor_open = true;
return true;
}
bool OpenXRRuntime::LocateViews(OpenXRFrame& frame) {
frame.views_valid = tracking_valid;
frame.view_state_flags = XR_VIEW_STATE_POSITION_VALID_BIT | XR_VIEW_STATE_ORIENTATION_VALID_BIT;
for (auto& view : frame.views) {
view.pose.orientation.w = 1;
view.pose.position.x = static_cast<float>(frame.serial);
view.fov.angleLeft = -0.75f;
}
return true;
}
bool OpenXRRuntime::LocateViewsAt(XrTime time, OpenXRFrame& frame) {
Require(!compositor_open);
frame.predicted_display_time = time;
return LocateViews(frame);
}
bool OpenXRRuntime::EndFrame(const OpenXRFrame& frame,
const XrCompositionLayerBaseHeader* const* layers, uint32_t count) {
Require(m_frame_phase == FramePhase::Begun && frame.serial == m_active_frame_serial);
Require(frame.predicted_display_time > display_time);
display_time = frame.predicted_display_time;
layer_count = count;
if (count) {
Require(frame.should_render && count == 1);
layer_type = layers[0]->type;
const auto check_image = [](const XrSwapchainSubImage& subimage) {
const auto& chain = *reinterpret_cast<Swapchain*>(subimage.swapchain);
Require(chain.released && !chain.acquired && chain.image.content != 0);
return chain.image.content;
};
if (layer_type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) {
auto& projection = *reinterpret_cast<const XrCompositionLayerProjection*>(layers[0]);
Require(projection.viewCount == 2);
for (const auto& view : {projection.views[0], projection.views[1]}) {
displayed_content = check_image(view.subImage);
// Detect a new image paired with an old pose, or a repeated image
// falsely labelled with the latest compositor pose.
Require(view.pose.position.x == static_cast<float>(displayed_content));
Require(view.fov.angleLeft == -0.75f);
}
} else {
Require(layer_type == XR_TYPE_COMPOSITION_LAYER_QUAD);
const auto& quad = *reinterpret_cast<const XrCompositionLayerQuad*>(layers[0]);
displayed_content = check_image(quad.subImage);
quad_pose = quad.pose;
}
}
m_frame_phase = FramePhase::Idle;
compositor_open = false;
if (fail_end) {
fail_end = false;
return false;
}
return true;
}
bool OpenXRRuntime::EndFrameWithoutLayers(const OpenXRFrame& frame) {
return EndFrame(frame, nullptr, 0);
}
}
void TestRenderFirst() {
display_time = 0;
expect_render_first = true;
OpenXRRuntime runtime;
OpenXRD3D12Backend backend;
Require(backend.QueryGraphicsRequirements(runtime) && backend.BindAurora(runtime));
OpenXRPresentation presentation;
OpenXRBackendFrame packet, frame;
const auto prepare = [&] {
Require(backend.PreparePacket(presentation, packet) == OpenXRBeginStatus::Ready);
Require(!compositor_open && packet.expects_gpu_submission);
};
const auto submit = [&] {
Complete();
Require(backend.WaitForSubmission(packet, 0) == OpenXRSubmissionStatus::Success);
Require(backend.BeginFrameForPacket(packet, frame) == OpenXRBeginStatus::Ready);
Require(compositor_open && frame.xr_frame.serial != packet.xr_frame.serial);
Require(backend.CopyRenderedEyes(frame) == OpenXRSubmissionStatus::Success);
Require(backend.FinishFrame(frame, true));
Require(!compositor_open);
};
prepare();
Require(backend.BeginFrameForPacket(packet, frame) == OpenXRBeginStatus::Error);
submit();
const auto first = displayed_content;
Require(first == packet.xr_frame.serial && layer_count == 1);
prepare();
const auto before_stall = releases;
encoded = true;
for (int i = 0; i < 300; ++i) {
Require(backend.WaitForSubmission(packet, 0) == OpenXRSubmissionStatus::Timeout);
Require(!backend.TryCancelPendingPacket(packet));
Require(backend.KeepAliveCycle() == OpenXRBeginStatus::Ready);
Require(!compositor_open && layer_count == 1 && displayed_content == first);
Require(releases == before_stall);
}
submit(); // Must preserve original poses across independently advancing cycles.
Require(displayed_content == packet.xr_frame.serial);
const auto second = displayed_content;
prepare();
auto stale = packet;
++stale.xr_frame.serial;
Require(!backend.TryCancelPendingPacket(stale));
Require(backend.BeginFrameForPacket(stale, frame) == OpenXRBeginStatus::Error);
Require(backend.TryCancelPendingPacket(packet));
Require(!compositor_open && releases == before_stall + 4);
Require(backend.KeepAliveCycle() == OpenXRBeginStatus::Ready);
Require(displayed_content == second);
// Original frame-first mode still works after switching interpolation on.
expect_render_first = false;
Require(backend.BeginFrame(presentation, frame) == OpenXRBeginStatus::Ready);
Complete();
Require(backend.FinishFrame(frame, true));
expect_render_first = true;
prepare();
Require(packet.xr_frame.predicted_display_time > display_time);
Require(backend.TryCancelPendingPacket(packet));
presentation.mode = OpenXRFrameMode::VirtualScreen;
presentation.quad_anchored = true;
presentation.quad_pose.position.z = -4;
prepare();
Require(targets.size() == 1);
submit();
Require(layer_type == XR_TYPE_COMPOSITION_LAYER_QUAD && quad_pose.position.z == -4);
presentation.mode = OpenXRFrameMode::ImmersiveProjection;
prepare();
should_render = false; // Visibility can change while rendering.
submit();
Require(layer_count == 0);
Require(backend.PreparePacket(presentation, packet) == OpenXRBeginStatus::Ready);
Require(!packet.expects_gpu_submission);
should_render = true;
Require(backend.KeepAliveCycle() == OpenXRBeginStatus::Ready);
tracking_valid = false;
Require(backend.PreparePacket(presentation, packet) == OpenXRBeginStatus::Ready);
Require(!packet.expects_gpu_submission);
tracking_valid = true;
for (bool session_change : {false, true}) {
prepare();
change_space = !session_change;
restart_session = session_change;
runtime.PollEvents();
submit();
Require(layer_count == 0); // Do not relabel old images with new space/session serials.
prepare();
submit();
Require(layer_count == 1);
}
prepare();
Complete();
stop_session = true;
runtime.PollEvents();
Require(backend.BeginFrameForPacket(packet, frame) == OpenXRBeginStatus::SessionNotRunning);
restart_session = true;
runtime.PollEvents();
prepare();
submit();
Require(layer_count == 1);
prepare();
const auto before_failure = releases;
Complete(false);
Require(backend.WaitForSubmission(packet, 0) == OpenXRSubmissionStatus::Failed);
Require(backend.BeginFrameForPacket(packet, frame) == OpenXRBeginStatus::Error);
Require(releases == before_failure);
Require(backend.Shutdown() && live_swapchains == 0);
// Shutdown must also drain packets that never entered a compositor frame,
// including a runtime end failure or session stop during a keep-alive.
for (int scenario = 0; scenario < 3; ++scenario) {
display_time = 0;
OpenXRRuntime next_runtime;
OpenXRD3D12Backend next_backend;
Require(next_backend.QueryGraphicsRequirements(next_runtime));
Require(next_backend.BindAurora(next_runtime));
Require(next_backend.PreparePacket(presentation, packet) == OpenXRBeginStatus::Ready);
encoded = true;
if (scenario == 1) {
fail_end = true;
Require(next_backend.KeepAliveCycle() == OpenXRBeginStatus::Error);
} else if (scenario == 2) {
stop_session = true;
next_runtime.PollEvents();
Require(next_backend.KeepAliveCycle() == OpenXRBeginStatus::SessionNotRunning);
}
Require(!compositor_open);
Require(next_backend.Shutdown() && live_swapchains == 0);
}
expect_render_first = false;
display_time = 0;
}
int main() {
TestRenderFirst();
OpenXRRuntime runtime;
OpenXRD3D12Backend backend;
Require(backend.QueryGraphicsRequirements(runtime) && backend.BindAurora(runtime));
Require(live_swapchains == 4);
OpenXRD3D12Presentation presentation;
OpenXRD3D12Frame frame;
const auto begin = [&] {
Require(backend.BeginFrame(presentation, frame) == OpenXRD3D12BeginStatus::Ready);
};
const auto finish = [&] {
Complete();
Require(backend.WaitForSubmission(frame, 0) == OpenXRD3D12SubmissionStatus::Success);
Require(backend.FinishFrame(frame, true));
};
begin();
Require(backend.RepeatFrame(frame) && layer_count == 0); // No valid image yet.
finish();
const auto first = displayed_content;
begin();
encoded = true;
const auto releases_before_stall = releases;
for (int i = 0; i < 300; ++i) {
Require(backend.WaitForSubmission(frame, 0) == OpenXRD3D12SubmissionStatus::Timeout);
Require(!backend.TryCancelPendingFrame(frame));
Require(backend.RepeatFrame(frame));
Require(layer_count == 1 && displayed_content == first);
Require(releases == releases_before_stall);
}
const auto second = frame.xr_frame.serial;
finish(); // A late completion keeps its original render token and poses.
Require(displayed_content == second);
begin();
Require(backend.TryCancelPendingFrame(frame));
Require(backend.FinishFrame(frame, false));
Require(layer_count == 1 && displayed_content == second);
should_render = false;
begin();
Require(!frame.expects_gpu_submission && backend.FinishFrame(frame, false));
Require(layer_count == 0);
should_render = true;
tracking_valid = false;
begin();
Require(!frame.expects_gpu_submission && backend.FinishFrame(frame, false));
Require(layer_count == 1 && displayed_content == second);
tracking_valid = true;
presentation.mode = OpenXRD3D12FrameMode::VirtualScreen;
presentation.quad_anchored = true;
presentation.quad_pose.position.z = -3;
begin();
Require(targets.size() == 1);
finish();
const auto menu = displayed_content;
begin();
Require(backend.RepeatFrame(frame));
Require(displayed_content == menu && quad_pose.position.z == -3);
Require(backend.TryCancelPendingFrame(frame) && backend.FinishFrame(frame, false));
presentation.mode = OpenXRD3D12FrameMode::ImmersiveProjection;
begin();
change_space = true;
Require(backend.RepeatFrame(frame));
Require(backend.RepeatFrame(frame) && layer_count == 0);
finish(); // A render from before the space change must remain invalid.
Require(layer_count == 0);
begin();
finish();
Require(layer_count == 1);
restart_session = true;
runtime.PollEvents();
begin();
Require(backend.RepeatFrame(frame) && layer_count == 0);
finish();
Require(layer_count == 1);
begin();
const auto before_failure = releases;
Complete(false);
Require(backend.WaitForSubmission(frame, 0) == OpenXRD3D12SubmissionStatus::Failed);
Require(!backend.FinishFrame(frame, false));
Require(releases == before_failure); // Never release possibly in-flight GPU work.
Require(backend.Shutdown() && live_swapchains == 0);
// A runtime stop or failed xrEndFrame during a stall must still drain the
// pending target without trying to end an already consumed frame token.
for (bool fail_submission : {false, true}) {
display_time = 0;
OpenXRRuntime next_runtime;
OpenXRD3D12Backend next_backend;
Require(next_backend.QueryGraphicsRequirements(next_runtime));
Require(next_backend.BindAurora(next_runtime));
Require(next_backend.BeginFrame(presentation, frame) == OpenXRD3D12BeginStatus::Ready);
encoded = true;
stop_session = !fail_submission;
fail_end = fail_submission;
Require(!next_backend.RepeatFrame(frame));
Require(next_backend.Shutdown() && live_swapchains == 0);
}
std::cout << "OpenXR retained-frame tests passed\n";
}