diff --git a/docs/STEREO_RENDERING.md b/docs/STEREO_RENDERING.md index 87ade4da..86ee68f5 100644 --- a/docs/STEREO_RENDERING.md +++ b/docs/STEREO_RENDERING.md @@ -92,3 +92,27 @@ game-specific and must be calibrated independently. This API also works without a headset backend; it does not enable a headset mode by default. The optional headset backend is described in [OPENXR_RENDERING.md](OPENXR_RENDERING.md). + + +## Production startup and performance receipts + +`PSX_OPENXR_READY_FILE=` requests one JSON startup receipt, including +in builds with `PSX_DEBUG_TOOLS=OFF`. Main publishes through a same-directory +temporary and atomic replacement, so partial JSON is never visible. An existing +destination is refused at startup; the caller owns a fresh path and removes it +afterward. Schema 2 records XR session generations, counters and the savestate +receipt. When `PSX_LOAD_SLOT` is set, success requires that load to settle +successfully and a later submission from the current XR session. A failed load +publishes `ready:false` with `state_load_failed`; session re-init publishes a +new receipt after its first current-session submission. An invalid, refused, or +netplay-disabled load request also publishes `ready:false` with a specific error +instead of being treated as if no load were requested. A game-session reboot +starts a fresh load lifecycle, so an earlier success cannot authorize the new +session; the newly staged `PSX_LOAD_SLOT` must settle before readiness can be +published. Without the variable the reporter immediately becomes a no-op. + +`PSX_RUNTIME_PERF_DIAG=1` also prints VR pair/shed/empty/failure totals, the pair +EMA/last milliseconds and rollback verification totals at its existing configured +interval (`PSX_RUNTIME_PERF_DIAG_MS`). This exposes production behavior without +per-instruction debug tracing. The pair average is an EMA, not a percentile. +`PSX_FRAME_REPORT_MS` remains the production guest-frame cadence measurement. diff --git a/docs/internal/upstream/vr-production-readiness.md b/docs/internal/upstream/vr-production-readiness.md new file mode 100644 index 00000000..280e389d --- /dev/null +++ b/docs/internal/upstream/vr-production-readiness.md @@ -0,0 +1,28 @@ +# Report XR readiness without the debug server + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [5932092c](https://github.com/FractalEngineer/psxrecomp/commit/5932092cdf20aaa707e286dbd840c63b1f0d0598) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: this feature and its tests. The readiness receipt uses same-directory +temporary publication, refuses an existing destination, records XR session +generations, and requires a submission from the current session after a +requested load succeeds. Each game-session reboot starts a fresh load lifecycle; +an earlier success cannot authorize the rebooted session. A failed, invalid, or +refused load publishes a non-ready receipt without exposing partial JSON. +Excluded: other VR features, game policy, assets and chronological +investigation notes. +Base: `fef7a1b6e3e1be7511eb82fa3b512258beacba04` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release/OpenGL/OpenXR/PGXP MoH build passes with +debug tools OFF and existing UI. Atomic replacement is covered by a standalone +test; the readiness state-machine fixture covers success, XR re-init, reboot +with an old success, pending and failed reboot loads, recovery, and refused +requests. Fresh-boot headless readiness and missing-slot refusal pass. The older +slot-2 fixture failed to load on current upstream; no compatibility guard/header +was changed. + +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace). +Requires a framework pin bump and rebuild; no game/BIOS regeneration. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5badda9f..da100f89 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -50,6 +50,15 @@ if(BUILD_TESTING) add_test(NAME debug_socket_shutdown_test COMMAND debug_socket_shutdown_test) set_tests_properties(debug_socket_shutdown_test PROPERTIES TIMEOUT 15) + add_executable(atomic_file_write_test tests/test_atomic_file_write.cpp) + target_include_directories(atomic_file_write_test PRIVATE include) + target_compile_features(atomic_file_write_test PRIVATE cxx_std_17) + add_test(NAME atomic_file_write_test COMMAND atomic_file_write_test) + + add_executable(openxr_ready_state_test tests/test_openxr_ready_state.c) + target_include_directories(openxr_ready_state_test PRIVATE include) + add_test(NAME openxr_ready_state_test COMMAND openxr_ready_state_test) + add_executable(video_texture_filter_test tests/test_video_texture_filter.c src/video_texture_filter.c) target_include_directories(video_texture_filter_test PRIVATE include) add_test(NAME video_texture_filter_test COMMAND video_texture_filter_test) diff --git a/runtime/include/atomic_file_write.h b/runtime/include/atomic_file_write.h new file mode 100644 index 00000000..30495c3e --- /dev/null +++ b/runtime/include/atomic_file_write.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#endif + +namespace psx { + +/* Publish a complete file by writing a same-directory temporary and replacing + * the destination atomically. The destination is never observed half-written. */ +inline bool atomic_write_file(const std::filesystem::path& target, + std::string_view data, + std::string* error = nullptr) { + if (target.empty() || target.filename().empty()) { + if (error) *error = "destination has no filename"; + return false; + } + auto temporary = target; +#ifdef _WIN32 + temporary += "." + std::to_string(GetCurrentProcessId()) + ".tmp"; +#else + temporary += "." + std::to_string(getpid()) + ".tmp"; +#endif + { + std::ofstream out(temporary, std::ios::binary | std::ios::trunc); + out.write(data.data(), static_cast(data.size())); + out.flush(); + out.close(); + if (!out) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + if (error) *error = "temporary write failed"; + return false; + } + } +#ifdef _WIN32 + if (!MoveFileExW(temporary.c_str(), target.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + if (error) *error = "atomic replace failed"; + return false; + } +#else + std::error_code ec; + std::filesystem::rename(temporary, target, ec); + if (ec) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + if (error) *error = ec.message(); + return false; + } +#endif + return true; +} + +} // namespace psx diff --git a/runtime/include/openxr_ready_state.h b/runtime/include/openxr_ready_state.h new file mode 100644 index 00000000..13c3e6c4 --- /dev/null +++ b/runtime/include/openxr_ready_state.h @@ -0,0 +1,88 @@ +#pragma once + +#include + +typedef struct PSXOpenXRReadyLoadRequest { + uint64_t sequence; + uint64_t execution_generation; + uint32_t status_generation_before; + int slot; + const char *error; +} PSXOpenXRReadyLoadRequest; + +typedef struct PSXOpenXRReadyLoadStatus { + uint32_t generation; + int pending; + int last_ok; + int last_load; + int last_slot; +} PSXOpenXRReadyLoadStatus; + +typedef struct PSXOpenXRReadyLoadState { + uint64_t execution_generation; + uint64_t request_sequence; + uint32_t generation_floor; + int expected; + int request_seen; + int resolved; + int last_ok; + int slot; + const char *error; +} PSXOpenXRReadyLoadState; + +static inline void psx_openxr_ready_load_begin_execution( + PSXOpenXRReadyLoadState *state, uint64_t execution_generation, + int expected, int slot) { + if (!state) return; + state->execution_generation = execution_generation; + state->request_sequence = 0; + state->generation_floor = 0; + state->expected = !!expected; + state->request_seen = 0; + state->resolved = !state->expected; + state->last_ok = 0; + state->slot = slot; + state->error = 0; +} + +/* Returns nonzero when this is a new request for the current execution. */ +static inline int psx_openxr_ready_load_observe_request( + PSXOpenXRReadyLoadState *state, + const PSXOpenXRReadyLoadRequest *request) { + if (!state || !request || !state->expected || + request->execution_generation != state->execution_generation || + request->sequence == 0 || request->sequence == state->request_sequence) + return 0; + state->request_sequence = request->sequence; + state->request_seen = 1; + state->generation_floor = request->status_generation_before; + state->slot = request->slot; + state->error = request->error; + state->resolved = request->error != 0; + state->last_ok = 0; + return 1; +} + +/* Only a settled load after the request-generation floor can resolve success. */ +static inline int psx_openxr_ready_load_observe_status( + PSXOpenXRReadyLoadState *state, + const PSXOpenXRReadyLoadStatus *status) { + if (!state || !status || !state->expected || !state->request_seen || + state->resolved || + status->pending || status->generation <= state->generation_floor || + !status->last_load || status->last_slot != state->slot) + return 0; + state->resolved = 1; + state->last_ok = status->last_ok; + return 1; +} + +static inline int psx_openxr_ready_load_ok( + const PSXOpenXRReadyLoadState *state) { + return state && (!state->expected || (state->resolved && state->last_ok)); +} + +static inline int psx_openxr_ready_load_failed( + const PSXOpenXRReadyLoadState *state) { + return state && state->expected && state->resolved && !state->last_ok; +} diff --git a/runtime/include/psx_openxr.h b/runtime/include/psx_openxr.h index 86fd5e1a..1cacd4ea 100644 --- a/runtime/include/psx_openxr.h +++ b/runtime/include/psx_openxr.h @@ -10,6 +10,7 @@ typedef struct PSXOpenXRStats { uint64_t view_flags; const char *stage; uint64_t waits, submitted, empty, failures, predicted_time; + uint64_t session_generation, submitted_session_generation; float ipd_m; float origin_up_dot; uint32_t origin_inversion_streak, origin_ambiguous; diff --git a/runtime/include/savestate.h b/runtime/include/savestate.h index 9ea5e6f3..276127b1 100644 --- a/runtime/include/savestate.h +++ b/runtime/include/savestate.h @@ -128,6 +128,11 @@ int savestate_pending(void); /* Machine-readable lifecycle receipt for deterministic debug harnesses. */ void savestate_status_json(char* buf, size_t cap); +typedef struct PSXSavestateStatus { + uint32_t generation; + int pending, last_ok, last_load, last_slot; +} PSXSavestateStatus; +void savestate_get_status(PSXSavestateStatus* out); /* 1 once after a successful load restore (before scheduler longjmp). Clears. */ int savestate_take_load_completed(void); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 77e5e99d..f0f9bd85 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9088,6 +9088,7 @@ static void handle_openxr_stats(int id, const char *json) { "\"frame_open\":%u,\"result\":%d,\"stage\":\"%s\",\"runtime\":\"%s\"," "\"last_failure\":\"%s\",\"last_failure_result\":%d,\"view_flags\":%llu," "\"waits\":%llu,\"submitted\":%llu,\"empty\":%llu,\"failures\":%llu," + "\"session_generation\":%llu,\"submitted_session_generation\":%llu," "\"predicted_time\":%llu,\"ipd_m\":%.6f,\"origin_up_dot\":%.6f," "\"origin_inversion_streak\":%u,\"origin_ambiguous\":%u,\"units_per_meter\":%.6f," "\"gl_version\":%llu,\"min_gl_version\":%llu,\"max_gl_version\":%llu,\"swapchain_format\":%lld," @@ -9098,8 +9099,11 @@ static void handle_openxr_stats(int id, const char *json) { s.initialized,s.running,s.state,s.tracking,s.frame_open,s.result, s.stage?s.stage:"off",s.runtime,s.last_failure?s.last_failure:"",s.last_failure_result, (unsigned long long)s.view_flags,(unsigned long long)s.waits, - (unsigned long long)s.submitted,(unsigned long long)s.empty, - (unsigned long long)s.failures,(unsigned long long)s.predicted_time,s.ipd_m, + (unsigned long long)s.submitted,(unsigned long long)s.empty, + (unsigned long long)s.failures, + (unsigned long long)s.session_generation, + (unsigned long long)s.submitted_session_generation, + (unsigned long long)s.predicted_time,s.ipd_m, s.origin_up_dot,s.origin_inversion_streak,s.origin_ambiguous,s.units_per_meter, (unsigned long long)s.gl_version,(unsigned long long)s.min_gl_version, (unsigned long long)s.max_gl_version,(long long)s.swapchain_format,s.submitted_layer, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 6bf5eea9..2cc54da6 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -34,6 +34,9 @@ #include "starvation_ring.h" #include "load_accel.h" #include "savestate.h" +#include "psx_openxr.h" +#include "atomic_file_write.h" +#include "openxr_ready_state.h" #include "psx_rewind.h" #include "psx_savestate_menu.h" #include "host_osd.h" @@ -4803,6 +4806,145 @@ static void runtime_perf_section_end(uint64_t start, uint64_t *total) { * waits on the render queue), the render thread's busy share, the dynamic * resolution level and Smooth motion's generated / real presents per second. */ static uint64_t g_frame_report_wait_ticks = 0; /* offline pacer waits */ +static uint64_t g_openxr_ready_execution_generation = 0; +static PSXOpenXRReadyLoadRequest g_openxr_ready_load_request{}; + +static void openxr_ready_record_load_request(int slot, const char *error) { + PSXSavestateStatus status{}; + savestate_get_status(&status); + g_openxr_ready_load_request.sequence++; + if (g_openxr_ready_load_request.sequence == 0) + g_openxr_ready_load_request.sequence = 1; + g_openxr_ready_load_request.execution_generation = + g_openxr_ready_execution_generation; + g_openxr_ready_load_request.status_generation_before = status.generation; + g_openxr_ready_load_request.slot = slot; + g_openxr_ready_load_request.error = error; +} + +/* One opt-in startup receipt per XR session, including production builds without + * debug TCP. The destination must not exist at process start. */ +static void openxr_ready_report_tick(uint64_t frames) { + static bool initialized = false; + static bool requested = false; + static PSXOpenXRReadyLoadState load_state{}; + static uint64_t observed_execution = 0; + static uint64_t observed_session = 0, submission_floor = 0; + static bool published_for_session = false; + static uint64_t retry_at_ms = 0; + static std::filesystem::path path; + + if (!initialized) { + initialized = true; + const char *env_path = std::getenv("PSX_OPENXR_READY_FILE"); + if (!env_path || !*env_path) return; + path = env_path; + std::error_code ec; + const bool present = std::filesystem::exists(path, ec); + if (ec || present) { + std::fprintf(stderr, + "psxrecomp: refusing %s OpenXR readiness path '%s'\n", + ec ? "unreadable" : "existing", path.string().c_str()); + return; + } + requested = true; + const char *load = std::getenv("PSX_LOAD_SLOT"); + const bool load_expected = load && *load; + int load_slot = -1; + if (load_expected) { + char *end = nullptr; + const long parsed = std::strtol(load, &end, 10); + if (end && *end == '\0' && parsed >= 0 && parsed < SAVESTATE_SLOTS) + load_slot = static_cast(parsed); + } + psx_openxr_ready_load_begin_execution( + &load_state, g_openxr_ready_execution_generation, + load_expected, load_slot); + if (load_expected && load_slot < 0) + openxr_ready_record_load_request( + -1, "state_load_request_invalid"); + observed_execution = g_openxr_ready_execution_generation; + } + if (!requested) return; + + bool execution_changed = + observed_execution != g_openxr_ready_execution_generation; + if (execution_changed) { + psx_openxr_ready_load_begin_execution( + &load_state, g_openxr_ready_execution_generation, + load_state.expected, load_state.slot); + observed_execution = g_openxr_ready_execution_generation; + } + psx_openxr_ready_load_observe_request( + &load_state, &g_openxr_ready_load_request); + + PSXOpenXRStats xr{}; + psx_openxr_stats(&xr); + if (execution_changed) { + submission_floor = xr.submitted; + published_for_session = false; + } + if (xr.session_generation && xr.session_generation != observed_session) { + if (observed_session != 0) { + psx_openxr_ready_load_begin_execution( + &load_state, g_openxr_ready_execution_generation, + load_state.expected, load_state.slot); + psx_openxr_ready_load_observe_request( + &load_state, &g_openxr_ready_load_request); + } + observed_session = xr.session_generation; + submission_floor = xr.submitted; + published_for_session = false; + } + + PSXSavestateStatus current_state{}; + savestate_get_status(¤t_state); + PSXOpenXRReadyLoadStatus load_status = { + current_state.generation, current_state.pending, current_state.last_ok, + current_state.last_load, current_state.last_slot}; + if (psx_openxr_ready_load_observe_status(&load_state, &load_status)) + submission_floor = xr.submitted; + + const uint64_t now = SDL_GetTicks(); + if (now < retry_at_ms || published_for_session) return; + + const bool load_ok = psx_openxr_ready_load_ok(&load_state); + const bool fresh_submission = + xr.session_generation != 0 && + xr.submitted_session_generation == xr.session_generation && + xr.submitted > submission_floor; + const bool load_failed = psx_openxr_ready_load_failed(&load_state); + if (!load_failed && (!load_ok || !xr.enabled || !xr.running || !fresh_submission)) + return; + + char state[512]; + savestate_status_json(state, sizeof state); + char receipt[2048]; + std::snprintf(receipt, sizeof receipt, + "{\"schema\":2,\"ready\":%d,\"error\":\"%s\",\"frame\":%llu," + "\"openxr\":{\"session_generation\":%llu,\"submitted_session_generation\":%llu," + "\"running\":%u,\"tracking\":%u,\"submitted\":%llu," + "\"empty\":%llu,\"failures\":%llu},\"savestate\":{%s}}\n", + load_failed ? 0 : 1, + load_failed + ? (load_state.error ? load_state.error : "state_load_failed") + : "", + (unsigned long long)frames, + (unsigned long long)xr.session_generation, + (unsigned long long)xr.submitted_session_generation, + xr.running, xr.tracking, (unsigned long long)xr.submitted, + (unsigned long long)xr.empty, (unsigned long long)xr.failures, state); + std::string error; + if (psx::atomic_write_file(path, receipt, &error)) { + published_for_session = true; + } else { + retry_at_ms = now + 1000; + std::fprintf(stderr, + "psxrecomp: cannot publish OpenXR startup receipt '%s': %s\n", + path.string().c_str(), error.c_str()); + } +} + static void frame_report_tick(uint64_t frames) { static int interval_ms = -1; static uint64_t first_ticks = 0, last_ticks = 0, last_frames = 0; @@ -4946,6 +5088,22 @@ static void runtime_perf_diag_tick() { current.capture_triggers - last.capture_triggers, current.capture_overlays - last.capture_overlays, (unsigned long long)current.capture_last_dispatch_delta); + PSXOpenXRStats xr{}; + psx_openxr_stats(&xr); + if (xr.enabled) { + RenderStereoStats stereo{}; + RenderPassStats passes{}; + render_stereo_get_stats(&stereo); + render_pass_get_stats(&passes); + std::fprintf(stdout, + "psxrecomp: VR pairs=%llu shed=%llu empty=%llu failures=%llu " + "avg_pair_ms=%.3f last_pair_ms=%.3f verify=%llu mismatch=%llu\n", + (unsigned long long)stereo.pairs, (unsigned long long)stereo.shed, + (unsigned long long)xr.empty, (unsigned long long)xr.failures, + stereo.avg_pair_ms, stereo.last_pair_ms, + (unsigned long long)passes.verify_checks, + (unsigned long long)passes.verify_mismatch); + } std::fflush(stdout); last = current; last_spu = audio.tap_frames[AUDIO_TAP_SPU_OUT]; @@ -9036,6 +9194,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { { /* Outside every debug guard on purpose: production must be measurable. */ extern uint64_t s_frame_count; + openxr_ready_report_tick(s_frame_count); frame_report_tick(s_frame_count); } runtime_perf_frame_begin(); @@ -20209,6 +20368,9 @@ int main(int argc, char** argv) { DiscMenuSession disc_menu_session{}; session_reboot: + g_openxr_ready_execution_generation++; + if (g_openxr_ready_execution_generation == 0) + g_openxr_ready_execution_generation = 1; mod_runtime_set_mounted_disc_resolver(nullptr, nullptr); psx_disc_tray_reset_session(); /* Rematch after lobby soft-return re-enters here with updated net_cfg. */ @@ -21493,17 +21655,33 @@ session_reboot: psx_rewind_set_depth((uint32_t)g_rewind_depth); psx_rewind_set_interval((uint32_t)g_rewind_interval); psx_rewind_configure(memory_get_bios_checksum(), game_entry_pc); - /* Headless / agent load: PSX_LOAD_SLOT=N stages slot load (0..11) + /* Headless load: PSX_LOAD_SLOT=N stages slot load (0..11) * at the next safe block boundary after boot. */ - if (const char *ls = std::getenv("PSX_LOAD_SLOT")) { - int slot = atoi(ls); - if (slot >= 0 && slot < 12) { + if (const char *ls = std::getenv("PSX_LOAD_SLOT"); ls && *ls) { + char *end = nullptr; + const long parsed = std::strtol(ls, &end, 10); + if (!end || *end != '\0' || parsed < 0 || parsed >= SAVESTATE_SLOTS) { + openxr_ready_record_load_request( + -1, "state_load_request_invalid"); + std::fprintf(stderr, + "psxrecomp: PSX_LOAD_SLOT=%s ignored (expected 0..%d)\n", + ls, SAVESTATE_SLOTS - 1); + } else { + const int slot = static_cast(parsed); if (psx_netplay_active()) { + openxr_ready_record_load_request( + slot, "state_load_unavailable_netplay"); std::fprintf(stdout, "psxrecomp: PSX_LOAD_SLOT=%d ignored (use the save-state menu after netplay starts)\n", slot); } else if (savestate_request_load(slot)) { + openxr_ready_record_load_request(slot, nullptr); std::fprintf(stdout, "psxrecomp: PSX_LOAD_SLOT=%d staged\n", slot); + } else { + openxr_ready_record_load_request( + slot, "state_load_request_refused"); + std::fprintf(stderr, + "psxrecomp: PSX_LOAD_SLOT=%d could not be staged\n", slot); } } } diff --git a/runtime/src/psx_openxr.c b/runtime/src/psx_openxr.c index 26b44ccd..657a317e 100644 --- a/runtime/src/psx_openxr.c +++ b/runtime/src/psx_openxr.c @@ -303,6 +303,7 @@ static int initialize(void) { if(!binding.hDC || !binding.hGLRC){s_stats.stage="opengl_context";return 0;} XrSessionCreateInfo sci={XR_TYPE_SESSION_CREATE_INFO};sci.next=&binding;sci.systemId=s_system; if(!check(xrCreateSession(s_instance,&sci,&s_session),"session"))return 0; + if(++s_stats.session_generation==0)s_stats.session_generation=1; if(!input_initialize())return 0; XrReferenceSpaceCreateInfo space={XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; space.referenceSpaceType=XR_REFERENCE_SPACE_TYPE_LOCAL;space.poseInReferenceSpace.orientation.w=1; @@ -798,6 +799,7 @@ int psx_openxr_end(int keep,PSXOpenXRCopy copy) { xr_trace_us(gl_renderer_perf_ticks()-s_pose_sample_ticks)); psx_frame_trace_xr_cadence(s_display_delta_us, complete); if(ok && complete) { + s_stats.submitted_session_generation=s_stats.session_generation; s_stats.submitted_surfaces=quad?0:((s_surface_mask&1)!=0)+((s_surface_mask&2)!=0); s_stats.submitted++;s_stats.submitted_pair_id=s_pair_id; s_stats.submitted_guest_cycle=s_pair_cycle;s_stats.submitted_predicted_time=(uint64_t)s_time; diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index 8251c4e0..4ea99231 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -873,6 +873,15 @@ int savestate_pending(void) { return (s_save_pending >= 0 || s_load_pending >= 0) ? 1 : 0; } +void savestate_get_status(PSXSavestateStatus* out) { + if (!out) return; + out->generation = s_status_generation; + out->pending = s_status_pending; + out->last_ok = s_status_last_ok; + out->last_load = s_status_last_load; + out->last_slot = s_status_last_slot; +} + void savestate_status_json(char* buf, size_t cap) { if (!buf || cap == 0) return; snprintf(buf, cap, diff --git a/runtime/tests/test_atomic_file_write.cpp b/runtime/tests/test_atomic_file_write.cpp new file mode 100644 index 00000000..cc976a4d --- /dev/null +++ b/runtime/tests/test_atomic_file_write.cpp @@ -0,0 +1,46 @@ +#include "atomic_file_write.h" + +#include +#include +#include +#include +#include + +static bool read_file(const std::filesystem::path& path, std::string* out) { + std::ifstream in(path, std::ios::binary); + if (!in) return false; + out->assign(std::istreambuf_iterator(in), std::istreambuf_iterator()); + return bool(in); +} + +int main() { + const auto nonce = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto root = std::filesystem::temp_directory_path() / + ("psx-atomic-write-" + std::to_string(nonce)); + std::filesystem::create_directories(root); + const auto target = root / "receipt.json"; + { + std::ofstream old(target, std::ios::binary); + old << "old"; + } + + std::string error; + if (!psx::atomic_write_file(target, "{\"complete\":true}\n", &error)) { + std::fprintf(stderr, "atomic write failed: %s\n", error.c_str()); + return 1; + } + std::string got; + if (!read_file(target, &got) || got != "{\"complete\":true}\n") { + std::fprintf(stderr, "destination was not atomically replaced\n"); + return 1; + } + for (const auto& entry : std::filesystem::directory_iterator(root)) { + if (entry.path() != target) { + std::fprintf(stderr, "temporary file leaked\n"); + return 1; + } + } + std::error_code ec; + std::filesystem::remove_all(root, ec); + return ec ? 1 : 0; +} diff --git a/runtime/tests/test_openxr_ready_state.c b/runtime/tests/test_openxr_ready_state.c new file mode 100644 index 00000000..c2631abb --- /dev/null +++ b/runtime/tests/test_openxr_ready_state.c @@ -0,0 +1,83 @@ +#include "openxr_ready_state.h" + +#include + +#define CHECK(v, m) do { if (!(v)) { \ + fprintf(stderr, "FAIL: %s\n", (m)); return 1; } } while (0) + +int main(void) { + PSXOpenXRReadyLoadState state = {0}; + PSXOpenXRReadyLoadRequest request = {0}; + PSXOpenXRReadyLoadStatus status = {0}; + + psx_openxr_ready_load_begin_execution(&state, 1, 0, -1); + CHECK(psx_openxr_ready_load_ok(&state), "no requested load is ready"); + + psx_openxr_ready_load_begin_execution(&state, 1, 1, 2); + CHECK(!psx_openxr_ready_load_ok(&state), "requested load waits for its request"); + + request = (PSXOpenXRReadyLoadRequest){1, 1, 10, 2, NULL}; + CHECK(psx_openxr_ready_load_observe_request(&state, &request), + "first load request is accepted"); + CHECK(!psx_openxr_ready_load_ok(&state), "pending request is not ready"); + status = (PSXOpenXRReadyLoadStatus){10, 1, 0, 1, 2}; + CHECK(!psx_openxr_ready_load_observe_status(&state, &status), + "pending request cannot resolve"); + status = (PSXOpenXRReadyLoadStatus){11, 0, 1, 1, 2}; + CHECK(psx_openxr_ready_load_observe_status(&state, &status), + "completed current request resolves"); + CHECK(psx_openxr_ready_load_ok(&state), "successful load is ready"); + + /* An XR session re-init in the same execution rebinds the current request. */ + psx_openxr_ready_load_begin_execution(&state, 1, 1, 2); + CHECK(!psx_openxr_ready_load_ok(&state), "session re-init clears readiness"); + request = (PSXOpenXRReadyLoadRequest){1, 1, 10, 2, NULL}; + CHECK(psx_openxr_ready_load_observe_request(&state, &request), + "current execution request is rebound"); + status = (PSXOpenXRReadyLoadStatus){11, 0, 1, 1, 2}; + CHECK(psx_openxr_ready_load_observe_status(&state, &status) && + psx_openxr_ready_load_ok(&state), + "current execution stays ready after XR session re-init"); + + /* A reboot must ignore both the prior status and the prior request. */ + psx_openxr_ready_load_begin_execution(&state, 2, 1, 2); + status = (PSXOpenXRReadyLoadStatus){11, 0, 1, 1, 2}; + CHECK(!psx_openxr_ready_load_observe_status(&state, &status), + "previous session success cannot authorize reboot"); + CHECK(!psx_openxr_ready_load_ok(&state), "reboot waits for a fresh load"); + request = (PSXOpenXRReadyLoadRequest){1, 1, 10, 2, NULL}; + CHECK(!psx_openxr_ready_load_observe_request(&state, &request), + "stale execution request is ignored"); + + request = (PSXOpenXRReadyLoadRequest){2, 2, 11, 2, NULL}; + CHECK(psx_openxr_ready_load_observe_request(&state, &request), + "reboot load request is accepted"); + CHECK(!psx_openxr_ready_load_ok(&state), + "reboot pending load cannot reuse the prior success"); + status = (PSXOpenXRReadyLoadStatus){12, 0, 0, 1, 2}; + CHECK(psx_openxr_ready_load_observe_status(&state, &status), + "reboot failed load resolves as failure"); + CHECK(psx_openxr_ready_load_failed(&state), + "reboot failed load cannot authorize success"); + + psx_openxr_ready_load_begin_execution(&state, 3, 1, 2); + request = (PSXOpenXRReadyLoadRequest){3, 3, 12, 2, NULL}; + CHECK(psx_openxr_ready_load_observe_request(&state, &request), + "recovery load request is accepted"); + status = (PSXOpenXRReadyLoadStatus){13, 0, 1, 1, 2}; + CHECK(psx_openxr_ready_load_observe_status(&state, &status), + "recovery load resolves"); + CHECK(psx_openxr_ready_load_ok(&state), "recovery session is ready"); + + psx_openxr_ready_load_begin_execution(&state, 4, 1, 3); + request = (PSXOpenXRReadyLoadRequest){4, 4, 13, -1, + "state_load_request_refused"}; + CHECK(psx_openxr_ready_load_observe_request(&state, &request), + "refused load request is accepted"); + CHECK(psx_openxr_ready_load_failed(&state) && + state.error && state.error[0], + "refused load reports failure in its execution"); + + puts("ALL PASS"); + return 0; +}