diff --git a/README-PC-VR.md b/README-PC-VR.md index 2e9625c..083d86e 100644 --- a/README-PC-VR.md +++ b/README-PC-VR.md @@ -78,6 +78,12 @@ or SteamVR motion smoothing. - Upstream now provides the SteamVR address-space reservation and runtime-specific idle recovery behavior; those implementations are retained instead of duplicate fork patches. +### Selected elliotttate performance port (candidate) + +This candidate adds pipeline-cache preload repairs and bounded late-frame recovery. See +[the port notes](docs/elliotttate-performance-port.md) for scope, attribution and limitations. +Set `env=SHADPS4_XR_LATE_WAIT_MS=0` in settings.txt to disable late-frame waiting. + ### Precise VR presentation pacing (experimental) VR early-flip presentation now uses a reusable Windows high-resolution waitable timer @@ -838,3 +844,4 @@ bash tools/make-pc-vr.sh # cop The OpenXR loader (Khronos OpenXR-SDK 1.1.63, `externals/openxr-sdk`) is built with the emulator; `ENABLE_OPENXR` (on for Windows) switches the whole of it. + diff --git a/docs/elliotttate-performance-port.md b/docs/elliotttate-performance-port.md new file mode 100644 index 0000000..bf3faaa --- /dev/null +++ b/docs/elliotttate-performance-port.md @@ -0,0 +1,25 @@ +# Selected elliotttate performance port + +Reviewed on 2026-10-07: [elliotttate/AstroQuest at b72db1b](https://github.com/elliotttate/AstroQuest/tree/b72db1b). +[Their measurements and limitations](https://github.com/elliotttate/AstroQuest/blob/b72db1b/docs/pc-performance.md). + +Adapted pipeline-cache repairs and late-frame recovery from `6af3210`, preserving the original license headers. This candidate retains upstream 0.20, the soccer-enemy fix, Full framerate selector, expanded resolutions, separate headset queue and precise presentation timer. + +## Included + +Cached image/sampler lists now contain their storage, so a new process does not load pointers from the process that wrote the cache. Ordinary descriptor layouts avoid reading guest descriptors during preloading; dynamic-mip layouts requiring guest memory are deferred. Vertex-fetch data survives later shader stages, and shader permutations point at the persistent cached program's information. + +Metadata and pipeline-key versions are incremented to reject the former layout. Upstream 0.20's shader-binary version 3 (geometry-input ABI fix) is preserved. Separate cold/warm processes test cache reuse before enabling it in the installed configuration. + +When a recent stream's next frame is missing, the OpenXR thread waits briefly before repeating the last picture. The budget starts at xrWaitFrame wakeup, defaults to 3 ms, and is configurable with `SHADPS4_XR_LATE_WAIT_MS` (0 disables, maximum 6). Idle streams and pictures the runtime does not want are skipped. No Vulkan queue lock is held. The log counts recovered pictures and timeouts. + +The initial condition-variable port's nominal 3-ms timeout took 15-16 ms on this PC. The Windows adaptation uses a producer event and a high-resolution timeout timer; measured timeout was about 3.5 ms. Arriving frames can wake it immediately. Unsupported precise timers disable waiting instead of using a coarse timeout. This is a scheduling budget, not a hard real-time guarantee. + +## Kept separate + +- Their native 90/120 engine mode is promising, but their 120 measurements use a simulator and game-1.00 offsets. The 90 mode is unmeasured and 1.04 offsets are unverified. It is not enabled here. +- Their guest HR-timer scheduler addresses the game's 3-ms tick, with broader cancellation/lifetime implications than the presentation timer already in this fork. +- Parallel texture copies, stream/shader memoization and lock-skipping are not imported wholesale. Their notes document unresolved memory corruption near the parallel-copy/shader-memo work; no causal fix is established. +- Their 32 presentation polls and high-resolution sleeps overlap with this fork. Their new delivery phase was measured at simulated 120 Hz; this fork retains its phase until a real-runtime comparison. + +Startup checks do not establish improved headset gameplay. The existing regular release remains available for comparison. diff --git a/shadps4-arm64-main/src/common/windows_sleep_timer.h b/shadps4-arm64-main/src/common/windows_sleep_timer.h index b07c387..3b47106 100644 --- a/shadps4-arm64-main/src/common/windows_sleep_timer.h +++ b/shadps4-arm64-main/src/common/windows_sleep_timer.h @@ -13,6 +13,7 @@ public: timer = CreateWaitableTimerExW(nullptr, nullptr, high_resolution ? CREATE_WAITABLE_TIMER_HIGH_RESOLUTION : 0, TIMER_MODIFY_STATE | SYNCHRONIZE); + precise = timer && high_resolution; if (!timer && high_resolution) { timer = CreateWaitableTimerExW(nullptr, nullptr, 0, TIMER_MODIFY_STATE | SYNCHRONIZE); } @@ -26,16 +27,27 @@ public: DWORD Wait(std::chrono::nanoseconds duration, bool interruptible) { if (duration.count() <= 0) return WAIT_OBJECT_0; - if (!timer) - return WAIT_FAILED; - LARGE_INTEGER interval; - interval.QuadPart = -std::max(1, duration.count() / 100); - if (!SetWaitableTimer(timer, &interval, 0, nullptr, nullptr, FALSE)) + if (!Arm(duration)) return WAIT_FAILED; return WaitForSingleObjectEx(timer, INFINITE, interruptible); } + bool Arm(std::chrono::nanoseconds duration) { + if (!timer) + return false; + LARGE_INTEGER interval; + interval.QuadPart = -std::max(1, duration.count() / 100); + return SetWaitableTimer(timer, &interval, 0, nullptr, nullptr, FALSE) != FALSE; + } + HANDLE Handle() const { + return timer; + } + bool IsHighResolution() const { + return precise; + } + private: HANDLE timer{}; + bool precise{}; }; } // namespace Common diff --git a/shadps4-arm64-main/src/core/vr/late_frame_wait.h b/shadps4-arm64-main/src/core/vr/late_frame_wait.h new file mode 100644 index 0000000..a3934b2 --- /dev/null +++ b/shadps4-arm64-main/src/core/vr/late_frame_wait.h @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once +#include +#include +#include +#ifdef _WIN32 +#include "common/windows_sleep_timer.h" +#endif + +namespace Core::Vr { +// Producer notification wakes immediately; the Windows timeout uses its own high- +// resolution timer instead of the coarse timeout of SleepConditionVariableSRW. +class LateFrameSignal { +public: +#ifdef _WIN32 + LateFrameSignal() : event{CreateEventW(nullptr, FALSE, FALSE, nullptr)} {} + ~LateFrameSignal() { + if (event) + CloseHandle(event); + } + void Notify() { + if (event) + SetEvent(event); + } +#else + void Notify() { + delivered.notify_one(); + } +#endif + LateFrameSignal(const LateFrameSignal&) = delete; + LateFrameSignal& operator=(const LateFrameSignal&) = delete; +#ifndef _WIN32 + LateFrameSignal() = default; +#endif + template + bool WaitUntil(std::unique_lock& lock, std::chrono::steady_clock::time_point until, + Ready ready) { +#ifdef _WIN32 + while (!ready()) { + const auto remaining = until - std::chrono::steady_clock::now(); + // Unsupported timers or allocation failure disable waiting, rather than use + // a coarse timeout that can consume a whole headset refresh. + if (remaining.count() <= 0 || !event || !timer.IsHighResolution()) + return false; + // Producer updates readiness and notifies under the same mutex. Resetting + // before unlocking cannot lose a delivery between the predicate and wait. + ResetEvent(event); + if (!timer.Arm(std::chrono::duration_cast(remaining))) + return false; + const HANDLE handles[]{event, timer.Handle()}; + lock.unlock(); + const DWORD result = WaitForMultipleObjects(2, handles, FALSE, INFINITE); + lock.lock(); + if (ready()) + return true; + if (result != WAIT_OBJECT_0) + return false; + } + return true; +#else + return delivered.wait_until(lock, until, ready); +#endif + } + +private: +#ifdef _WIN32 + HANDLE event{}; + Common::WindowsSleepTimer timer; +#else + std::condition_variable delivered; +#endif +}; + +enum class LateFrameResult { Skipped, Ready, TimedOut }; + +// Budget begins at the runtime wakeup. The predicate is always checked first so a +// delivery between the initial TakeFrame and taking the mutex is not lost. +template +LateFrameResult WaitForLateFrame(LateFrameSignal& delivered, std::unique_lock& lock, + std::chrono::steady_clock::time_point woke, + std::chrono::steady_clock::time_point last_delivery, float rate, + float wait_ms, Ready ready) { + using Clock = std::chrono::steady_clock; + if (ready()) + return LateFrameResult::Ready; + if (!(wait_ms > 0.0f && rate > 30.0f) || last_delivery == Clock::time_point{} || + woke - last_delivery >= std::chrono::duration(2.0 / rate)) { + return LateFrameResult::Skipped; + } + const auto until = woke + std::chrono::duration_cast( + std::chrono::duration(wait_ms)); + if (Clock::now() >= until) + return LateFrameResult::TimedOut; + return delivered.WaitUntil(lock, until, ready) ? LateFrameResult::Ready + : LateFrameResult::TimedOut; +} +} // namespace Core::Vr diff --git a/shadps4-arm64-main/src/core/vr/openxr_host.cpp b/shadps4-arm64-main/src/core/vr/openxr_host.cpp index e41395a..2e623c0 100644 --- a/shadps4-arm64-main/src/core/vr/openxr_host.cpp +++ b/shadps4-arm64-main/src/core/vr/openxr_host.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include "common/logging/log.h" +#include "core/vr/late_frame_wait.h" #include "common/polyfill_thread.h" #include "common/singleton.h" #include "common/thread.h" @@ -391,6 +393,7 @@ struct OpenXrHost::Impl { // The title's frames on their way here. std::mutex slot_mutex; + LateFrameSignal delivered_cv; std::array slots; s32 latest{-1}; u32 next_slot{}; @@ -478,6 +481,8 @@ struct OpenXrHost::Impl { u32 display_frames{}; u32 reported_delivered{}; u32 copied_frames{}; + u32 late_frames{}; + u32 late_wait_timeouts{}; u32 head_tracked_frames{}; u32 palm_samples{}; float palm_distance{}; @@ -1753,7 +1758,25 @@ struct OpenXrHost::Impl { AskAudioDevicesIfDue(); UpdatePad(pose_time); - if (const s32 index = TakeFrame(); index >= 0) { + s32 taken = TakeFrame(); + // From elliotttate/AstroQuest: a frame a little late need not become a repeat. + // Budget is measured from xrWaitFrame wakeup, not from the start of this wait, + // and no Vulkan queue lock is held while the producer is allowed to finish. + static const float late_wait_ms = + std::clamp(EnvFloat("SHADPS4_XR_LATE_WAIT_MS", 3.0f), 0.0f, 6.0f); + if (taken < 0 && late_wait_ms > 0.0f && have_frame && + frame_state.shouldRender == XR_TRUE && refresh_rate > 30.0f) { + std::unique_lock lock{slot_mutex}; + const auto result = WaitForLateFrame(delivered_cv, lock, woke, last_delivery, + refresh_rate, late_wait_ms, [this] { + return latest >= 0 && slots[latest].state == Slot::State::Ready; + }); + lock.unlock(); + taken = TakeFrame(); + if (taken >= 0) ++late_frames; + else if (result == LateFrameResult::TimedOut) ++late_wait_timeouts; + } + if (const s32 index = taken; index >= 0) { const auto started = Clock::now(); bool blank = false; const bool copied = CopyFrame(static_cast(index), blank); @@ -2466,6 +2489,10 @@ struct OpenXrHost::Impl { "({:.2f} ms at worst)", graphics.queue_index, end_lock_time / frames * 1e3, end_call_time / frames * 1e3, end_call_worst * 1e3); + LOG_INFO(Core_Vr, "Late-frame recovery: {} pictures recovered, {} waits timed out", + late_frames, late_wait_timeouts); + late_frames = 0; + late_wait_timeouts = 0; if ((has_hand_tracking || (use_grips && actions_ready)) && !controllers_used) { const float held = pad_samples != 0 ? 1.0f / static_cast(pad_samples) : 0.0f; LOG_INFO(Core_Vr, @@ -2841,6 +2868,7 @@ void OpenXrHost::EndFrame(u32 index, const PresentedFrame& info, vk::Semaphore r } } impl->last_delivery = now; + impl->delivered_cv.Notify(); } void OpenXrHost::DropFrame(u32 index) { diff --git a/shadps4-arm64-main/src/shader_recompiler/resource.h b/shadps4-arm64-main/src/shader_recompiler/resource.h index 2ac5463..117f7b1 100644 --- a/shadps4-arm64-main/src/shader_recompiler/resource.h +++ b/shadps4-arm64-main/src/shader_recompiler/resource.h @@ -125,13 +125,20 @@ struct ImageResource { } u32 NumBindings(const auto& info) const { + // Only an image whose mips are indexed in the shader needs its sharp for this: a + // pipeline made from the cache as the emulator starts has no sharps to read (the + // title's memory holds nothing yet). + if (mip_fallback_mode != MipStorageFallbackMode::DynamicIndex) { + return 1; + } const AmdGpu::Image tsharp = GetSharp(info); - return (mip_fallback_mode == MipStorageFallbackMode::DynamicIndex) - ? (tsharp.last_level - tsharp.base_level + 1) - : 1; + return tsharp.last_level - tsharp.base_level + 1; } }; -using ImageResourceList = boost::container::small_vector; +// (Fixed capacity, like the other lists: the pipeline cache stores Shader::InfoPersistent as its +// bytes, and a small_vector's bytes hold a pointer to its elements, which pointed into the +// process that wrote the cache.) +using ImageResourceList = boost::container::static_vector; struct SamplerResource { u32 sharp_idx; @@ -145,7 +152,7 @@ struct SamplerResource { : info.template ReadUdSharp(sharp_idx); } }; -using SamplerResourceList = boost::container::small_vector; +using SamplerResourceList = boost::container::static_vector; struct FMaskResource { u32 sharp_idx; diff --git a/shadps4-arm64-main/src/video_core/renderer_vulkan/vk_pipeline_cache.h b/shadps4-arm64-main/src/video_core/renderer_vulkan/vk_pipeline_cache.h index 83ecd02..44b866a 100644 --- a/shadps4-arm64-main/src/video_core/renderer_vulkan/vk_pipeline_cache.h +++ b/shadps4-arm64-main/src/video_core/renderer_vulkan/vk_pipeline_cache.h @@ -136,6 +136,8 @@ private: std::array infos{}; std::array modules{}; std::optional fetch_shader{}; + // Preloading must retain vertex fetch data across later shader stages. + std::optional preload_fetch_shader{}; GraphicsPipelineKey graphics_key{}; u8 targets_left_out{}; ComputePipelineKey compute_key{}; diff --git a/shadps4-arm64-main/src/video_core/renderer_vulkan/vk_pipeline_serialization.cpp b/shadps4-arm64-main/src/video_core/renderer_vulkan/vk_pipeline_serialization.cpp index 1a71b32..1405d43 100644 --- a/shadps4-arm64-main/src/video_core/renderer_vulkan/vk_pipeline_serialization.cpp +++ b/shadps4-arm64-main/src/video_core/renderer_vulkan/vk_pipeline_serialization.cpp @@ -14,10 +14,11 @@ namespace Serialization { /* You should increment versions below once corresponding serialization scheme is changed. */ -// Recompile cached shaders with the corrected geometry input ABI and invocation ID. +// Preserve upstream 0.20 geometry-input ABI and invocation-ID correction. static constexpr u32 ShaderBinaryVersion = 3u; -static constexpr u32 ShaderMetaVersion = 2u; -static constexpr u32 PipelineKeyVersion = 2u; +// Inline image/sampler lists replace pointer-containing small vectors. +static constexpr u32 ShaderMetaVersion = 3u; +static constexpr u32 PipelineKeyVersion = 3u; } // namespace Serialization namespace Vulkan { @@ -143,6 +144,16 @@ bool ComputePipeline::SerializationSupport::Deserialize(Serialization::Archive& return true; } +// A pipeline whose descriptor layout depends on the title's image sharps (an image whose mips +// the shader indexes: a binding for each) cannot be made before the title runs: it is not +// preloaded but compiled when the title first uses it. (Reading the sharps as the emulator +// starts read the title's memory before there was any.) +static bool UsesSharpsToLayOut(const Shader::Info& info) { + return std::ranges::any_of(info.images, [](const Shader::ImageResource& image) { + return image.mip_fallback_mode == Shader::MipStorageFallbackMode::DynamicIndex; + }); +} + bool PipelineCache::LoadComputePipeline(Serialization::Archive& ar) { compute_key.Deserialize(ar); @@ -161,6 +172,11 @@ bool PipelineCache::LoadComputePipeline(Serialization::Archive& ar) { if (!LoadPipelineStage(meta_ar, 0)) { return false; } + if (UsesSharpsToLayOut(*infos[0])) { + infos.fill(nullptr); + modules.fill(nullptr); + return false; + } const auto [it, is_new] = compute_pipelines.try_emplace(compute_key); ASSERT(is_new); @@ -212,6 +228,7 @@ bool GraphicsPipeline::SerializationSupport::Deserialize(Serialization::Archive& } bool PipelineCache::LoadGraphicsPipeline(Serialization::Archive& ar) { + preload_fetch_shader.reset(); graphics_key.Deserialize(ar); GraphicsPipeline::SerializationSupport sdata{}; @@ -233,20 +250,44 @@ bool PipelineCache::LoadGraphicsPipeline(Serialization::Archive& ar) { Serialization::Archive meta_ar{std::move(meta_blob)}; if (!LoadPipelineStage(meta_ar, stage_idx)) { + infos.fill(nullptr); + modules.fill(nullptr); + preload_fetch_shader.reset(); return false; } } + if (const auto* vs_info = infos[u32(Shader::LogicalStage::Vertex)]; + vs_info && vs_info->has_fetch_shader && !preload_fetch_shader) { + // A pipeline that would draw without its vertex input is not preloaded: it is compiled + // when the title first draws with it, from the fetch shader it uses then. + LOG_WARNING(Render_Vulkan, + "Not preloading a pipeline: vertex shader {:#x} has a fetch shader, but the " + "cache has no fetch data for it", + vs_info->pgm_hash); + infos.fill(nullptr); + modules.fill(nullptr); + return false; + } + + if (std::ranges::any_of(infos, [](const Shader::Info* info) { + return info != nullptr && UsesSharpsToLayOut(*info); + })) { + infos.fill(nullptr); + modules.fill(nullptr); + preload_fetch_shader.reset(); + return false; + } const auto [it, is_new] = graphics_pipelines.try_emplace(graphics_key); ASSERT(is_new); it.value() = std::make_unique( instance, scheduler, desc_heap, profile, graphics_key, *pipeline_cache, infos, - runtime_infos, fetch_shader, modules, sdata, true); + runtime_infos, preload_fetch_shader, modules, sdata, true); infos.fill(nullptr); modules.fill(nullptr); - fetch_shader.reset(); + preload_fetch_shader.reset(); return true; } @@ -256,9 +297,15 @@ bool PipelineCache::LoadPipelineStage(Serialization::Archive& ar, size_t stage) Shader::StageSpecialization spec{}; spec.info = &program->info; size_t perm_idx{}; - if (!LoadShaderMeta(ar, program->info, fetch_shader, spec, perm_idx)) { + std::optional stage_fetch{}; + if (!LoadShaderMeta(ar, program->info, stage_fetch, spec, perm_idx)) { return false; } + // Only the stage that has a fetch shader (the vertex stage) sets the pipeline's fetch data, + // as at runtime. A later stage, a geometry shader above all, has none and must not clear it. + if (stage_fetch) { + preload_fetch_shader = std::move(stage_fetch); + } std::vector spv{}; Storage::DataBase::Instance().Load(Storage::BlobType::ShaderBinary, @@ -278,6 +325,9 @@ bool PipelineCache::LoadPipelineStage(Serialization::Archive& ar, size_t stage) module = CompileSPV(spv, instance.GetDevice()); it_pgm.value() = std::move(program); } else { + // The permutation joins the program already cached: its specialization must point at + // that program's info, not at the one this load made, which is gone when it returns. + spec.info = &it_pgm.value()->info; const auto& it = std::ranges::find(it_pgm.value()->modules, spec, &Program::Module::spec); if (it != it_pgm.value()->modules.end()) { // If the permutation is already preloaded, make sure it has the same permutation index diff --git a/tools/tests/late_frame_wait_test.cpp b/tools/tests/late_frame_wait_test.cpp new file mode 100644 index 0000000..1613118 --- /dev/null +++ b/tools/tests/late_frame_wait_test.cpp @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later +#include "core/vr/late_frame_wait.h" +#include +#include +#include +#include +using namespace std::chrono_literals; +using Clock = std::chrono::steady_clock; +using Core::Vr::LateFrameResult; +int main() { + int failures = 0; + std::mutex mutex; + Core::Vr::LateFrameSignal delivered; + bool ready = false; + auto run = [&](auto woke, auto last, float ms) { + std::unique_lock lock{mutex}; + return Core::Vr::WaitForLateFrame(delivered, lock, woke, last, 90, ms, + [&] { return ready; }); + }; + auto now = Clock::now(); + if (run(now, now, 0) != LateFrameResult::Skipped) + ++failures; + if (run(now, now - 1s, 3) != LateFrameResult::Skipped) + ++failures; + if (run(now, Clock::time_point{}, 3) != LateFrameResult::Skipped) + ++failures; + ready = true; + if (run(now - 1s, now, 3) != LateFrameResult::Ready) + ++failures; + ready = false; + now = Clock::now(); + if (run(now - 10ms, now - 11ms, 3) != LateFrameResult::TimedOut) + ++failures; + std::jthread producer{[&] { + std::this_thread::sleep_for(1ms); + std::scoped_lock lock{mutex}; + ready = true; + delivered.Notify(); + }}; + now = Clock::now(); + if (run(now, now, 30) != LateFrameResult::Ready) + ++failures; + producer.join(); + ready = false; + std::jthread spurious{[&] { + std::this_thread::sleep_for(1ms); + delivered.Notify(); + }}; + now = Clock::now(); + if (run(now, now, 3) != LateFrameResult::TimedOut) + ++failures; + spurious.join(); + std::vector waits; + for (int i = 0; i < 50; ++i) { + now = Clock::now(); + if (run(now, now, 3) != LateFrameResult::TimedOut) + ++failures; + waits.push_back( + std::chrono::duration(Clock::now() - now).count()); + } + std::sort(waits.begin(), waits.end()); + printf("3-ms timeout: median %.3f p95 %.3f max %.3f ms\n", waits[25], + waits[47], waits.back()); + printf("late-frame disabled/stale/race/deadline/arrival/spurious checks: %d " + "failures\n", + failures); + return failures ? 1 : 0; +} diff --git a/tools/tests/pipeline_resource_relocation_test.cpp b/tools/tests/pipeline_resource_relocation_test.cpp new file mode 100644 index 0000000..1c21f0d --- /dev/null +++ b/tools/tests/pipeline_resource_relocation_test.cpp @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later +#include "shader_recompiler/info.h" +#include +#include +#include +#include +int main() { + auto source = std::make_unique(); + for (unsigned i = 0; i < Shader::NUM_IMAGES; ++i) + source->images.push_back(Shader::ImageResource{.sharp_idx = i + 17}); + for (unsigned i = 0; i < Shader::NUM_SAMPLERS; ++i) + source->samplers.push_back(Shader::SamplerResource{.sharp_idx = i + 91}); + std::vector bytes(sizeof(*source)); + std::memcpy(bytes.data(), source.get(), bytes.size()); + auto restored = std::make_unique(); + std::memcpy(restored.get(), bytes.data(), bytes.size()); + source.reset(); // Original process/object storage is gone, as on a warm cache + // launch. + int failures = 0; + auto inside = [&](const void *p) { + auto at = reinterpret_cast(p), + begin = reinterpret_cast(restored.get()); + return at >= begin && at < begin + sizeof(*restored); + }; + if (!inside(restored->images.data()) || !inside(restored->samplers.data())) + ++failures; + if (restored->images.size() != Shader::NUM_IMAGES || + restored->samplers.size() != Shader::NUM_SAMPLERS) + ++failures; + for (unsigned i = 0; i < Shader::NUM_IMAGES; ++i) + if (restored->images[i].sharp_idx != i + 17) + ++failures; + for (unsigned i = 0; i < Shader::NUM_SAMPLERS; ++i) + if (restored->samplers[i].sharp_idx != i + 91) + ++failures; + printf("persistent shader image/sampler lists survive byte relocation and " + "original destruction at full capacity: %d failures\n", + failures); + return failures ? 1 : 0; +}