diff --git a/THIRD_PARTY_ATTRIBUTION.md b/THIRD_PARTY_ATTRIBUTION.md index bef31894..0865bf64 100644 --- a/THIRD_PARTY_ATTRIBUTION.md +++ b/THIRD_PARTY_ATTRIBUTION.md @@ -151,3 +151,23 @@ scaffolding a project and destructive on a live one. Keep the pin above accurate when re-syncing. These files are the reason a standalone setup-wizard install and a Retro build produce the same multi-disc `game.toml`; if the two drift, so do those two paths. + +## OpenXR SDK loader - optional PC headset backend + +[OpenXR-SDK](https://github.com/KhronosGroup/OpenXR-SDK) by The Khronos Group +Inc. and contributors is licensed Apache-2.0. Its bundled JsonCpp sources are +distributed under their MIT terms. Only `PSX_OPENXR=ON` links the static loader; +the SDK provides the official host API/runtime discovery implementation needed +to connect to the user's separately installed OpenXR runtime. The feature is +currently Win32/OpenGL-only and defaults OFF, so ordinary builds do not fetch it. + +The dependency is pinned by annotated tag object +`b76b80adaf65ac3ad6cc1ce61974fb29a5d02352`, resolving to commit +`c15d38cb4bb10a5b7e075f74493ff13896e2597a`. Enabled builds fetch and compile that +source, including bundled JsonCpp; they need Git/network on a cold cache or a +`FETCHCONTENT_SOURCE_DIR_PSX_OPENXR_SDK` override. No proprietary headset SDK, +runtime binary, API layer or vendor driver is redistributed. + +The complete SDK and JsonCpp notices are in +`runtime/licenses/OpenXR-SDK-NOTICES.txt`; both existing release packagers copy +that directory into player packages. The source dependency is unmodified. diff --git a/docs/OPENXR_RENDERING.md b/docs/OPENXR_RENDERING.md new file mode 100644 index 00000000..59502523 --- /dev/null +++ b/docs/OPENXR_RENDERING.md @@ -0,0 +1,87 @@ +# Experimental Win32/OpenGL OpenXR backend + +Build with `-DPSX_OPENXR=ON` to opt in. The default is OFF: ordinary builds +do not fetch the SDK or require an active headset runtime. The enabled build +uses official OpenXR-SDK annotated tag object `b76b80adaf65ac3ad6cc1ce61974fb29a5d02352` +and currently supports Win32/OpenGL. This is a PC headset backend; it does not +provide an Android/standalone Quest build. + +## Frame ownership and stereo submission + +The game plugin enables the backend with `psx_mod_openxr_enable`, then calls +`psx_mod_openxr_begin(width,height,units_per_meter)` at a host frame boundary. +Begin waits/begins the XR frame and locates both eyes at its predicted time. +`psx_mod_openxr_view` returns the scoped rigid transform and asymmetric +projection for each eye. The plugin supplies its own draw-only callbacks to +the [atomic stereo renderer](STEREO_RENDERING.md). + +`psx_mod_openxr_end(pair_rendered)` submits only the fresh complete pair from +that frame. A failed, refused or shed redraw submits zero layers, closing the +frame without recycling an old pair. Private swapchains receive eye textures; +guest execution, simulation time and guest TR registers are not owned by XR. +`psx_mod_openxr_recenter` establishes the shared pose origin. World scale, +native camera/body mapping, aiming and gameplay policy belong to the plugin. + +## Offline actions and controller snapshots + +Touch stick, trigger, squeeze and click actions are sampled at the normal +offline input boundary, never during eye replay. `psx_mod_openxr_input` exposes +activity/focus, sequence and synthetic metadata. Unfocused/inactive actions +are neutral; the game chooses how to map them through the separate trusted +controller-source API. Default flat input registers no XR source. + +`psx_mod_openxr_hands` is read-only and safe during replay. It reports grip/aim +poses for both hands, activity, OpenXR validity/tracking flags, common origin, +predicted time, sequence and age. A cached pose is not a promise that tracking +is currently valid. Focus loss, disable and shutdown clear samples. Relative +controller transforms use the same metric pose math and origin as the eyes. + +TCP `openxr_stats`, `openxr_views`, `openxr_input` and `openxr_hands` expose the +backend. `openxr_control` enables/recenters it. Synthetic `openxr_input_override` +and `openxr_hands_override` exist only with debug tools enabled and label their +samples. Their handlers and registrations are excluded by PSX_NO_DEBUG_TOOLS. + +## Menus and native startup surfaces + +`psx_mod_openxr_quad(distance_m,width_m,height_m)` chooses a frame-local menu +quad after pair begin. The request resets each frame; normal gameplay resumes +projection submission. Applications choose when their identical-eye menus +should become a comfortable quad. Distance must be 0.25..20m and dimensions +positive/bounded; values must be finite. + +`psx_mod_openxr_native_surface(distance_m,width_m,units_per_meter)` permits +boot/menu/video presentation before any gameplay stereo hook. It persists +until disabled with distance zero. Width is positive and at most 10m, scale +1..65536, distance 0.25..20m; values must be finite. It refuses during replay, +an open XR frame, or compiled-out XR. Disable it before application pair begin. + +Only a fresh native present arms submission. The letterboxed GL_BACK rectangle +is copied before host OSD, upright, to a VIEW-space quad preserving its aspect. +This path does not replay guest draws. Unchanged native frames still pump XR. +`openxr_stats.submitted_source` distinguishes stereo (1) and native (2). +Successful-submit counters and latched frame/cycle metadata describe the last +submission; they do not prove the layer currently visible. `video_info` exposes +the actual GL swap interval. The plugin can avoid a second desktop wait while +keeping the runtime's guest-speed deadline. + +## Validation and limits + +The original MoH alpha was accepted on Quest 3 via Virtual Desktop/VDXR for +startup/menu visibility, head tracking and mapped combat input. Menu quads and +native startup surfaces were exercised. This is historical acceptance of the +alpha checkpoint, not a new headset run of this rebased branch. + +Fresh extraction checks cover action lifecycle/neutralization, pose math, +paired watchdog rollback, GTE identity/parallax/rigid/asymmetric projection, +and XR/debug-tool compile combinations. Controlled focus/reconnection tests, +other runtimes/headsets and fresh rebased headset acceptance remain pending. +Guest-frame replay cost does not guarantee headset-rate rendering. No claim +of independent headset-rate simulation, controller/barrel alignment or Android +support is made. The projection-scale prerequisite is runtime-only; no retail BIOS +regeneration is required. Bundled OpenBIOS is suitable for enabled builds. + +The pin resolves to SDK commit `c15d38cb4bb10a5b7e075f74493ff13896e2597a`. +See [Third-Party Attribution](../THIRD_PARTY_ATTRIBUTION.md#openxr-sdk-loader---optional-pc-headset-backend) +for the Apache-2.0 SDK, MIT JsonCpp notices, cold-cache requirements and package +distribution. Consuming this feature requires a framework pin update and runtime +rebuild; it does not change guest code generation. diff --git a/docs/STEREO_RENDERING.md b/docs/STEREO_RENDERING.md index ab8da0c7..727d8547 100644 --- a/docs/STEREO_RENDERING.md +++ b/docs/STEREO_RENDERING.md @@ -65,3 +65,5 @@ The game's [VR execution plan](https://github.com/FractalEngineer/Medal-Of-Honor records historical alpha evidence. Projection scale and eye separation remain 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). diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 6588795f..7663b7f1 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -430,9 +430,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**325 commands registered** — 312 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**332 commands registered** — 319 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -62 of 325 have prose above; **263 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +62 of 332 have prose above; **270 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -603,6 +603,13 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `mmio_clear` | ✓ | | ✓ | | `mmio_dump` | ✓ | | ✓ | | `mmx6_freshfix` | ✓ | | | +| `openxr_control` | ✓ | | | +| `openxr_hands` | ✓ | | | +| `openxr_hands_override` | ✓ | | | +| `openxr_input` | ✓ | | | +| `openxr_input_override` | ✓ | | | +| `openxr_stats` | ✓ | | | +| `openxr_views` | ✓ | | | | `overlay_candidates` | ✓ | | | | `overlay_capture_dump` | ✓ | | | | `overlay_cps_probe` | ✓ | | | diff --git a/docs/internal/upstream/experimental-openxr-win32-gl.md b/docs/internal/upstream/experimental-openxr-win32-gl.md new file mode 100644 index 00000000..9fd9d25f --- /dev/null +++ b/docs/internal/upstream/experimental-openxr-win32-gl.md @@ -0,0 +1,46 @@ +# Experimental Win32/OpenGL OpenXR: source and validation + +Reconstructed from [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp), developed on +`vr-dev` at `9976567eb8e95510ca7eeeeccd9af3a260885de5`. +Exact source commits: + +- [22bdc9e9](https://github.com/FractalEngineer/psxrecomp/commit/22bdc9e90d14f7a04b79c83b77611c4eb980fef4) +- [5a1264b7](https://github.com/FractalEngineer/psxrecomp/commit/5a1264b79b69b5d75f7b8507e305b1e13a911a50) +- [ac6f84ba](https://github.com/FractalEngineer/psxrecomp/commit/ac6f84ba039b1d27bb1e93d3b6d05390dfa42ff9) +- [734977c9](https://github.com/FractalEngineer/psxrecomp/commit/734977c93e80789e9210a262daa35a88f41c40d4) +- [04714837](https://github.com/FractalEngineer/psxrecomp/commit/04714837245ebe68abbc462ac6822a3818eedf67) +- [5bafeebf](https://github.com/FractalEngineer/psxrecomp/commit/5bafeebf3f0c2eaaa38212f960b9f6f88351cb74) + +Original author: Yves . The feature commit retains +that exact human author identity. The focused port consolidates the selected +source hunks and corrections. Its commit also cites these sources and carries +the original human contributor's `Co-authored-by: Yves ` trailer. + +Adapted: Only SDK dependency/build integration, headset frame lifecycle, Touch actions and tracked hands, synthetic debug controls, menu quads and native startup presentation. Debug-less guards, complete dependency notices and focused docs were corrected during the port. + +Deliberately excluded: Producer-PC filtering, title-specific transforms/hook PCs/input policy, private saves/discs, unrelated chronology, Android support and headset-rate simulation. + +The feature was reconstructed on upstream master +`db62eec15f81c88aef3d6dab51d4e566a7167d23`, retaining current upstream APIs, +renderer/input policy and build integration. Validation after reconstruction: +XR ON/OFF crossed with debug-tool ON/OFF C compilation, compiled-out action/hand lifecycle tests and the SDK loader native link. Historical Quest 3/Virtual Desktop VDXR acceptance is not fresh rebased headset evidence. + +The motivating consumer is [Medal of Honor, SLUS-00974 (NTSC-U)](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/e323885958c2ae0b0df847bed6175f9e76524442). +Game-specific integration remains in that repository. Consuming this change +requires a deliberate framework pin update in the game repo and a rebuild. +This is a host/runtime/tool change; it requires no BIOS or game C regeneration. + +Historical alpha acceptance used the older framework checkpoint above. It is +separate from current-master branch verification. Fresh game, renderer and +headset checks, where applicable, are recorded in the PR validation notes; +uncertain hardware/runtime behavior must not be inferred from a clean link. + +## Current-master integration validation + +Windows, GCC 13.2, Ninja, Release. Runtime test checks remain active with NDEBUG enabled. + +Full Release runtime builds and nine focused CTest cases pass for XR ON/OFF crossed with debug tools ON/OFF. The rebuilt Medal of Honor game links with XR ON, launcher UI OFF, rewind ON and netplay OFF. OpenBIOS LLE reaches flat gameplay after a fresh save/load. A live initialization attempt found VirtualDesktopXR, then xrGetSystem returned XR_ERROR_FORM_FACTOR_UNAVAILABLE (-35); the game continued in flat mode with neutral XR input. No headset layers were submitted. Quest 3/VDXR alpha acceptance remains historical; fresh visual headset, controlled focus/reconnection and other-runtime tests remain pending. + +Integration: [Medal of Honor, SLUS-00974 NTSC-U](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/e323885958c2ae0b0df847bed6175f9e76524442), built against the corrected framework stack with `PSXRECOMP_ROOT`. Bundled OpenBIOS LLE cold boot, menu navigation, mission gameplay, fresh savestate creation/loading and post-load input/frame advancement were checked with screenshots. An older alpha slot-5 savestate was refused; compatibility with historical states is not established. Memory-card round trips, attract-demo completion, other games and fresh headset visuals were not tested. + +The registered recompiler suite reports 132 passes, four failures and three disabled tests. The four failures reproduce on unchanged upstream master in this Windows toolchain: `portable_settings_paths_test`, `optional_netplay_auth`, `compile_overlays_static_tail` and `aot_overlay_discovery`. The introduced BIOS-fingerprint and GL-guard failures are fixed. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ab92f530..645bb81e 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -352,6 +352,13 @@ if(BUILD_TESTING) add_test(NAME sio_dualshock_rumble_test COMMAND sio_dualshock_rumble_test) set_tests_properties(sio_dualshock_rumble_test PROPERTIES DISABLED TRUE) + add_executable(openxr_input_test tests/test_openxr_input.c src/psx_openxr.c) + target_include_directories(openxr_input_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(openxr_input_test PRIVATE m) + endif() + add_test(NAME openxr_input_test COMMAND openxr_input_test) + add_executable(vr_pose_math_test tests/test_vr_pose_math.c src/vr_pose_math.c) target_include_directories(vr_pose_math_test PRIVATE include) if(NOT MSVC) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index b46de535..5d50e65d 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -28,6 +28,7 @@ int gl_renderer_texture_banks_supported(void); /* Set the GL swap interval / vsync mode (1=vsync, 0=immediate, -1=adaptive). * Safe before or after context creation; applies live when a context exists. */ void gl_renderer_set_swap_interval(int interval); +int gl_renderer_get_swap_interval(void); /* Actual driver interval, -2 if unavailable. */ /* Presentation-only temporal blending. High-refresh sub-presents blend the two * most recent stable display images on the owning render thread/context; this diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index de09ada8..26595195 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -395,6 +395,66 @@ typedef struct PSXModRenderView { int32_t fx_q16, fy_q16, cx_delta_q16, cy_delta_q16; } PSXModRenderView; int psx_mod_render_view(const PSXModRenderView *view); +/* Begin locates both views at one predicted time. End submits only a fresh + * complete pair; failed/shed redraws submit zero layers. */ +int psx_mod_openxr_enable(int enabled); +int psx_mod_openxr_begin(uint32_t width, uint32_t height, double units_per_meter); +int psx_mod_openxr_view(uint32_t eye, PSXModRenderView *view); +int psx_mod_openxr_end(int pair_rendered); +/* Frame-local UI surface: the fresh pair's left image is shown to both eyes + * on a head-relative quad. Call after begin, outside the draw transaction. + * Dimensions and distance are meters; zero distance restores projection. + * Requests reset at begin/end and never affect faithful guest rendering. */ +int psx_mod_openxr_quad(double distance_m, double width_m, double height_m); +/* Opt-in native presentation surface for boot, videos and menus. Copies the + * freshly drawn desktop content before host overlays, without guest replay. + * Zero distance disables it. Applications disable it before scene begin and + * re-enable when their scene renderer is inactive. Dimensions are meters; + * height follows the presented content aspect. No retained stereo substitution. */ +int psx_mod_openxr_native_surface(double distance_m, double width_m, + double units_per_meter); +void psx_mod_openxr_recenter(void); +/* Fresh action sample at the offline input boundary, never in an eye replay. + * Positive Y is forward/up in XR. active[] refers only to thumbsticks; + * other actions have independent activity. Unavailable/unfocused actions + * return zero values. Click masks are active-high, unrelated to PSX pad bits. */ +#define PSX_MOD_XR_PRIMARY 1u /* left X / right A */ +#define PSX_MOD_XR_SECONDARY 2u /* left Y / right B */ +#define PSX_MOD_XR_MENU 4u /* Touch left Menu */ +#define PSX_MOD_XR_STICK 8u /* thumbstick click */ +#define PSX_MOD_XR_CLICKS 15u +typedef struct PSXModOpenXRInput { + uint32_t struct_size, focused, active[2], synthetic; + float stick[2][2]; + uint64_t sequence; + float trigger[2], squeeze[2]; /* [0,1] */ + uint32_t trigger_active[2], squeeze_active[2]; + uint32_t buttons[2], buttons_active[2]; /* PSX_MOD_XR_* masks */ +} PSXModOpenXRInput; +int psx_mod_openxr_input(PSXModOpenXRInput *input); + +/* Read-only controller snapshot from the latest located XR frame. Grip and aim + * share the eye poses' predicted time and LOCAL space; no action sync or locate + * occurs here, including during eye replay. Positions are meters; quaternions + * are x,y,z,w. The origin matches the rendered view's recenter basis. Consumers + * must check focus, activity, validity and age before using a cached pose. */ +enum { PSX_MOD_XR_GRIP_POSE = 0, PSX_MOD_XR_AIM_POSE = 1 }; +#define PSX_MOD_XR_ORIENTATION_VALID 1u +#define PSX_MOD_XR_POSITION_VALID 2u +#define PSX_MOD_XR_ORIENTATION_TRACKED 4u +#define PSX_MOD_XR_POSITION_TRACKED 8u +typedef struct PSXModTrackedPose { + uint32_t active, flags; + float position_m[3], orientation_xyzw[4]; +} PSXModTrackedPose; +typedef struct PSXModOpenXRHands { + uint32_t struct_size, focused, synthetic, origin_valid; + uint64_t sequence, predicted_time; + uint32_t age_ms; /* UINT32_MAX when no real frame has been located */ + double origin_position_m[3], origin_orientation_xyzw[4]; + PSXModTrackedPose pose[2][2]; /* [left/right][grip/aim] */ +} PSXModOpenXRHands; +int psx_mod_openxr_hands(PSXModOpenXRHands *hands); int psx_mod_set_auto_skip_fmv(int enabled); diff --git a/runtime/include/psx_openxr.h b/runtime/include/psx_openxr.h new file mode 100644 index 00000000..8a949c3f --- /dev/null +++ b/runtime/include/psx_openxr.h @@ -0,0 +1,48 @@ +#pragma once +#include "mod_plugins.h" +#ifdef __cplusplus +extern "C" { +#endif +typedef struct PSXOpenXRStats { + uint32_t compiled, enabled, initialized, running, state, tracking, frame_open; + int32_t result, last_failure_result; + const char *last_failure; + uint64_t view_flags; + const char *stage; + uint64_t waits, submitted, empty, failures, predicted_time; + float ipd_m; + double units_per_meter; + uint64_t gl_version, min_gl_version, max_gl_version; + char runtime[128]; + float pose[2][7], fov[2][4]; + PSXModRenderView view[2]; + uint64_t submitted_pair_id, submitted_guest_cycle; + uint64_t submitted_predicted_time; + uint32_t submitted_layer; /* 1 projection, 2 head-relative quad */ + uint64_t quad_submitted; + double quad_distance_m, quad_width_m, quad_height_m; /* last submitted quad */ + uint32_t submitted_source; /* 1 fresh stereo pair, 2 native presentation */ + uint64_t native_submitted, submitted_native_frame; +} PSXOpenXRStats; +int psx_openxr_enable(int enabled); +int psx_openxr_begin(int width,int height,double units); +int psx_openxr_view(uint32_t eye,PSXModRenderView *out); +/* copy callback: blit this pair's eye to runtime GL texture. */ +typedef int (*PSXOpenXRCopy)(uint32_t eye,uint32_t texture,int w,int h); +int psx_openxr_end(int keep,PSXOpenXRCopy copy); +int psx_openxr_quad(double distance_m,double width_m,double height_m); +void psx_openxr_pair_metadata(uint64_t pair_id,uint64_t cycle); +void psx_openxr_native_metadata(uint64_t frame); +void psx_openxr_shutdown(void); +void psx_openxr_recenter(void); +void psx_openxr_stats(PSXOpenXRStats *out); +int psx_openxr_input(PSXModOpenXRInput *out); +void psx_openxr_input_snapshot(PSXModOpenXRInput *out); +int psx_openxr_hands(PSXModOpenXRHands *out); +#ifndef PSX_NO_DEBUG_TOOLS +int psx_openxr_input_override(const PSXModOpenXRInput *input); +int psx_openxr_hands_override(const PSXModOpenXRHands *hands); +#endif +#ifdef __cplusplus +} +#endif diff --git a/runtime/licenses/OpenXR-SDK-NOTICES.txt b/runtime/licenses/OpenXR-SDK-NOTICES.txt new file mode 100644 index 00000000..18fb446a --- /dev/null +++ b/runtime/licenses/OpenXR-SDK-NOTICES.txt @@ -0,0 +1,270 @@ +OpenXR SDK loader by The Khronos Group Inc. and contributors. +Source: https://github.com/KhronosGroup/OpenXR-SDK +Pinned tag object: b76b80adaf65ac3ad6cc1ce61974fb29a5d02352 +Resolved commit: c15d38cb4bb10a5b7e075f74493ff13896e2597a +The unmodified loader is statically linked only with PSX_OPENXR=ON. +License: Apache-2.0. Complete SDK license follows. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +Bundled JsonCpp, compiled into the OpenXR loader. +Source: OpenXR-SDK src/external/jsoncpp at the same revision. +Distributed here under its MIT terms, with its complete upstream notice: + +The JsonCpp library's source code, including accompanying documentation, +tests and demonstration applications, are licensed under the following +conditions... + +Baptiste Lepilleur and The JsonCpp Authors explicitly disclaim copyright in all +jurisdictions which recognize such a disclaimer. In such jurisdictions, +this software is released into the Public Domain. + +In jurisdictions which do not recognize Public Domain property (e.g. Germany as of +2010), this software is Copyright (c) 2007-2010 by Baptiste Lepilleur and +The JsonCpp Authors, and is released under the terms of the MIT License (see below). + +In jurisdictions which recognize Public Domain property, the user of this +software may choose to accept it either as 1) Public Domain, 2) under the +conditions of the MIT License (see below), or 3) under the terms of dual +Public Domain/MIT License conditions described here, as they choose. + +The MIT License is about as close to Public Domain as a license can get, and is +described in clear, concise terms at: + + http://en.wikipedia.org/wiki/MIT_License + +The full text of the MIT License follows: + +======================================================================== +Copyright (c) 2007-2010 Baptiste Lepilleur and The JsonCpp Authors + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +======================================================================== +(END LICENSE TEXT) + +The MIT license is compatible with both the GPL and commercial +software, affording one all of the rights of Public Domain with the +minor nuisance of being required to keep the above copyright notice +and license text in the source code. Note also that by accepting the +Public Domain "license" you can re-license your copy using whatever +license you like. diff --git a/runtime/openxr_dependency.cmake b/runtime/openxr_dependency.cmake new file mode 100644 index 00000000..2dfbc7d9 --- /dev/null +++ b/runtime/openxr_dependency.cmake @@ -0,0 +1,21 @@ +# Optional host headset backend. Faithful builds have no SDK dependency. +option(PSX_OPENXR "Build experimental Win32/OpenGL OpenXR backend" OFF) +if(PSX_OPENXR) + if(NOT WIN32) + message(FATAL_ERROR "PSX_OPENXR currently supports Win32/OpenGL") + endif() + include(FetchContent) + set(BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(BUILD_API_LAYERS OFF CACHE BOOL "" FORCE) + set(BUILD_LOADER ON CACHE BOOL "" FORCE) + set(DYNAMIC_LOADER OFF CACHE BOOL "" FORCE) + FetchContent_Declare(psx_openxr_sdk + GIT_REPOSITORY https://github.com/KhronosGroup/OpenXR-SDK.git + GIT_TAG b76b80adaf65ac3ad6cc1ce61974fb29a5d02352) + FetchContent_MakeAvailable(psx_openxr_sdk) + if(MINGW) + # Older MinGW desktop headers omit WINAPI_PARTITION_SYSTEM. The SDK + # correctly treats it as zero; leave this vendor warning nonfatal. + target_compile_options(openxr_loader PRIVATE -Wno-error=undef) + endif() +endif() diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index c21bcd84..f1216699 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -18,6 +18,7 @@ include("${PSXRECOMP_ROOT}/cmake/psx_runtime_ipo.cmake") include("${PSXRECOMP_ROOT}/cmake/psx_dependency_archive.cmake") include("${PSXRECOMP_ROOT}/runtime/chd_dependency.cmake") +include("${PSXRECOMP_ROOT}/runtime/openxr_dependency.cmake") include("${PSXRECOMP_ROOT}/runtime/overlay_static_sources.cmake") include("${PSXRECOMP_ROOT}/runtime/netplay_dependency.cmake") @@ -351,6 +352,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/gpu_vram_dirty.c ${PSXRECOMP_ROOT}/runtime/src/gpu_render.c ${PSXRECOMP_ROOT}/runtime/src/gpu_gl_renderer.c + ${PSXRECOMP_ROOT}/runtime/src/psx_openxr.c ${PSXRECOMP_ROOT}/runtime/src/vr_pose_math.c ${PSXRECOMP_ROOT}/runtime/src/mod_controller_source.c ${PSXRECOMP_ROOT}/runtime/src/gpu_vk_renderer.c @@ -2144,6 +2146,10 @@ function(psxrecomp_add_runtime_target target) RECOMP_UI_PSX_HAS_REWIND=$) endif() + if(PSX_OPENXR) + target_compile_definitions(${target} PRIVATE PSX_OPENXR=1) + target_link_libraries(${target} PRIVATE openxr_loader) + endif() if(WIN32 OR MINGW) # opengl32: GL backend (gpu_gl_renderer.c). GL 1.x is exported directly # by opengl32; Phase 2b will load modern GL via SDL_GL_GetProcAddress. diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index bf732403..58e1763f 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -1,3 +1,5 @@ +#include "psx_openxr.h" +#include "mod_controller_source.h" /* * debug_server.c -- TCP debug server for PSX recomp v4 * @@ -8089,6 +8091,182 @@ static void handle_render_pass_refuse(int id, const char *json) (unsigned)psx_mod_render_pass_status()); } +static void handle_openxr_stats(int id, const char *json) { + PSXOpenXRStats s; (void)json; psx_openxr_stats(&s); + send_fmt("{\"id\":%d,\"ok\":true,\"compiled\":%u,\"enabled\":%u," + "\"initialized\":%u,\"running\":%u,\"state\":%u,\"tracking\":%u," + "\"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," + "\"predicted_time\":%llu,\"ipd_m\":%.6f,\"units_per_meter\":%.6f," + "\"gl_version\":%llu,\"min_gl_version\":%llu,\"max_gl_version\":%llu," + "\"submitted_layer\":%u,\"quad_submitted\":%llu," + "\"submitted_source\":%u,\"native_submitted\":%llu,\"submitted_native_frame\":%llu," + "\"quad_distance_m\":%.6f,\"quad_width_m\":%.6f,\"quad_height_m\":%.6f}", id,s.compiled,s.enabled, + 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,s.units_per_meter, + (unsigned long long)s.gl_version,(unsigned long long)s.min_gl_version, + (unsigned long long)s.max_gl_version,s.submitted_layer, + (unsigned long long)s.quad_submitted,s.submitted_source, + (unsigned long long)s.native_submitted,(unsigned long long)s.submitted_native_frame, + s.quad_distance_m,s.quad_width_m,s.quad_height_m); +} +static void handle_openxr_views(int id,const char *json) { + PSXOpenXRStats s; (void)json;psx_openxr_stats(&s); + send_fmt("{\"id\":%d,\"ok\":true,\"predicted_time\":%llu," + "\"submitted_pair_id\":%llu,\"submitted_guest_cycle\":%llu," + "\"submitted_predicted_time\":%llu,\"eyes\":[",id, + (unsigned long long)s.predicted_time,(unsigned long long)s.submitted_pair_id, + (unsigned long long)s.submitted_guest_cycle,(unsigned long long)s.submitted_predicted_time); + for(int e=0;e<2;e++) { + PSXModRenderView *v=&s.view[e]; + send_fmt("%s{\"eye\":%d,\"position_m\":[%.6f,%.6f,%.6f]," + "\"orientation_xyzw\":[%.6f,%.6f,%.6f,%.6f]," + "\"fov_lrud\":[%.6f,%.6f,%.6f,%.6f]," + "\"rotation_q12\":[%d,%d,%d,%d,%d,%d,%d,%d,%d]," + "\"translation\":[%d,%d,%d],\"projection_q16\":[%d,%d,%d,%d]}", + e?",":"",e,s.pose[e][0],s.pose[e][1],s.pose[e][2],s.pose[e][3],s.pose[e][4],s.pose[e][5],s.pose[e][6], + s.fov[e][0],s.fov[e][1],s.fov[e][2],s.fov[e][3], + v->rotation_q12[0],v->rotation_q12[1],v->rotation_q12[2],v->rotation_q12[3],v->rotation_q12[4], + v->rotation_q12[5],v->rotation_q12[6],v->rotation_q12[7],v->rotation_q12[8], + v->translation[0],v->translation[1],v->translation[2], + v->fx_q16,v->fy_q16,v->cx_delta_q16,v->cy_delta_q16); + } + send_fmt("]}"); +} + +static void handle_openxr_control(int id,const char *json) { + if (json_get_int(json,"recenter",0)) psx_mod_openxr_recenter(); + int enable=json_get_int(json,"enable",-1); + if (enable>=0 && !psx_mod_openxr_enable(enable!=0)) { send_err(id,"OpenXR enable refused (not compiled or rendering)");return; } + handle_openxr_stats(id,json); +} + +/* Snapshot only: querying never syncs actions or consumes a controller sample. */ +static void handle_openxr_input(int id,const char *json) { + (void)json; + PSXModOpenXRInput s; psx_openxr_input_snapshot(&s); + send_fmt("{\"id\":%d,\"ok\":true,\"sequence\":%llu,\"synthetic\":%u," + "\"focused\":%u,\"active\":[%u,%u],\"stick\":[[%.6f,%.6f],[%.6f,%.6f]]," + "\"trigger\":[%.6f,%.6f],\"squeeze\":[%.6f,%.6f]," + "\"trigger_active\":[%u,%u],\"squeeze_active\":[%u,%u]," + "\"buttons\":[%u,%u],\"buttons_active\":[%u,%u]," + "\"p1_source\":%d}",id,(unsigned long long)s.sequence,s.synthetic,s.focused, + s.active[0],s.active[1],s.stick[0][0],s.stick[0][1],s.stick[1][0],s.stick[1][1], + s.trigger[0],s.trigger[1],s.squeeze[0],s.squeeze[1], + s.trigger_active[0],s.trigger_active[1],s.squeeze_active[0],s.squeeze_active[1], + s.buttons[0],s.buttons[1],s.buttons_active[0],s.buttons_active[1], + mod_controller_source_present(0)); +} +/* Read-only controller poses from the eye frame's predicted time. */ +static void handle_openxr_hands(int id,const char *json) { + (void)json;PSXModOpenXRHands s={0};s.struct_size=sizeof s; + if(!psx_openxr_hands(&s)){send_err(id,"hand snapshot unavailable");return;} + char buf[4096];size_t pos=0; + pos+=(size_t)snprintf(buf+pos,sizeof buf-pos, + "{\"id\":%d,\"ok\":true,\"sequence\":%llu,\"synthetic\":%u,\"focused\":%u," + "\"origin_valid\":%u,\"predicted_time\":%llu,\"age_ms\":%u," + "\"origin_position_m\":[%.9f,%.9f,%.9f],\"origin_orientation_xyzw\":[%.9f,%.9f,%.9f,%.9f],\"pose\":[", + id,(unsigned long long)s.sequence,s.synthetic,s.focused,s.origin_valid, + (unsigned long long)s.predicted_time,s.age_ms, + s.origin_position_m[0],s.origin_position_m[1],s.origin_position_m[2], + s.origin_orientation_xyzw[0],s.origin_orientation_xyzw[1], + s.origin_orientation_xyzw[2],s.origin_orientation_xyzw[3]); + for(int e=0;e<2;e++) { + pos+=(size_t)snprintf(buf+pos,sizeof buf-pos,"%s[",e?",":""); + for(int k=0;k<2;k++) { + const PSXModTrackedPose *v=&s.pose[e][k]; + pos+=(size_t)snprintf(buf+pos,sizeof buf-pos, + "%s{\"active\":%u,\"flags\":%u,\"position_m\":[%.9f,%.9f,%.9f]," + "\"orientation_xyzw\":[%.9f,%.9f,%.9f,%.9f]}",k?",":"",v->active,v->flags, + v->position_m[0],v->position_m[1],v->position_m[2], + v->orientation_xyzw[0],v->orientation_xyzw[1],v->orientation_xyzw[2],v->orientation_xyzw[3]); + } + pos+=(size_t)snprintf(buf+pos,sizeof buf-pos,"]"); + } + (void)snprintf(buf+pos,sizeof buf-pos,"]}");debug_server_send_line(buf); +} +#ifndef PSX_NO_DEBUG_TOOLS +static void handle_openxr_hands_override(int id,const char *json) { + if(json_get_int(json,"clear",0)){psx_openxr_hands_override(NULL);handle_openxr_hands(id,json);return;} + char hand[16],pose[16]; + if(!json_get_str(json,"hand",hand,sizeof hand) || !json_get_str(json,"pose",pose,sizeof pose)) { + send_err(id,"hand=left|right and pose=grip|aim required");return; + } + int e=!strcmp(hand,"left")?0:!strcmp(hand,"right")?1:-1; + int k=!strcmp(pose,"grip")?0:!strcmp(pose,"aim")?1:-1; + int active=json_get_int(json,"active",1),focused=json_get_int(json,"focused",1),flags=json_get_int(json,"flags",15); + if(e<0 || k<0 || active<0 || active>1 || focused<0 || focused>1 || flags<0 || flags>15) { + send_err(id,"invalid hand, pose, focus, activity or flags");return; + } + PSXModOpenXRHands s={0};s.struct_size=sizeof s;(void)psx_openxr_hands(&s); + if(!s.synthetic){memset(&s,0,sizeof s);s.struct_size=sizeof s;s.origin_valid=1;s.origin_orientation_xyzw[3]=1;} + s.focused=(uint32_t)focused;s.predicted_time=0; + PSXModTrackedPose *v=&s.pose[e][k];memset(v,0,sizeof *v); + v->active=(uint32_t)active;v->flags=(uint32_t)flags; + const char *pn[3]={"px_mm","py_mm","pz_mm"},*qn[4]={"qx","qy","qz","qw"}; + for(int i=0;i<3;i++) { + int value=json_get_int(json,pn[i],0); + if(value < -100000 || value > 100000){send_err(id,"position must be -100000..100000 mm");return;} + v->position_m[i]=(float)value/1000; + } + for(int i=0;i<4;i++) { + int value=json_get_int(json,qn[i],i==3?1000000:0); + if(value < -1000000 || value > 1000000){send_err(id,"quaternion components must be -1000000..1000000");return;} + v->orientation_xyzw[i]=(float)value/1000000; + } + if(!psx_openxr_hands_override(&s)){send_err(id,"invalid synthetic pose numbers");return;} + handle_openxr_hands(id,json); +} +#endif + +/* Debug-only synthetic action sample; axes are signed thousandths [-1000,1000]. + * Explicitly tagged so desktop controls cannot be mistaken for device evidence. */ +#ifndef PSX_NO_DEBUG_TOOLS +static void handle_openxr_input_override(int id,const char *json) { + if(json_get_int(json,"clear",0)) psx_openxr_input_override(NULL); + else { + PSXModOpenXRInput s; memset(&s,0,sizeof s);s.struct_size=sizeof s; + int focused=json_get_int(json,"focused",1); + int left=json_get_int(json,"left_active",1),right=json_get_int(json,"right_active",1); + if(focused<0 || focused>1 || left<0 || left>1 || right<0 || right>1) { + send_err(id,"focused/active must be 0 or 1");return; + } + s.focused=(uint32_t)focused;s.active[0]=(uint32_t)left;s.active[1]=(uint32_t)right; + int a[4]={json_get_int(json,"lx",0),json_get_int(json,"ly",0), + json_get_int(json,"rx",0),json_get_int(json,"ry",0)}; + for(int i=0;i<4;i++) { + if(a[i]<-1000 || a[i]>1000) { send_err(id,"axis must be -1000..1000");return; } + s.stick[i/2][i%2]=(float)a[i]/1000; + } + const char *t[2]={"left_trigger","right_trigger"}, *g[2]={"left_squeeze","right_squeeze"}; + const char *ta[2]={"left_trigger_active","right_trigger_active"}; + const char *ga[2]={"left_squeeze_active","right_squeeze_active"}; + const char *b[2]={"left_buttons","right_buttons"}; + const char *ba[2]={"left_buttons_active","right_buttons_active"}; + for(int e=0;e<2;e++) { + int trigger=json_get_int(json,t[e],0),squeeze=json_get_int(json,g[e],0); + int trigger_active=json_get_int(json,ta[e],1),squeeze_active=json_get_int(json,ga[e],1); + int buttons=json_get_int(json,b[e],0),buttons_active=json_get_int(json,ba[e],15); + if(trigger<0 || trigger>1000 || squeeze<0 || squeeze>1000 || + trigger_active<0 || trigger_active>1 || squeeze_active<0 || squeeze_active>1 || + buttons<0 || buttons>15 || buttons_active<0 || buttons_active>15) { + send_err(id,"trigger/squeeze must be 0..1000, activity 0|1, click masks 0..15");return; + } + s.trigger[e]=(float)trigger/1000;s.squeeze[e]=(float)squeeze/1000; + s.trigger_active[e]=(uint32_t)trigger_active;s.squeeze_active[e]=(uint32_t)squeeze_active; + s.buttons[e]=(uint32_t)buttons;s.buttons_active[e]=(uint32_t)buttons_active; + } + if(!psx_openxr_input_override(&s)) { send_err(id,"invalid synthetic input");return; } + } + handle_openxr_input(id,json); +} +#endif + + static void handle_stereo_stats(int id, const char *json) { RenderStereoStats s; GLRenderStereoDiag g; @@ -9235,6 +9413,7 @@ static void handle_video_info(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":true,\"backend\":\"%s\",\"preset\":%d," "\"reference_lines\":%d,\"requested_scale\":%d,\"effective_scale\":%d," "\"internal_lines\":%u,\"gl\":%d,\"gl_max_dim\":%d,\"gl_max_scale\":%d," + "\"gl_swap_interval\":%d," "\"gl_clamp_reason\":%d,\"gl_alloc_retries\":%d,\"gl_budget_mib\":%d," "\"fbo_w\":%d,\"fbo_h\":%d,\"hidpi_window\":%d,\"window_w\":%d," "\"window_h\":%d,\"drawable_w\":%d,\"drawable_h\":%d," @@ -9246,7 +9425,7 @@ static void handle_video_info(int id, const char *json) id, gr_backend() == GR_BACKEND_OPENGL ? "opengl" : gr_backend() == GR_BACKEND_VULKAN ? "vulkan" : "software", preset, ref, req, eff, di.height * (unsigned)(eff > 0 ? eff : 1), gl, - si.max_dim, si.max_scale, si.clamp_reason, si.alloc_retries, si.budget_mib, + si.max_dim, si.max_scale, gl_renderer_get_swap_interval(), si.clamp_reason, si.alloc_retries, si.budget_mib, si.fbo_w, si.fbo_h, hidpi, ww, wh, pw, ph, di.display_x, di.display_y, di.width, di.height, si.hr_scale, si.windowed, si.window_x, si.window_w, si.window_fbo_w, @@ -14271,6 +14450,17 @@ static const CmdEntry s_commands[] = { { "gl_ws_ablate", handle_gl_ws_ablate }, { "gl_interp", handle_gl_interp }, { "render_pass_stats", handle_render_pass_stats }, + { "openxr_stats", handle_openxr_stats }, + { "openxr_views", handle_openxr_views }, + { "openxr_control", handle_openxr_control }, + { "openxr_input", handle_openxr_input }, +#ifndef PSX_NO_DEBUG_TOOLS + { "openxr_input_override", handle_openxr_input_override }, +#endif + { "openxr_hands", handle_openxr_hands }, +#ifndef PSX_NO_DEBUG_TOOLS + { "openxr_hands_override", handle_openxr_hands_override }, +#endif { "stereo_stats", handle_stereo_stats }, { "stereo_dump", handle_stereo_dump }, { "render_pass_dump", handle_render_pass_dump }, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 1716b6ab..f4035911 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -84,6 +84,7 @@ #include "latency_ring.h" #include "frame_pacing.h" #include "psx_rewind.h" +#include "psx_openxr.h" #include "psx_sdl.h" #if defined(PSX_SDL3) @@ -410,6 +411,10 @@ static GLuint s_osd_tex = 0; static int s_osd_tw = 0, s_osd_th = 0; static GLuint s_present_prog = 0, s_present_vao = 0; static void gl_swap_with_osd(void); +static int s_native_surface_enabled, s_native_surface_pending; +static int s_native_surface_rect[4]; /* Fresh native backbuffer content, GL coordinates. */ +static double s_native_surface_distance, s_native_surface_width, s_native_surface_units; +static void openxr_present_native(void); static GLint s_present_uTex = -1, s_present_uUvRect = -1; static GLint s_present_uTexSize = -1, s_present_uSharpScale = -1; static GLint s_present_uSharp = -1; @@ -1015,6 +1020,10 @@ static uint64_t s_pres_seq = 0; static void pres_record(int path, int dx, int dy, int w, int h, int lx, int ly, int lw, int lh) { + s_native_surface_pending=s_native_surface_enabled && + (path==GL_PRES_VRAM || path==GL_PRES_WIDE || path==GL_PRES_CPU || path==GL_PRES_BLANK); + s_native_surface_rect[0]=lx;s_native_surface_rect[1]=ly; + s_native_surface_rect[2]=lw;s_native_surface_rect[3]=lh; /* The ring metadata stays always-on, but pixel probing must not: each * glReadPixels synchronously drains queued GPU work. Two probes per frame * were enough to make Tomba 2 miss its frame budget. */ @@ -4626,10 +4635,20 @@ void gl_renderer_set_swap_interval(int interval) { } } } +int gl_renderer_get_swap_interval(void) { + if(!s_ctx)return -2; +#if defined(PSX_SDL3) + int interval=0; + return SDL_GL_GetSwapInterval(&interval)?interval:-2; +#else + return SDL_GL_GetSwapInterval(); +#endif +} static void pass_resources_release(void); void gl_renderer_shutdown(void) { + s_native_surface_enabled=s_native_surface_pending=0; pass_resources_release(); if (s_ctx) { for (unsigned i = 1; i < 65536u; ++i) @@ -6205,6 +6224,7 @@ static void pass_free_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo, /* Context teardown (gl_renderer_shutdown): free the pass images, backups * and journal, and forget their names so a new context makes fresh ones. */ static void pass_resources_release(void) { + psx_openxr_shutdown(); stereo_resources_release(); pass_gen_release(0); pass_gen_release(1); @@ -6584,6 +6604,119 @@ int gl_renderer_stereo_publish(uint64_t id, uint64_t cycle, const int32_t view[2 if (s_stereo_dump_left > 0) stereo_dump(p); return 1; } + +static uint64_t s_xr_begin_pair; +int psx_mod_openxr_enable(int enabled) { + PSXOpenXRStats s; psx_openxr_stats(&s); + if (s_pass_active || s.frame_open) return 0; + return psx_openxr_enable(enabled); +} +void psx_mod_openxr_recenter(void) { psx_openxr_recenter(); } +int psx_mod_openxr_quad(double distance,double width,double height) { + if(s_pass_active)return 0; + return psx_openxr_quad(distance,width,height); +} +int psx_mod_openxr_native_surface(double distance,double width,double units) { + PSXOpenXRStats stats;psx_openxr_stats(&stats); + if(s_pass_active || stats.frame_open || !stats.compiled || + !isfinite(distance) || !isfinite(width) || !isfinite(units))return 0; + if(distance && (distance<.25 || distance>20 || width<=0 || width>10 || units<1 || units>65536))return 0; + s_native_surface_enabled=distance!=0;s_native_surface_pending=0; + s_native_surface_distance=distance;s_native_surface_width=width;s_native_surface_units=units; + return 1; +} +int psx_mod_openxr_begin(uint32_t width, uint32_t height, double units) { + if (s_native_surface_enabled || s_pass_active || gl_renderer_stereo_unavailable() != PSX_MOD_RENDER_PASS_READY || + !width || !height || width > VRAM_W || height > VRAM_H) return 0; + s_xr_begin_pair = s_stereo_valid ? s_stereo_pair[s_stereo_current].id : 0; + return psx_openxr_begin((int)width, (int)height, units); +} +int psx_mod_openxr_view(uint32_t eye, PSXModRenderView *view) { + return psx_openxr_view(eye, view); +} +int psx_mod_openxr_input(PSXModOpenXRInput *input) { + if (s_pass_active) return 0; + return psx_openxr_input(input); +} +int psx_mod_openxr_hands(PSXModOpenXRHands *hands) { + return psx_openxr_hands(hands); /* Snapshot only, also safe during replay. */ +} +static int openxr_copy_eye(uint32_t eye, uint32_t texture, int w, int h) { + StereoPair *p = &s_stereo_pair[s_stereo_current]; + GLint read_fbo, draw_fbo; GLuint target = 0; + GLboolean scissor = glIsEnabled(GL_SCISSOR_TEST); + glGetIntegerv(0x8CAA, &read_fbo); + glGetIntegerv(0x8CA6, &draw_fbo); + p_glGenFramebuffers(1, &target); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, target); + p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0,GL_TEXTURE_2D, texture, 0); + int ok = p_glCheckFramebufferStatus(PSXGL_DRAW_FRAMEBUFFER) == PSXGL_FRAMEBUFFER_COMPLETE; + if (ok) { + glDisable(GL_SCISSOR_TEST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, p->fbo[eye]); + /* Stereo capture texture row convention is opposite the XR layer. + * User confirmed inverted headset output with the original direct blit. + * Flip only submission; eye dumps and desktop presentation stay intact. */ + p_glBlitFramebuffer(0, p->th[eye], p->tw[eye], 0, 0, 0, w, h, + GL_COLOR_BUFFER_BIT, GL_LINEAR); + glFlush(); + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, (GLuint)read_fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, (GLuint)draw_fbo); + if (scissor) glEnable(GL_SCISSOR_TEST); + p_glDeleteFramebuffers(1, &target);return ok; +} +int psx_mod_openxr_end(int rendered) { + int fresh = rendered && s_stereo_valid && !s_pass_active && + s_stereo_pair[s_stereo_current].id != s_xr_begin_pair; + if (fresh) psx_openxr_pair_metadata(s_stereo_pair[s_stereo_current].id, + s_stereo_pair[s_stereo_current].cycle); + return psx_openxr_end(fresh, openxr_copy_eye); +} + +static int openxr_copy_native(uint32_t eye,uint32_t texture,int w,int h) { + GLint read_fbo,draw_fbo,read_buffer;GLuint target=0; + GLboolean scissor=glIsEnabled(GL_SCISSOR_TEST); + (void)eye; + glGetIntegerv(0x8CAA,&read_fbo);glGetIntegerv(0x8CA6,&draw_fbo); + glGetIntegerv(GL_READ_BUFFER,&read_buffer); + p_glGenFramebuffers(1,&target);p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER,target); + p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER,PSXGL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,texture,0); + int ok=p_glCheckFramebufferStatus(PSXGL_DRAW_FRAMEBUFFER)==PSXGL_FRAMEBUFFER_COMPLETE; + if(ok) { + int *r=s_native_surface_rect; + glDisable(GL_SCISSOR_TEST);p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,0);glReadBuffer(GL_BACK); + /* Default framebuffer is already upright: unlike native VRAM/eye + * textures, its bottom GL row is the displayed bottom row. */ + (void)pass_gl_errors(); + p_glBlitFramebuffer(r[0],r[1],r[0]+r[2],r[1]+r[3],0,0,w,h,GL_COLOR_BUFFER_BIT,GL_LINEAR); + glFlush();ok=pass_gl_errors()==0; + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,(GLuint)read_fbo);glReadBuffer((GLenum)read_buffer); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER,(GLuint)draw_fbo); + if(scissor)glEnable(GL_SCISSOR_TEST); + p_glDeleteFramebuffers(1,&target);return ok; +} +static void openxr_present_native(void) { + int ww=0,wh=0,*r=s_native_surface_rect; + if(!s_native_surface_enabled || !s_native_surface_pending || s_pass_active)return; + s_native_surface_pending=0; + SDL_GL_GetDrawableSize(s_win,&ww,&wh); + if(r[0]<0 || r[1]<0 || r[2]<1 || r[3]<1 || r[0]+r[2]>ww || r[1]+r[3]>wh)return; + /* No replay or retained pair: this exact native present is the source. + * Direct lifecycle permits native 24-bit videos as well as 15-bit UI. */ + /* The shared locator also builds PSX projection matrices, whose domain + * is native VRAM dimensions. Quad geometry does not use those matrices; + * keep that domain bounded while copying the full drawable rectangle. */ + if(!psx_openxr_begin(512,240,s_native_surface_units))return; + if(!psx_openxr_quad(s_native_surface_distance,s_native_surface_width, + s_native_surface_width*(double)r[3]/r[2])) { + (void)psx_openxr_end(0,NULL);return; + } + psx_openxr_native_metadata(s_frame_count); + (void)psx_openxr_end(1,openxr_copy_native); +} + static int stereo_present(int w, int h) { StereoPair *p = &s_stereo_pair[s_stereo_current]; int ww, wh, lx, ly, lw, lh; @@ -6828,6 +6961,8 @@ static void gl_draw_osd_image(const uint32_t *px, int ow, int oh, /* Composite host toast + volume bar into the default framebuffer, then swap. */ static void gl_swap_with_osd(void) { + openxr_present_native(); /* Copy guest content before host-only overlays. */ + s_native_surface_pending=0; /* Hold-last/resim cannot reuse a native source. */ if (s_present_prog && s_ctx) { int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); @@ -7097,7 +7232,7 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, flush_tex_batch(); flush_cpu_upload(); if (stereo_present(w, h)) return; - if (s_force_present_remaining <= 0 && + if (!s_native_surface_enabled && s_force_present_remaining <= 0 && s_last_present_path == GL_PRES_VRAM && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == w && s_last_dh == h && @@ -7260,7 +7395,7 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) flush_tex_batch(); flush_cpu_upload(); hiw_flush_queue(); /* windowed: queued wide mirrors */ - if (s_force_present_remaining <= 0 && + if (!s_native_surface_enabled && s_force_present_remaining <= 0 && s_last_present_path == GL_PRES_WIDE && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == g_wide_w && s_last_dh == disp_h && diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b5b0f2a5..2afe78da 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1997,8 +1997,14 @@ static void update_adaptive_widescreen() { * teardown resets them and macOS otherwise supplies a legacy 2.1 context. */ static void configure_core_gl_context_attributes() { SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + int major = 3, minor = 3; +#if defined(PSX_OPENXR) + /* Opt-in headset context. Ordinary GL launches keep the historic 3.3. */ + const char *xr = std::getenv("PSX_OPENXR"); + if (xr && xr[0] && xr[0] != '0') { major = 4; minor = 6; } +#endif + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, major); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, minor); SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); } diff --git a/runtime/src/psx_openxr.c b/runtime/src/psx_openxr.c new file mode 100644 index 00000000..4bc68210 --- /dev/null +++ b/runtime/src/psx_openxr.c @@ -0,0 +1,573 @@ +/* Opt-in Win32 OpenXR lifecycle. No guest state, console diagnostics or timing + * phases: located poses and projection submission belong to one host frame. */ +#include "psx_openxr.h" +#include "vr_pose_math.h" +#include +#include +#include +static PSXOpenXRStats s_stats; +static uint64_t s_pair_id,s_pair_cycle; +static uint32_t s_source; +static uint64_t s_native_frame; +static double s_quad_distance,s_quad_width,s_quad_height; +static PSXModOpenXRInput s_input; +static PSXModOpenXRHands s_hands; +static uint64_t s_hands_sequence, s_hands_sample_ms; +static void hands_clear(void) { + memset(&s_hands,0,sizeof s_hands);s_hands.struct_size=sizeof s_hands; + s_hands.sequence=++s_hands_sequence;s_hands.age_ms=UINT32_MAX; + s_hands_sample_ms=0; +} +#ifndef PSX_NO_DEBUG_TOOLS +static int s_input_override; +static PSXModOpenXRInput s_injected; +static int s_hands_override; +static PSXModOpenXRHands s_injected_hands; +static int valid_pose_numbers(const double q[4],const double p[3]) { + double n=0; + for(int i=0;i<4;i++){if(!isfinite(q[i]))return 0;n+=q[i]*q[i];} + for(int i=0;i<3;i++)if(!isfinite(p[i]) || fabs(p[i])>100)return 0; + return n>=.5 && n<=1.5; +} +int psx_openxr_hands_override(const PSXModOpenXRHands *hands) { + if(!hands){s_hands_override=0;hands_clear();return 1;} + if(hands->struct_size!=sizeof *hands || hands->focused>1 || hands->origin_valid>1)return 0; + for(int i=0;i<4;i++)if(!isfinite(hands->origin_orientation_xyzw[i]))return 0; + for(int i=0;i<3;i++)if(!isfinite(hands->origin_position_m[i]) || fabs(hands->origin_position_m[i])>100)return 0; + if(hands->origin_valid && !valid_pose_numbers(hands->origin_orientation_xyzw,hands->origin_position_m))return 0; + for(int e=0;e<2;e++)for(int k=0;k<2;k++) { + const PSXModTrackedPose *v=&hands->pose[e][k]; + if(v->active>1 || (v->flags & ~15u))return 0; + double q[4],p[3];for(int i=0;i<4;i++)q[i]=v->orientation_xyzw[i]; + for(int i=0;i<3;i++)p[i]=v->position_m[i]; + for(int i=0;i<4;i++)if(!isfinite(q[i]))return 0; + for(int i=0;i<3;i++)if(!isfinite(p[i]) || fabs(p[i])>100)return 0; + if((v->flags & 3u) && !valid_pose_numbers(q,p))return 0; + } + s_injected_hands=*hands;s_injected_hands.synthetic=1; + s_injected_hands.sequence=++s_hands_sequence;s_injected_hands.age_ms=0; + for(int e=0;e<2;e++)for(int k=0;k<2;k++) + if(!hands->focused || !hands->origin_valid || !hands->pose[e][k].active) + memset(&s_injected_hands.pose[e][k],0,sizeof(PSXModTrackedPose)); + s_hands_override=1;return 1; +} +int psx_openxr_input_override(const PSXModOpenXRInput *input) { + if (!input) { s_input_override=0; memset(&s_injected,0,sizeof s_injected); return 1; } + if (input->struct_size!=sizeof *input || input->focused>1 || + input->active[0]>1 || input->active[1]>1) return 0; + for(int e=0;e<2;e++) for(int a=0;a<2;a++) + if(!isfinite(input->stick[e][a]) || fabsf(input->stick[e][a])>1) return 0; + for(int e=0;e<2;e++) { + if(input->trigger_active[e]>1 || input->squeeze_active[e]>1 || + (input->buttons[e] & ~PSX_MOD_XR_CLICKS) || + (input->buttons_active[e] & ~PSX_MOD_XR_CLICKS) || + !isfinite(input->trigger[e]) || input->trigger[e]<0 || input->trigger[e]>1 || + !isfinite(input->squeeze[e]) || input->squeeze[e]<0 || input->squeeze[e]>1)return 0; + } + s_injected=*input; s_input_override=1; return 1; +} +#endif +void psx_openxr_input_snapshot(PSXModOpenXRInput *out) { if(out)*out=s_input; } +void psx_openxr_pair_metadata(uint64_t id,uint64_t cycle){s_pair_id=id;s_pair_cycle=cycle;s_source=1;s_native_frame=0;} +void psx_openxr_native_metadata(uint64_t frame){s_pair_id=s_pair_cycle=0;s_source=2;s_native_frame=frame;} +#if defined(PSX_OPENXR) +#include "host_time.h" +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#define XR_USE_PLATFORM_WIN32 +#define XR_USE_GRAPHICS_API_OPENGL +#include +#include +static XrInstance s_instance; +static XrSystemId s_system; +static XrSession s_session; +static XrSpace s_space; +static XrSpace s_view_space; +static XrActionSet s_actions; +static XrAction s_stick; +static XrAction s_trigger,s_squeeze,s_click[4]; +static XrAction s_pose_action[2]; +static XrSpace s_pose_space[2][2]; +static XrPath s_hand[2]; +static XrTime s_origin_reset_time; +static XrSwapchain s_chain[2]; +static XrSwapchainImageOpenGLKHR *s_images[2]; +static uint32_t s_count[2]; +static int s_w[2],s_h[2],s_origin_valid; +static XrView s_views[2]; +static PSXModRenderView s_render[2]; +static double s_oq[4],s_op[3]; +static XrTime s_time; +static int check(XrResult r,const char *stage) { + s_stats.result=r; + if(XR_FAILED(r)) { s_stats.stage=s_stats.last_failure=stage;s_stats.last_failure_result=r;s_stats.failures++;return 0; } + return 1; +} +static int input_initialize(void) { + XrActionSetCreateInfo set={XR_TYPE_ACTION_SET_CREATE_INFO}; + strcpy(set.actionSetName,"gameplay"); strcpy(set.localizedActionSetName,"Gameplay"); + if(!check(xrCreateActionSet(s_instance,&set,&s_actions),"action_set"))return 0; + if(!check(xrStringToPath(s_instance,"/user/hand/left",&s_hand[0]),"left_hand_path") || + !check(xrStringToPath(s_instance,"/user/hand/right",&s_hand[1]),"right_hand_path"))return 0; + XrActionCreateInfo action={XR_TYPE_ACTION_CREATE_INFO}; + strcpy(action.actionName,"thumbstick"); strcpy(action.localizedActionName,"Thumbstick"); + action.actionType=XR_ACTION_TYPE_VECTOR2F_INPUT; + action.countSubactionPaths=2; action.subactionPaths=s_hand; + if(!check(xrCreateAction(s_actions,&action,&s_stick),"stick_action"))return 0; + action.actionType=XR_ACTION_TYPE_FLOAT_INPUT; + strcpy(action.actionName,"trigger");strcpy(action.localizedActionName,"Trigger"); + if(!check(xrCreateAction(s_actions,&action,&s_trigger),"trigger_action"))return 0; + strcpy(action.actionName,"squeeze");strcpy(action.localizedActionName,"Grip squeeze"); + if(!check(xrCreateAction(s_actions,&action,&s_squeeze),"squeeze_action"))return 0; + const char *names[4]={"primary","secondary","menu","stick_click"}; + const char *labels[4]={"Primary button","Secondary button","Menu","Stick click"}; + action.actionType=XR_ACTION_TYPE_BOOLEAN_INPUT; + for(int i=0;i<4;i++) { + strcpy(action.actionName,names[i]);strcpy(action.localizedActionName,labels[i]); + if(!check(xrCreateAction(s_actions,&action,&s_click[i]),names[i]))return 0; + } + action.actionType=XR_ACTION_TYPE_POSE_INPUT; + for(int k=0;k<2;k++) { + strcpy(action.actionName,k?"aim_pose":"grip_pose"); + strcpy(action.localizedActionName,k?"Aim pose":"Grip pose"); + if(!check(xrCreateAction(s_actions,&action,&s_pose_action[k]),"pose_action"))return 0; + for(int e=0;e<2;e++) { + XrActionSpaceCreateInfo ci={XR_TYPE_ACTION_SPACE_CREATE_INFO}; + ci.action=s_pose_action[k];ci.subactionPath=s_hand[e];ci.poseInActionSpace.orientation.w=1; + if(!check(xrCreateActionSpace(s_session,&ci,&s_pose_space[e][k]),"pose_space"))return 0; + } + } + XrPath profile; + if(!check(xrStringToPath(s_instance,"/interaction_profiles/oculus/touch_controller",&profile),"touch_profile"))return 0; + const char *paths[2][7]={ + {"/user/hand/left/input/thumbstick","/user/hand/left/input/trigger/value", + "/user/hand/left/input/squeeze/value","/user/hand/left/input/x/click", + "/user/hand/left/input/y/click","/user/hand/left/input/menu/click", + "/user/hand/left/input/thumbstick/click"}, + {"/user/hand/right/input/thumbstick","/user/hand/right/input/trigger/value", + "/user/hand/right/input/squeeze/value","/user/hand/right/input/a/click", + "/user/hand/right/input/b/click",NULL,"/user/hand/right/input/thumbstick/click"}}; + XrAction actions[7]={s_stick,s_trigger,s_squeeze,s_click[0],s_click[1],s_click[2],s_click[3]}; + XrActionSuggestedBinding bindings[17];uint32_t n=0; + for(int e=0;e<2;e++)for(int a=0;a<7;a++)if(paths[e][a]) { + bindings[n].action=actions[a]; + if(!check(xrStringToPath(s_instance,paths[e][a],&bindings[n].binding),"touch_input_path"))return 0; + n++; + } + const char *pose_paths[2][2]={{"/user/hand/left/input/grip/pose","/user/hand/left/input/aim/pose"}, + {"/user/hand/right/input/grip/pose","/user/hand/right/input/aim/pose"}}; + for(int e=0;e<2;e++)for(int k=0;k<2;k++) { + bindings[n].action=s_pose_action[k]; + if(!check(xrStringToPath(s_instance,pose_paths[e][k],&bindings[n].binding),"pose_binding"))return 0; + n++; + } + XrInteractionProfileSuggestedBinding suggest={XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING}; + suggest.interactionProfile=profile; suggest.countSuggestedBindings=n; suggest.suggestedBindings=bindings; + if(!check(xrSuggestInteractionProfileBindings(s_instance,&suggest),"touch_bindings"))return 0; + XrSessionActionSetsAttachInfo attach={XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO}; + attach.countActionSets=1; attach.actionSets=&s_actions; + return check(xrAttachSessionActionSets(s_session,&attach),"attach_actions"); +} +static int initialize(void) { + uint32_t count=0; + XrExtensionProperties *ext=NULL; + if(!check(xrEnumerateInstanceExtensionProperties(NULL,0,&count,NULL),"extensions"))return 0; + ext=calloc(count,sizeof *ext); if(!ext){s_stats.stage="extensions_memory";return 0;} + for(uint32_t i=0;istate; + if(e->state==XR_SESSION_STATE_READY && !s_stats.running) { + XrSessionBeginInfo begin={XR_TYPE_SESSION_BEGIN_INFO}; + begin.primaryViewConfigurationType=XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + if(!check(xrBeginSession(s_session,&begin),"begin_session"))return 0; + s_stats.running=1; + } else if(e->state==XR_SESSION_STATE_STOPPING) { + if(!check(xrEndSession(s_session),"end_session"))return 0; + s_stats.running=0; + } else if(e->state==XR_SESSION_STATE_LOSS_PENDING || e->state==XR_SESSION_STATE_EXITING) { + s_stats.enabled=0;s_stats.running=0;s_stats.stage="session_exit";return 0; + } + } else if(event.type==XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING) { + /* Recenter from the next valid located pair. */ + const XrEventDataReferenceSpaceChangePending *e=(const void*)&event; + if(e->referenceSpaceType==XR_REFERENCE_SPACE_TYPE_LOCAL) s_origin_reset_time=e->changeTime; + } else if(event.type==XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) { + s_stats.enabled=0;s_stats.running=0;s_stats.stage="instance_loss";return 0; + } + } + return 1; +} +static void hands_locate(void) { + PSXModOpenXRHands current=s_hands; + current.predicted_time=(uint64_t)s_time; + current.focused=s_stats.state==XR_SESSION_STATE_FOCUSED && s_input.focused; + current.origin_valid=s_origin_valid; + memcpy(current.origin_position_m,s_op,sizeof s_op); + memcpy(current.origin_orientation_xyzw,s_oq,sizeof s_oq); + if(current.focused && current.origin_valid)for(int e=0;e<2;e++)for(int k=0;k<2;k++) { + XrActionStateGetInfo get={XR_TYPE_ACTION_STATE_GET_INFO}; + get.action=s_pose_action[k];get.subactionPath=s_hand[e]; + XrActionStatePose state={XR_TYPE_ACTION_STATE_POSE}; + if(!check(xrGetActionStatePose(s_session,&get,&state),"pose_state"))continue; + PSXModTrackedPose *v=¤t.pose[e][k];v->active=state.isActive; + if(!state.isActive)continue; + XrSpaceLocation loc={XR_TYPE_SPACE_LOCATION}; + XrResult r=xrLocateSpace(s_pose_space[e][k],s_space,s_time,&loc); + if(!check(r,"locate_hand") || r!=XR_SUCCESS)continue; + v->flags=(uint32_t)loc.locationFlags & 15u; + if((v->flags & 3u)!=3u)continue; /* Values remain zero for incomplete poses. */ + double norm=0;float q[4]={loc.pose.orientation.x,loc.pose.orientation.y, + loc.pose.orientation.z,loc.pose.orientation.w}; + float p[3]={loc.pose.position.x,loc.pose.position.y,loc.pose.position.z}; + int valid=1; + for(int i=0;i<4;i++){if(!isfinite(q[i]))valid=0;norm+=(double)q[i]*q[i];} + for(int i=0;i<3;i++)if(!isfinite(p[i]) || fabsf(p[i])>100)valid=0; + if(!valid || norm<.5 || norm>1.5){v->flags=0;continue;} + memcpy(v->orientation_xyzw,q,sizeof q);memcpy(v->position_m,p,sizeof p); + } + s_hands=current;s_hands_sample_ms=psx_host_mono_ms(); +} +#endif +int psx_openxr_hands(PSXModOpenXRHands *out) { + if(!out || out->struct_size!=sizeof *out)return 0; +#ifndef PSX_NO_DEBUG_TOOLS + if(s_hands_override){*out=s_injected_hands;return 1;} +#endif + *out=s_hands;out->struct_size=sizeof *out;out->age_ms=UINT32_MAX; +#if defined(PSX_OPENXR) + if(s_hands_sample_ms) { + uint64_t now=psx_host_mono_ms(); + uint64_t age=now>=s_hands_sample_ms?now-s_hands_sample_ms:UINT32_MAX; + out->age_ms=agecompiled=1; +#endif + } +} +void psx_openxr_recenter(void) { + hands_clear(); +#if defined(PSX_OPENXR) + s_origin_valid=0; +#endif +} +void psx_openxr_shutdown(void) { +#if defined(PSX_OPENXR) + if(s_stats.frame_open)psx_openxr_end(0,NULL); + for(int eye=0;eye<2;eye++) { + for(int k=0;k<2;k++) { + if(s_pose_space[eye][k])xrDestroySpace(s_pose_space[eye][k]); + s_pose_space[eye][k]=XR_NULL_HANDLE; + } + if(s_chain[eye])xrDestroySwapchain(s_chain[eye]); + s_chain[eye]=XR_NULL_HANDLE;free(s_images[eye]);s_images[eye]=NULL;s_count[eye]=0; + } + if(s_space)xrDestroySpace(s_space);s_space=XR_NULL_HANDLE; + if(s_view_space)xrDestroySpace(s_view_space);s_view_space=XR_NULL_HANDLE; + if(s_session)xrDestroySession(s_session);s_session=XR_NULL_HANDLE; + if(s_actions)xrDestroyActionSet(s_actions);s_actions=XR_NULL_HANDLE;s_stick=XR_NULL_HANDLE; + s_trigger=s_squeeze=XR_NULL_HANDLE;memset(s_click,0,sizeof s_click); + memset(s_pose_action,0,sizeof s_pose_action); + if(s_instance)xrDestroyInstance(s_instance);s_instance=XR_NULL_HANDLE; + s_origin_valid=0;s_origin_reset_time=0;s_stats.initialized=s_stats.running=s_stats.tracking=s_stats.frame_open=0; +#endif + s_stats.enabled=0; + s_quad_distance=s_quad_width=s_quad_height=0; + memset(&s_input,0,sizeof s_input); + hands_clear(); +#ifndef PSX_NO_DEBUG_TOOLS + s_input_override=0; + s_hands_override=0;memset(&s_injected_hands,0,sizeof s_injected_hands); +#endif +} +int psx_openxr_enable(int enabled) { + psx_openxr_shutdown();memset(&s_stats,0,sizeof s_stats); +#if defined(PSX_OPENXR) + s_stats.compiled=1;s_stats.enabled=enabled!=0;s_stats.stage=enabled?"requested":"off";return 1; +#else + s_stats.stage="not_compiled";return !enabled; +#endif +} +int psx_openxr_view(uint32_t eye,PSXModRenderView *out) { +#if defined(PSX_OPENXR) + if(s_stats.frame_open && s_stats.tracking && out && eye<2){*out=s_render[eye];return 1;} +#endif + return 0; +} +int psx_openxr_input(PSXModOpenXRInput *out) { + if(!out || out->struct_size!=sizeof *out)return 0; + uint64_t seq=s_input.sequence+1; + memset(&s_input,0,sizeof s_input);s_input.struct_size=sizeof s_input;s_input.sequence=seq; +#ifndef PSX_NO_DEBUG_TOOLS + if(s_input_override) { + s_input=s_injected;s_input.sequence=seq;s_input.synthetic=1; + for(int e=0;e<2;e++) if(!s_input.focused || !s_input.active[e]) + s_input.stick[e][0]=s_input.stick[e][1]=0; + for(int e=0;e<2;e++) { + if(!s_input.focused || !s_input.trigger_active[e])s_input.trigger[e]=0; + if(!s_input.focused || !s_input.squeeze_active[e])s_input.squeeze[e]=0; + s_input.buttons[e] &= s_input.focused ? s_input.buttons_active[e] : 0; + } + if(!s_input.focused)hands_clear(); + *out=s_input;return 1; + } +#endif +#if defined(PSX_OPENXR) + if(s_stats.enabled && s_stats.initialized && !s_stats.frame_open && events() && s_stats.running) { + XrActiveActionSet active={s_actions,XR_NULL_PATH}; + XrActionsSyncInfo sync={XR_TYPE_ACTIONS_SYNC_INFO};sync.countActiveActionSets=1;sync.activeActionSets=&active; + XrResult r=xrSyncActions(s_session,&sync); + if(check(r,"sync_actions") && r==XR_SUCCESS && s_stats.state==XR_SESSION_STATE_FOCUSED) { + PSXModOpenXRInput current=s_input;current.focused=1;int valid=1; + for(int e=0;e<2;e++) { + XrActionStateGetInfo get={XR_TYPE_ACTION_STATE_GET_INFO};get.action=s_stick;get.subactionPath=s_hand[e]; + XrActionStateVector2f state={XR_TYPE_ACTION_STATE_VECTOR2F}; + if(!check(xrGetActionStateVector2f(s_session,&get,&state),"stick_state")) { valid=0; break; } + current.active[e]=state.isActive; + if(state.isActive) { + current.stick[e][0]=state.currentState.x;current.stick[e][1]=state.currentState.y; + for(int a=0;a<2;a++) if(!isfinite(current.stick[e][a]) || fabsf(current.stick[e][a])>1) valid=0; + } + XrAction analog[2]={s_trigger,s_squeeze}; + for(int a=0;a<2;a++) { + get.action=analog[a]; + XrActionStateFloat value={XR_TYPE_ACTION_STATE_FLOAT}; + if(!check(xrGetActionStateFloat(s_session,&get,&value),"analog_action_state")) { valid=0;break; } + float v=value.isActive?value.currentState:0; + if(!isfinite(v) || v<0 || v>1) { valid=0;break; } + if(a==0){current.trigger_active[e]=value.isActive;current.trigger[e]=v;} + else {current.squeeze_active[e]=value.isActive;current.squeeze[e]=v;} + } + for(int a=0;a<4;a++) { + get.action=s_click[a]; + XrActionStateBoolean value={XR_TYPE_ACTION_STATE_BOOLEAN}; + if(!check(xrGetActionStateBoolean(s_session,&get,&value),"click_action_state")) { valid=0;break; } + if(value.isActive) { + current.buttons_active[e] |= 1u<orientation.x;q[i][1]=v->orientation.y;q[i][2]=v->orientation.z;q[i][3]=v->orientation.w; + p[i][0]=v->position.x;p[i][1]=v->position.y;p[i][2]=v->position.z; + } + if(s_origin_reset_time && s_time>=s_origin_reset_time) { + s_origin_valid=0;s_origin_reset_time=0; + } + if(!s_origin_valid) { + memcpy(s_oq,q[0],sizeof s_oq); + for(int j=0;j<3;j++)s_op[j]=(p[0][j]+p[1][j])*.5; + s_origin_valid=1; + } + double separation=0; + for(int j=0;j<3;j++)separation+=(p[1][j]-p[0][j])*(p[1][j]-p[0][j]); + s_stats.ipd_m=(float)sqrt(separation); + for(int i=0;i<2;i++) { + XrFovf *v=&s_views[i].fov; + double f[4]={v->angleLeft,v->angleRight,v->angleUp,v->angleDown}; + if(!vr_pose_to_view(q[i],p[i],s_oq,s_op,f,units,width,height,&s_render[i])) { + s_stats.stage="view_math";psx_openxr_end(0,NULL);return 0; + } + } + for(int i=0;i<2;i++) { + for(int j=0;j<3;j++)s_stats.pose[i][j]=(float)p[i][j]; + for(int j=0;j<4;j++)s_stats.pose[i][j+3]=(float)q[i][j]; + s_stats.fov[i][0]=s_views[i].fov.angleLeft;s_stats.fov[i][1]=s_views[i].fov.angleRight; + s_stats.fov[i][2]=s_views[i].fov.angleUp;s_stats.fov[i][3]=s_views[i].fov.angleDown; + s_stats.view[i]=s_render[i]; + } + hands_locate(); + s_stats.tracking=1;s_stats.stage="located";return 1; +#endif + return 0; +} +int psx_openxr_quad(double distance,double width,double height) { + if(!isfinite(distance) || !isfinite(width) || !isfinite(height))return 0; + if(distance!=0 && (distance<.25 || distance>20 || width<=0 || width>20 || height<=0 || height>20))return 0; +#if defined(PSX_OPENXR) + if(!s_stats.frame_open || !s_stats.tracking)return 0; + if(distance && !s_view_space) { + XrReferenceSpaceCreateInfo space={XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; + space.referenceSpaceType=XR_REFERENCE_SPACE_TYPE_VIEW;space.poseInReferenceSpace.orientation.w=1; + if(!check(xrCreateReferenceSpace(s_session,&space,&s_view_space),"quad_view_space"))return 0; + } + s_quad_distance=distance;s_quad_width=distance?width:0;s_quad_height=distance?height:0;return 1; +#else + return 0; +#endif +} +int psx_openxr_end(int keep,PSXOpenXRCopy copy) { +#if defined(PSX_OPENXR) + if(!s_stats.frame_open)return 0; + XrCompositionLayerProjectionView pv[2]={{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW},{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}}; + int complete=keep && copy && s_stats.tracking; + int quad=s_quad_distance>0; + for(int eye=0;eye<(quad?1:2) && complete;eye++) { + uint32_t index=0; + XrSwapchainImageAcquireInfo ai={XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; + if(!check(xrAcquireSwapchainImage(s_chain[eye],&ai,&index),"acquire")){complete=0;break;} + XrSwapchainImageWaitInfo wi={XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};wi.timeout=1000000000; + XrResult wr=xrWaitSwapchainImage(s_chain[eye],&wi); + /* A timeout leaves ownership acquired. Wait before releasing; never + * release an image the runtime has not made writable. */ + if(wr==XR_TIMEOUT_EXPIRED){wi.timeout=XR_INFINITE_DURATION;wr=xrWaitSwapchainImage(s_chain[eye],&wi);} + int writable=check(wr,"wait_image"); + if(writable) { + if(index>=s_count[eye] || !copy(eye,s_images[eye][index].image,s_w[eye],s_h[eye])) { + s_stats.stage="copy_image";s_stats.failures++;complete=0; + } + XrSwapchainImageReleaseInfo ri={XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + if(!check(xrReleaseSwapchainImage(s_chain[eye],&ri),"release"))complete=0; + } else complete=0; + pv[eye].pose=s_views[eye].pose;pv[eye].fov=s_views[eye].fov; + pv[eye].subImage.swapchain=s_chain[eye];pv[eye].subImage.imageRect.extent.width=s_w[eye]; + pv[eye].subImage.imageRect.extent.height=s_h[eye]; + } + XrCompositionLayerProjection layer={XR_TYPE_COMPOSITION_LAYER_PROJECTION}; + layer.space=s_space;layer.viewCount=2;layer.views=pv; + XrCompositionLayerQuad surface={XR_TYPE_COMPOSITION_LAYER_QUAD}; + surface.space=s_view_space;surface.eyeVisibility=XR_EYE_VISIBILITY_BOTH; + surface.subImage=pv[0].subImage;surface.pose.orientation.w=1; + surface.pose.position.z=(float)-s_quad_distance; + surface.size.width=(float)s_quad_width;surface.size.height=(float)s_quad_height; + const XrCompositionLayerBaseHeader *layers[]={quad?(void*)&surface:(void*)&layer}; + XrFrameEndInfo end={XR_TYPE_FRAME_END_INFO};end.displayTime=s_time; + end.environmentBlendMode=XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + end.layerCount=complete?1:0;end.layers=complete?layers:NULL; + int ok=check(xrEndFrame(s_session,&end),"end_frame");s_stats.frame_open=0; + if(ok && complete) { + 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; + s_stats.submitted_layer=quad?2:1; + s_stats.submitted_source=s_source;s_stats.submitted_native_frame=s_native_frame; + if(s_source==2)s_stats.native_submitted++; + if(quad){s_stats.quad_submitted++;s_stats.quad_distance_m=s_quad_distance; + s_stats.quad_width_m=s_quad_width;s_stats.quad_height_m=s_quad_height;} + } else s_stats.empty++; + if(ok && complete)s_stats.stage="submitted"; + s_quad_distance=s_quad_width=s_quad_height=0; + return ok && complete; +#else + return 0; +#endif +} diff --git a/runtime/tests/run_gl_readback_region.py b/runtime/tests/run_gl_readback_region.py index 028389be..b7643b90 100644 --- a/runtime/tests/run_gl_readback_region.py +++ b/runtime/tests/run_gl_readback_region.py @@ -69,7 +69,8 @@ def main(): # The renderer's render-pass paths call the journal and VRAM policy in # render_pass_plan.c (self-contained: no other runtime symbols). sources = [("probe", fixture), ("sw", framework / "runtime/src/gpu_sw_renderer.c"), - ("rp", framework / "runtime/src/render_pass_plan.c")] + ("rp", framework / "runtime/src/render_pass_plan.c"), + ("xr", framework / "runtime/src/psx_openxr.c")] for name, source in sources: if run([compiler / "gcc.exe", "-std=c11", "-O2", "-flto", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", *includes, "-c", source, diff --git a/runtime/tests/test_openxr_input.c b/runtime/tests/test_openxr_input.c new file mode 100644 index 00000000..8cd816c2 --- /dev/null +++ b/runtime/tests/test_openxr_input.c @@ -0,0 +1,79 @@ +#include "psx_openxr.h" +#include +#include +/* Evaluate side effects and failures even when NDEBUG is defined. */ +#define CHECK(expr) do { \ + if (!(expr)) { \ + fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); \ + return 1; \ + } \ +} while (0) +int main(void) { + /* UI requests require a live tracked frame. Off/unavailable requests + * must leave submission state neutral, including invalid dimensions. */ + CHECK(!psx_openxr_quad(2,2,1.5)); + CHECK(!psx_openxr_quad(NAN,2,1.5)); + CHECK(!psx_openxr_quad(.1,2,1.5)); + CHECK(!psx_openxr_quad(2,0,1.5)); + CHECK(!psx_openxr_quad(0,0,0)); + PSXOpenXRStats stats;psx_openxr_stats(&stats); + CHECK(!stats.submitted_layer && !stats.quad_submitted); + CHECK(!stats.submitted_source && !stats.native_submitted && !stats.submitted_native_frame); + PSXModOpenXRInput p={0},s={0}; + p.struct_size=sizeof p; + CHECK(psx_openxr_input(&p) && !p.focused && p.stick[0][0]==0 && !p.synthetic); + s.struct_size=sizeof s;s.focused=1;s.active[0]=s.active[1]=1; + s.stick[0][0]=-.75f;s.stick[0][1]=1;s.stick[1][0]=.6f; + s.trigger_active[1]=1;s.trigger[1]=.75f; + s.squeeze_active[0]=1;s.squeeze[0]=.6f; + s.buttons_active[0]=PSX_MOD_XR_PRIMARY|PSX_MOD_XR_MENU; + s.buttons[0]=PSX_MOD_XR_PRIMARY|PSX_MOD_XR_SECONDARY|PSX_MOD_XR_MENU; + CHECK(psx_openxr_input_override(&s)); + CHECK(psx_openxr_input(&p) && p.synthetic && p.stick[0][0]==-.75f && p.stick[1][0]==.6f); + CHECK(p.trigger[1]==.75f && p.squeeze[0]==.6f); + CHECK(p.buttons[0]==(PSX_MOD_XR_PRIMARY|PSX_MOD_XR_MENU)); + uint64_t seq=p.sequence;psx_openxr_input_snapshot(&p);CHECK(p.sequence==seq); + s.focused=0;CHECK(psx_openxr_input_override(&s)); + CHECK(psx_openxr_input(&p) && p.synthetic && !p.focused && p.stick[0][1]==0 && p.stick[1][0]==0); + CHECK(!p.trigger[1] && !p.squeeze[0] && !p.buttons[0]); + s.focused=1;s.active[0]=0;CHECK(psx_openxr_input_override(&s)); + CHECK(psx_openxr_input(&p) && p.stick[0][0]==0 && p.stick[1][0]==.6f); + CHECK(p.squeeze[0]==.6f && p.buttons[0]); /* independent of stick activity */ + s.trigger_active[1]=0;s.squeeze_active[0]=0;s.buttons_active[0]=0; + CHECK(psx_openxr_input_override(&s)); + CHECK(psx_openxr_input(&p) && !p.trigger[1] && !p.squeeze[0] && !p.buttons[0]); + s.trigger[0]=NAN;CHECK(!psx_openxr_input_override(&s));s.trigger[0]=0; + s.squeeze[0]=1.1f;CHECK(!psx_openxr_input_override(&s));s.squeeze[0]=0; + s.buttons_active[0]=16;CHECK(!psx_openxr_input_override(&s));s.buttons_active[0]=0; + s.stick[0][0]=NAN;CHECK(!psx_openxr_input_override(&s)); + s.stick[0][0]=1.1f;CHECK(!psx_openxr_input_override(&s)); + CHECK(psx_openxr_input_override(0)); + CHECK(psx_openxr_input(&p) && !p.synthetic && p.stick[1][0]==0); + s.stick[0][0]=1;CHECK(psx_openxr_input_override(&s)); + psx_openxr_shutdown();CHECK(psx_openxr_input(&p) && !p.synthetic && !p.focused); + PSXModOpenXRHands h={0},got={0};got.struct_size=sizeof got; + CHECK(psx_openxr_hands(&got) && !got.focused && got.age_ms==UINT32_MAX); + h.struct_size=sizeof h;h.focused=1;h.origin_valid=1;h.origin_orientation_xyzw[3]=1; + h.pose[1][1].active=1;h.pose[1][1].flags=15; + h.pose[1][1].orientation_xyzw[3]=1;h.pose[1][1].position_m[0]=.3f; + CHECK(psx_openxr_hands_override(&h)); + CHECK(psx_openxr_hands(&got) && got.synthetic && got.age_ms==0 && got.pose[1][1].position_m[0]==.3f); + uint64_t hands_seq=got.sequence; + CHECK(psx_openxr_hands(&got) && got.sequence==hands_seq); /* Read only. */ + h.pose[1][1].orientation_xyzw[3]=0;CHECK(!psx_openxr_hands_override(&h)); + CHECK(psx_openxr_hands(&got) && got.sequence==hands_seq); /* Reject before mutation. */ + h.pose[1][1].orientation_xyzw[3]=1;h.pose[1][1].flags=16;CHECK(!psx_openxr_hands_override(&h)); + h.pose[1][1].flags=1;CHECK(psx_openxr_hands_override(&h)); /* Partial flags retained. */ + CHECK(psx_openxr_hands(&got) && got.pose[1][1].flags==1); + h.pose[1][1].position_m[0]=NAN;CHECK(!psx_openxr_hands_override(&h));h.pose[1][1].position_m[0]=.3f; + h.focused=0;CHECK(psx_openxr_hands_override(&h)); + CHECK(psx_openxr_hands(&got) && !got.focused && !got.pose[1][1].flags && !got.pose[1][1].active); + h.focused=1;h.pose[1][1].active=0;CHECK(psx_openxr_hands_override(&h)); + CHECK(psx_openxr_hands(&got) && !got.pose[1][1].flags); + CHECK(psx_openxr_hands_override(NULL)); + CHECK(psx_openxr_hands(&got) && !got.synthetic && !got.pose[1][1].flags); + got.struct_size=0;CHECK(!psx_openxr_hands(&got));got.struct_size=sizeof got; + h.pose[1][1].active=1;CHECK(psx_openxr_hands_override(&h)); + psx_openxr_shutdown();CHECK(psx_openxr_hands(&got) && !got.synthetic && !got.origin_valid); + return 0; +}