From 18d52c8bfbe5afc380292ef3116af2920225a92e Mon Sep 17 00:00:00 2001 From: Yves Date: Fri, 23 Oct 2026 03:26:53 +0800 Subject: [PATCH] Add debug synthetic head translation and pitch Co-authored-by: Yves --- docs/TCP_COMMANDS.md | 5 ++- .../upstream/vr-synthetic-head-pose.md | 17 +++++++++ runtime/src/debug_server.c | 22 ++++++++++- runtime/src/psx_openxr.c | 38 +++++++++++++++++++ runtime/tests/test_openxr_input.c | 24 ++++++++++++ 5 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 docs/internal/upstream/vr-synthetic-head-pose.md diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index bece7d73..f2be50f7 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -493,9 +493,9 @@ between Play and TCP availability. ## Complete command index (generated) -**384 commands registered** — 371 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**385 commands registered** — 372 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -85 of 384 have prose above; **299 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. +85 of 385 have prose above; **300 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. @@ -705,6 +705,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `openxr_hands_override` | ✓ | | | | `openxr_input` | ✓ | | | | `openxr_input_override` | ✓ | | | +| `openxr_pose_override` | ✓ | | | | `openxr_stats` | ✓ | | | | `openxr_views` | ✓ | | | | `overlay_candidates` | ✓ | | | diff --git a/docs/internal/upstream/vr-synthetic-head-pose.md b/docs/internal/upstream/vr-synthetic-head-pose.md new file mode 100644 index 00000000..d4ea41db --- /dev/null +++ b/docs/internal/upstream/vr-synthetic-head-pose.md @@ -0,0 +1,17 @@ +# Add debug synthetic head translation and pitch + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [2ed12640](https://github.com/FractalEngineer/psxrecomp/commit/2ed12640adab3602acbf3307d42e9c2739b7d0eb) +- [a3714fad](https://github.com/FractalEngineer/psxrecomp/commit/a3714fada4d0582500001b0c22e4dfa00840403e) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: this feature and its tests. Excluded: other VR features, game policy, +assets and chronological investigation notes. +Base: `e4f1f985428101eb6c8f83fce834e281860b1fd2` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release: XR-off input fixture passes, including finite/range rejection, binocular translation/pitch, disabled state and clearing; command index is current. + +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/src/debug_server.c b/runtime/src/debug_server.c index bca89a24..43ac2544 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9218,6 +9218,25 @@ static void handle_openxr_hands_override(int id,const char *json) { /* 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 +/* openxr_pose_override: test-only synthetic head motion in millimetres, with an optional pitch_deg + * look angle, for headless head-motion checks. clear=1 disarms. Each call is absolute. The pitch is + * what reaches the defects that only appear when looking down, which no translation can show. */ +static void handle_openxr_pose_override(int id,const char *json) { + extern int psx_openxr_pose_override(double,double,double); + extern int psx_openxr_pose_pitch_override(double); + extern void psx_openxr_pose_clear(void); + if(json_get_int(json,"clear",0)){ psx_openxr_pose_clear(); send_fmt("{\"id\":%d,\"ok\":true,\"cleared\":1}",id); return; } + int x=json_get_int(json,"x_mm",0),y=json_get_int(json,"y_mm",0),z=json_get_int(json,"z_mm",0); + int pitch_deg=json_get_int(json,"pitch_deg",0); + if(!psx_openxr_pose_override((double)x/1000.0,(double)y/1000.0,(double)z/1000.0)){ + send_err(id,"pose offset must be finite and within +/-10 m");return; + } + if(!psx_openxr_pose_pitch_override((double)pitch_deg)){ + send_err(id,"pitch_deg must be finite and within +/-90");return; + } + send_fmt("{\"id\":%d,\"ok\":true,\"x_mm\":%d,\"y_mm\":%d,\"z_mm\":%d,\"pitch_deg\":%d}",id,x,y,z,pitch_deg); +} + static void handle_openxr_input_override(int id,const char *json) { if(json_get_int(json,"clear",0)) psx_openxr_input_override(NULL); else { @@ -16294,7 +16313,8 @@ static const CmdEntry s_commands[] = { #endif { "openxr_hands", handle_openxr_hands }, #ifndef PSX_NO_DEBUG_TOOLS - { "openxr_hands_override", handle_openxr_hands_override }, + { "openxr_hands_override", handle_openxr_hands_override }, + { "openxr_pose_override", handle_openxr_pose_override }, #endif { "stereo_stats", handle_stereo_stats }, { "frame_trace", handle_frame_trace }, diff --git a/runtime/src/psx_openxr.c b/runtime/src/psx_openxr.c index 3404dedf..ada0af65 100644 --- a/runtime/src/psx_openxr.c +++ b/runtime/src/psx_openxr.c @@ -43,6 +43,41 @@ static int valid_pose_numbers(const double q[4],const double p[3]) { for(int i=0;i<3;i++)if(!isfinite(p[i]) || fabs(p[i])>100)return 0; return n>=.5 && n<=1.5; } +/* Test-only synthetic head motion: translation in room-space metres and an optional pitch, added to + * both eyes after the origin is established so it reads as the head moving in the world. The + * translation checks that the world shifts rigidly with the head. The pitch exists because the + * defects that only appear when looking down cannot be reached by translation, and because the only + * other look path is unwired. Armed by the first override call; default off, so shipped behaviour + * is unchanged. */ +static int s_pose_bob_armed; +static double s_pose_bob_m[3]; +static double s_pose_bob_pitch_deg; +void psx_openxr_pose_bob(double q[2][4],double p[2][3]) { + if(!s_pose_bob_armed)return; + for(int i=0;i<2;i++)for(int j=0;j<3;j++)p[i][j]+=s_pose_bob_m[j]; + if(s_pose_bob_pitch_deg!=0.0) { + double h=s_pose_bob_pitch_deg*(3.141592653589793/180.0)*0.5,sq=sin(h),cq=cos(h); + for(int i=0;i<2;i++) { + double x=q[i][0],y=q[i][1],z=q[i][2],w=q[i][3]; + /* Left multiply by a rotation about the XR X axis: a look up or down. */ + q[i][0]=sq*w+cq*x;q[i][1]=cq*y-sq*z;q[i][2]=sq*y+cq*z;q[i][3]=cq*w-sq*x; + } + } +} +int psx_openxr_pose_override(double x_m,double y_m,double z_m) { + if(!isfinite(x_m)||!isfinite(y_m)||!isfinite(z_m))return 0; + if(fabs(x_m)>10.0||fabs(y_m)>10.0||fabs(z_m)>10.0)return 0; + s_pose_bob_m[0]=x_m;s_pose_bob_m[1]=y_m;s_pose_bob_m[2]=z_m;s_pose_bob_armed=1; + return 1; +} +int psx_openxr_pose_pitch_override(double pitch_deg) { + if(!isfinite(pitch_deg) || fabs(pitch_deg)>90.0)return 0; + s_pose_bob_pitch_deg=pitch_deg;s_pose_bob_armed=1; + return 1; +} +void psx_openxr_pose_clear(void) { + s_pose_bob_m[0]=s_pose_bob_m[1]=s_pose_bob_m[2]=0.0;s_pose_bob_pitch_deg=0.0;s_pose_bob_armed=0; +} 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; @@ -643,6 +678,9 @@ int psx_openxr_begin(int width,int height,double units) { 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); +#ifndef PSX_NO_DEBUG_TOOLS + psx_openxr_pose_bob(q, p); /* test-only synthetic head motion; no-op unless armed */ +#endif for(int i=0;i<2;i++) { XrFovf *v=&s_views[i].fov; double f[4]={v->angleLeft,v->angleRight,v->angleUp,v->angleDown}; diff --git a/runtime/tests/test_openxr_input.c b/runtime/tests/test_openxr_input.c index d9b7254a..416044e1 100644 --- a/runtime/tests/test_openxr_input.c +++ b/runtime/tests/test_openxr_input.c @@ -2,6 +2,10 @@ #include #include #include "xr_surface_math.h" +extern void psx_openxr_pose_bob(double q[2][4], double p[2][3]); +extern int psx_openxr_pose_override(double, double, double); +extern int psx_openxr_pose_pitch_override(double); +extern void psx_openxr_pose_clear(void); /* Evaluate side effects and failures even when NDEBUG is defined. */ #define CHECK(expr) do { \ if (!(expr)) { \ @@ -10,6 +14,26 @@ } \ } while (0) int main(void) { + /* Synthetic controls are absolute, bounded and inert after clearing. */ + double bob_q[2][4]={{0,0,0,1},{0,0,0,1}}; + double bob_p[2][3]={{-.03,0,0},{.03,0,0}}; + psx_openxr_pose_clear(); + CHECK(!psx_openxr_pose_override(NAN,0,0)); + CHECK(!psx_openxr_pose_override(11,0,0)); + CHECK(!psx_openxr_pose_pitch_override(INFINITY)); + CHECK(!psx_openxr_pose_pitch_override(91)); + psx_openxr_pose_bob(bob_q,bob_p); + CHECK(bob_p[0][0]==-.03 && bob_q[0][3]==1); + CHECK(psx_openxr_pose_override(.1,.2,-.3)); + CHECK(psx_openxr_pose_pitch_override(30)); + psx_openxr_pose_bob(bob_q,bob_p); + CHECK(fabs(bob_p[0][0]-.07)<1e-12 && fabs(bob_p[1][0]-.13)<1e-12); + CHECK(bob_p[0][1]==.2 && bob_p[1][2]==-.3); + CHECK(fabs(bob_q[0][0]-sin(3.141592653589793/12))<1e-12); + CHECK(bob_q[0][0]==bob_q[1][0] && bob_q[0][3]==bob_q[1][3]); + psx_openxr_pose_clear(); + psx_openxr_pose_bob(bob_q,bob_p); + CHECK(fabs(bob_p[0][0]-.07)<1e-12); /* Shared head center gives coherent binocular geometry. Billboarding must * ignore attachment roll, respond to head motion, and reject near-plane * crossings. Common translation of eye/head/surface preserves projection. */