diff --git a/docs/STEREO_RENDERING.md b/docs/STEREO_RENDERING.md index ab8da0c7..e3ec8063 100644 --- a/docs/STEREO_RENDERING.md +++ b/docs/STEREO_RENDERING.md @@ -32,7 +32,11 @@ per swap, LEFT on the left, RIGHT on the right, with each eye using the configur display aspect. Zero disables it. The first implementation uses ordinary game VBlank presentation cadence; it does not submit to OpenXR or create headset poses. Stereo and temporal interpolation should be configured separately; SBS takes -precedence when a compatible complete pair is available. +precedence when a compatible complete pair is available. A pair is shown only while +the plugin keeps submitting: it expires 24 VBlanks of guest time after its capture +cycle (3x the slowest 8-VBlank cadence) and the presenter falls back to the normal +flat image (menus, same-size FMV, a plugin that stopped submitting). Savestate/rollback loads and +session resets drop the pair immediately. Inside an eye callback, `psx_mod_render_view_offset(x,y,z)` replaces a scoped camera-space translation. RTPS and RTPT add it alongside TR before projection; @@ -42,6 +46,16 @@ previous host value. Calls outside a render transaction are refused. The GTE test compares this seam against explicit pre-divide translation and measures 12 pixels at Z=800 versus 3 pixels at Z=3200 for X=24 and H=400. +`psx_mod_render_view` (full pose) is an explicit per-eye projection. With +`projection=1` it REPLACES the guest X/Y projection entirely: guest `H`, the +widescreen/dome squash and the `[video] fov_scale` scaled-H do not feed it, and the +guest-formula PGXP shadows are skipped for those vertices. The only coupling is +`projection_h_ref`: when non-zero the focal lengths are multiplied by (fov-scaled +guest `H`) / `projection_h_ref`, so a plugin can follow per-draw H changes. SZ, the +depth cue and divide flags still come from the guest division. The pose exists only +inside a render transaction and is restored with it, so nothing outside the sandbox +sees it. `test_render_pose` covers the override together with `fov_scale`. + TCP inspection: - `stereo_stats`: pair outcomes, eye entry cycles/state hashes under VERIFY, diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index f0477c77..0eb95e46 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -460,7 +460,10 @@ int psx_mod_render_view_offset(int32_t x, int32_t y, int32_t z); /* Rigid camera transform after guest RT*V+TR, before division. Rotation is * row-major Q12; translation uses camera units. Optional projection supplies * focal lengths and centre deltas from guest OFX/OFY in Q16 pixel units. - * Identity rotation with projection=0 preserves the architectural path. */ + * Identity rotation with projection=0 preserves the architectural path. + * projection=1 REPLACES the guest X/Y projection (guest H, widescreen squash and + * [video] fov_scale do not apply); projection_h_ref != 0 scales the focal lengths + * by (fov-scaled guest H)/ref. Valid only inside a render callback; restored with it. */ typedef struct PSXModRenderView { uint32_t struct_size; int32_t rotation_q12[9], translation[3]; diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index d8548e51..f1f25b41 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -1054,7 +1054,20 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) dome_probe_note(gte->SZ[3]); /* locate the dome draw fn (far-vertex tally) */ int64_t sx16 = gte->OFX + xterm; int64_t sy16 = gte->OFY + (int64_t)gte->IR2 * h_div_sz; - if (s_render_pose.projection && gte->SZ[3]) { + /* Render-view projection override (psx_mod_render_view, projection=1). + * Only reachable inside a render-pass sandbox (set/restored by render_pass.c, + * rolled back with the GTE state), so no guest-visible state outside the + * pass sees it. It is an explicit per-eye projection and REPLACES the guest + * X/Y projection above entirely: the [video] fov_scale scaled-H, the + * widescreen/dome squash and guest H do not feed it, except through + * projection_h_ref, which deliberately multiplies the focal lengths by + * (fov-scaled guest H) / projection_h_ref so a plugin can follow the game's + * own per-draw H changes (and fov_scale) when it asks to. The guest-formula + * PGXP shadows below (precise projection, projection tracking) describe the + * guest projection and are skipped under the override. SZ, MAC0/IR0 depth + * cue and the divide flags still come from the guest division above. */ + const bool proj_override = s_render_pose.projection && gte->SZ[3]; + if (proj_override) { sx16 = (int64_t)gte->OFX + s_render_pose.cx_delta_q16 + (int64_t)gte->IR1 * s_render_pose.fx_q16 / gte->SZ[3]; sy16 = (int64_t)gte->OFY + s_render_pose.cy_delta_q16 + @@ -1088,7 +1101,7 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) * to native. Shadow only; the guest SXY, MAC and FLAG above are * already final. Skipped while the hooks record nothing (speculative * passes), where the shadow would be dropped anyway. */ - if (pgxp_preserve_projection() && pgxp_active()) { + if (!proj_override && pgxp_preserve_projection() && pgxp_active()) { int32_t ex16, ey16; if (pgxp_project_precise(mac1, mac2, mac3, shift, gte->IR1, gte->IR2, gte->SZ[3], gte_h_scaled(gte), gte->OFX, gte->OFY, @@ -1099,7 +1112,7 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) } } pgxp_gte_push_sxy(px16, py16, gte->SZ[3], (uint32_t)gte->SXY[2]); - if (pgxp_projection_tracking() && shift == 12 && !lm) { + if (!proj_override && pgxp_projection_tracking() && shift == 12 && !lm) { const double z = (double)(mac3 >> 12); const double h_proj = (double)gte_h_scaled(gte); double hx = (double)gte->MAC1 * h_proj; diff --git a/runtime/tests/test_gte_register_access.cpp b/runtime/tests/test_gte_register_access.cpp index 6671fc35..56de7645 100644 --- a/runtime/tests/test_gte_register_access.cpp +++ b/runtime/tests/test_gte_register_access.cpp @@ -1010,6 +1010,42 @@ int test_render_pose() { if ((int16_t)g.SXY[2] != 16 || (int16_t)(g.SXY[2] >> 16) != 1) return fail_value("authored focal ratio",0,0,0,16,g.SXY[2] & 65535); pose = {}; gte_render_pose_set(&pose); + /* Composition with [video] fov_scale (scaled guest H): an absolute override + * (projection_h_ref == 0) replaces the guest projection entirely, so the + * result does not depend on fov_scale; with projection_h_ref the focal + * lengths follow the fov-scaled guest H. Outside the override (pose reset) + * the guest path, including fov_scale, is untouched. */ + { + GTEState base; + base.RT[0][0] = base.RT[1][1] = base.RT[2][2] = 4096; + base.V0[0] = 80; base.V0[1] = 160; base.V0[2] = 800; + base.H = 400; base.OFX = 256 << 16; base.OFY = 120 << 16; + PSXModRenderView ov = {}; ov.struct_size = sizeof ov; + ov.rotation_q12[0] = ov.rotation_q12[4] = ov.rotation_q12[8] = 4096; + ov.projection = 1; ov.fx_q16 = 200 << 16; ov.fy_q16 = 100 << 16; + GTEState a = base, b = base, c = base, guest1 = base, guest2 = base; + gte_set_fov_scale(1, 1); + gte_render_pose_set(&ov); + PSXRecomp::GTE::gte_rtps_internal(&a, a.V0, true); + gte_set_fov_scale(1, 2); + PSXRecomp::GTE::gte_rtps_internal(&b, b.V0, true); + if (a.SXY[2] != b.SXY[2]) + return fail_value("absolute override independent of fov_scale",0,0,0,a.SXY[2],b.SXY[2]); + ov.projection_h_ref = 400; + gte_render_pose_set(&ov); /* scaled H = 200 -> half focal */ + PSXRecomp::GTE::gte_rtps_internal(&c, c.V0, true); + if ((int16_t)c.SXY[2] != 256 + 10 || (int16_t)(c.SXY[2] >> 16) != 120 + 10) + return fail_value("h_ref override follows fov-scaled H",0,0,0,266,c.SXY[2] & 65535); + PSXModRenderView none = {}; gte_render_pose_set(&none); + PSXRecomp::GTE::gte_rtps_internal(&guest2, guest2.V0, true); + gte_set_fov_scale(1, 1); + PSXRecomp::GTE::gte_rtps_internal(&guest1, guest1.V0, true); + if (guest1.SXY[2] == guest2.SXY[2]) + return fail_value("guest path keeps fov_scale after override reset",0,0,0,guest1.SXY[2],guest2.SXY[2]); + if (guest1.SXY[2] == a.SXY[2]) + return fail_value("override differs from guest projection (sanity)",0,0,0,guest1.SXY[2],a.SXY[2]); + } + gte_set_fov_scale(1, 1); std::puts("PASS: rigid rotation and asymmetric projection before division"); return 0; } diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 46f9958b..fe416b9b 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -544,7 +544,8 @@ int main(void) { CHECK(psx_mod_render_pass(&cpu, &pass, span_pass_fn, &runaway) == 0, "a runaway span is rolled back"); render_pass_get_stats(&st); - CHECK(st.watchdog == 2, "the watchdog cut the span off"); + /* Cumulative: the plain abort, the stereo right-eye abort, and this span. */ + CHECK(st.watchdog == 3, "the watchdog cut the span off"); CHECK(s_span_hi == 0 && s_span_lo == 0, "the abort closed the open span"); check_live(&live, "after a span watchdog abort"); CHECK(s_prec_begins > 0 && s_prec_open == 0,