gte: render-view projection override replaces guest projection explicitly; fix abort test count after merge

Override skips guest-formula PGXP shadows; composition with fov_scale tested and documented.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-10-04 11:03:01 -07:00
parent 7d9abcf5ff
commit dd0ce3d365
5 changed files with 73 additions and 6 deletions
+15 -1
View File
@@ -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,
+4 -1
View File
@@ -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];
+16 -3
View File
@@ -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;
@@ -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;
}
+2 -1
View File
@@ -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,