From 813829a40181ae759c4e1149e8bbd509df5db7e9 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 18:20:09 -0700 Subject: [PATCH 1/5] Keep GTE gameplay HUD groups edge anchored --- runtime/src/gpu.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 99f926f6..8ebfd79b 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -4343,7 +4343,16 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { ws_ui_prepass[out++] = ws_ui_prepass[i]; } ws_ui_prepass_count = out; - ws_auto_ui_dense = ws_ui_prepass_count >= 32u; + /* + * A high final-layer primitive count is a good "dense 2D menu" signal for + * titles without an explicit gameplay detector, where grouping everything + * around the centre avoids tearing text grids apart. For GTE-gated 3D + * titles, though, reaching this path already means a gameplay frame is + * being stretched. WipEout 3's race HUD is dense enough to trip the old + * threshold, which pinned every HUD group to the 4:3 centre. Keep edge + * groups edge-anchored in those frames so the HUD adapts to the wide view. + */ + ws_auto_ui_dense = ws_ui_prepass_count >= 32u && !ws_gte_game_mode_cfg; if (ws_ui_prepass_count == 0) return; WsUiGroupItem groups[WS_UI_PREPASS_MAX]; From 200b5bf0038329c29ca291657e85fb1f1aa85cb8 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 19:42:43 -0700 Subject: [PATCH 2/5] Add opt-in widescreen culling keep hooks --- recompiler/include/code_generator.h | 8 ++++ recompiler/src/code_generator.cpp | 29 +++++++++--- recompiler/src/config_loader.cpp | 12 +++++ recompiler/src/config_loader.h | 16 +++++++ recompiler/src/main_psx.cpp | 8 ++++ recompiler/tests/recompiler_patch_test.cpp | 19 ++++++++ runtime/include/cpu_state.h | 1 + runtime/include/gpu.h | 1 + runtime/src/gpu.c | 55 ++++++++++++++++------ runtime/src/gte.cpp | 23 ++++++--- runtime/src/main.cpp | 4 ++ 11 files changed, 149 insertions(+), 27 deletions(-) diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 10ba7902..803a2687 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -146,6 +146,14 @@ struct CodeGenConfig { // (INT32_MAX while revealed, vanilla at 4:3); empty by default. std::set ws_cull_xclip_load_sites; + // Exact `bltz MAC0, reject`-style NCLIP/backface rejects to suppress while + // widescreen reveals extra world. 4:3 keeps the original branch predicate. + std::set ws_cull_nclip_keep_sites; + + // Exact branch PCs whose reject target is suppressed while widescreen + // reveals extra world. 4:3 keeps the original branch predicate. + std::set ws_cull_branch_keep_sites; + // Exact, full-word-guarded comparison sites whose result is forced only // while widescreen reveals extra world. 4:3 evaluates the original compare. std::vector ws_cull_keep_sites; diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index b84dc492..543eb11f 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -737,6 +737,12 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad uint32_t opcode = (instr >> 26) & 0x3F; uint32_t rs = get_rs(instr); uint32_t rt = get_rt(instr); + auto keep_branch_if_wide = [&](std::string cond) { + if (!config_.ws_cull_branch_keep_sites.count(addr)) + return cond; + return fmt::format("psx_ws_x_margin() > 0 ? 0 : ({}) /* ws branch keep */", + cond); + }; // REGIMM branches. R3000A hardware decodes EVERY rt value here, not just // the four assembler mnemonics: the branch sense is rt bit 0 (0 = bltz, @@ -758,9 +764,14 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad (ws_cull_bltz_pcs_.count(addr) || config_.ws_cull_bltz_sites.count(addr))) return fmt::format("psx_ws_cull_bltz({}) /* ws cull (left edge) */", reg_name(rs)); - return fmt::format("(int32_t){} < 0", reg_name(rs)); + if (regimm_op == 0x00 && config_.ws_cull_nclip_keep_sites.count(addr)) + return fmt::format("psx_ws_x_margin() > 0 ? 0 : ((int32_t){} < 0) /* ws nclip keep */", + reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} < 0", reg_name(rs))); } else { // bgez family (incl. bgezal + undefined mirrors) - return fmt::format("(int32_t){} >= 0", reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} >= 0", reg_name(rs))); } } @@ -768,22 +779,26 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad switch (opcode) { case 0x04: // beq case 0x14: // beql - return fmt::format("{} == {}", reg_name(rs), reg_name(rt)); + return keep_branch_if_wide( + fmt::format("{} == {}", reg_name(rs), reg_name(rt))); case 0x05: // bne case 0x15: // bnel - return fmt::format("{} != {}", reg_name(rs), reg_name(rt)); + return keep_branch_if_wide( + fmt::format("{} != {}", reg_name(rs), reg_name(rt))); case 0x06: // blez case 0x16: // blezl - return fmt::format("(int32_t){} <= 0", reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} <= 0", reg_name(rs))); case 0x07: // bgtz case 0x17: // bgtzl - return fmt::format("(int32_t){} > 0", reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} > 0", reg_name(rs))); } - return "0 /* unknown branch condition: defaults to not-taken */"; + return keep_branch_if_wide("0 /* unknown branch condition: defaults to not-taken */"); } std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index cd38b9b9..2f220785 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -72,6 +72,8 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { h.words("cull_depth", c.ws_cull_depth_sites); h.words("cull_plane_nx", c.ws_cull_plane_nx_sites); h.words("cull_xclip_load", c.ws_cull_xclip_load_sites); + h.words("cull_nclip_keep", c.ws_cull_nclip_keep_sites); + h.words("cull_branch_keep", c.ws_cull_branch_keep_sites); h.words("cull_w_imms", c.ws_cull_w_imms); h.words("cull_h_imms", c.ws_cull_h_imms); h.words("backdrop_x", c.ws_backdrop_x_sites); @@ -1307,6 +1309,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { bool ws_auto_ui_squash = false; bool ws_full_2d = false; bool ws_gte_game_mode = false; + bool ws_precise_nclip = false; uint32_t ws_gameplay_state_addr = 0; std::vector ws_gameplay_state_values; bool ws_native_wide = true; @@ -1454,6 +1457,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { ws_full_2d = toml::find(ws, "full_2d"); if (ws.contains("gte_game_mode")) ws_gte_game_mode = toml::find(ws, "gte_game_mode"); + if (ws.contains("precise_nclip")) + ws_precise_nclip = toml::find(ws, "precise_nclip"); const bool has_gameplay_state_addr = ws.contains("gameplay_state_addr"); const bool has_gameplay_state_values = ws.contains("gameplay_state_values"); if (has_gameplay_state_addr != has_gameplay_state_values) @@ -1556,6 +1561,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { std::vector ws_cull_depth_sites; std::vector ws_cull_plane_nx_sites; std::vector ws_cull_xclip_load_sites; + std::vector ws_cull_nclip_keep_sites; + std::vector ws_cull_branch_keep_sites; std::vector ws_cull_keep_sites; std::vector ws_cull_angle_sites; WidescreenAspectConeConfig ws_aspect_cone; @@ -1589,6 +1596,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { load_sites("depth_sites", ws_cull_depth_sites); load_sites("plane_nx_sites", ws_cull_plane_nx_sites); load_sites("xclip_load_sites", ws_cull_xclip_load_sites); + load_sites("nclip_keep_sites", ws_cull_nclip_keep_sites); + load_sites("branch_keep_sites", ws_cull_branch_keep_sites); if (cull.contains("keep")) { std::set seen; for (const auto& item : toml::find(cull, "keep")) { @@ -2013,6 +2022,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_cull_depth_sites*/ ws_cull_depth_sites, /*ws_cull_plane_nx_sites*/ ws_cull_plane_nx_sites, /*ws_cull_xclip_load_sites*/ ws_cull_xclip_load_sites, + /*ws_cull_nclip_keep_sites*/ ws_cull_nclip_keep_sites, + /*ws_cull_branch_keep_sites*/ ws_cull_branch_keep_sites, /*ws_cull_keep_sites*/ ws_cull_keep_sites, /*ws_cull_angle_sites*/ ws_cull_angle_sites, /*ws_aspect_cone*/ ws_aspect_cone, @@ -2027,6 +2038,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_auto_backdrop_preload*/ ws_auto_backdrop_preload, /*ws_full_2d*/ ws_full_2d, /*ws_gte_game_mode*/ ws_gte_game_mode, + /*ws_precise_nclip*/ ws_precise_nclip, /*ws_gameplay_state_addr*/ ws_gameplay_state_addr, /*ws_gameplay_state_values*/ ws_gameplay_state_values, /*ws_native_wide*/ ws_native_wide, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 1829e024..2486ec19 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -812,6 +812,14 @@ struct GameConfig { // scissor clips the overflow and wrapped off-left coords pass); the // vanilla loaded value at 4:3. Empty by default; regen required. std::vector ws_cull_xclip_load_sites; + // Exact `bltz MAC0, reject`-style NCLIP/backface rejects that are forced + // not-taken only while widescreen reveals extra world. This is deliberately + // separate from bltz_sites, whose helper adjusts screen-X edge thresholds. + std::vector ws_cull_nclip_keep_sites; + // Exact branch PCs whose reject path is forced not-taken only while + // widescreen reveals extra world. Use only after screenshot-validated + // evidence that the target is a visibility reject. + std::vector ws_cull_branch_keep_sites; // Exact comparison sites whose result is forced only while widescreen // reveals extra world. Used for proven object/model participation gates // where maximal overdraw is preferable to range guessing. Each entry is @@ -903,6 +911,14 @@ struct GameConfig { // (native-wide engages); genuine full-2D screens (save/options) still // pillarbox 4:3. Runtime-only — no regen required. Off by default. bool ws_gte_game_mode = false; + + // [widescreen] precise_nclip — use the runtime's unsaturated GTE projection + // provenance for NCLIP/backface tests while classic adaptive widescreen is + // active. This is for 3D titles whose wide side geometry otherwise hits the + // PS1 SXY +/-1024 clamp and then disappears from game-side visibility tests. + // Runtime-only; off by default. + bool ws_precise_nclip = false; + // Optional authoritative game-state gate for titles whose menus also // render enough 3D geometry to fool gte_game_mode. When configured, // native-wide is active only while the guest word matches one listed diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index 23e1318a..5df7cb7c 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -209,6 +209,8 @@ int main(int argc, char** argv) { std::set ws_cull_depth; // [widescreen.cull] depth_sites std::set ws_cull_plane_nx; // [widescreen.cull] plane_nx_sites std::set ws_cull_xclip_load; // [widescreen.cull] xclip_load_sites + std::set ws_cull_nclip_keep; // [widescreen.cull] nclip_keep_sites + std::set ws_cull_branch_keep; // [widescreen.cull] branch_keep_sites std::vector ws_cull_keep; std::vector ws_cull_angle; PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; @@ -270,6 +272,8 @@ int main(int argc, char** argv) { ws_cull_depth.insert(cfg.ws_cull_depth_sites.begin(), cfg.ws_cull_depth_sites.end()); ws_cull_plane_nx.insert(cfg.ws_cull_plane_nx_sites.begin(), cfg.ws_cull_plane_nx_sites.end()); ws_cull_xclip_load.insert(cfg.ws_cull_xclip_load_sites.begin(), cfg.ws_cull_xclip_load_sites.end()); + ws_cull_nclip_keep.insert(cfg.ws_cull_nclip_keep_sites.begin(), cfg.ws_cull_nclip_keep_sites.end()); + ws_cull_branch_keep.insert(cfg.ws_cull_branch_keep_sites.begin(), cfg.ws_cull_branch_keep_sites.end()); ws_cull_keep = cfg.ws_cull_keep_sites; ws_cull_angle = cfg.ws_cull_angle_sites; ws_aspect_cone = cfg.ws_aspect_cone; @@ -363,6 +367,8 @@ int main(int argc, char** argv) { ws_cull_depth.insert(wscfg.ws_cull_depth_sites.begin(), wscfg.ws_cull_depth_sites.end()); ws_cull_plane_nx.insert(wscfg.ws_cull_plane_nx_sites.begin(), wscfg.ws_cull_plane_nx_sites.end()); ws_cull_xclip_load.insert(wscfg.ws_cull_xclip_load_sites.begin(), wscfg.ws_cull_xclip_load_sites.end()); + ws_cull_nclip_keep.insert(wscfg.ws_cull_nclip_keep_sites.begin(), wscfg.ws_cull_nclip_keep_sites.end()); + ws_cull_branch_keep.insert(wscfg.ws_cull_branch_keep_sites.begin(), wscfg.ws_cull_branch_keep_sites.end()); if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; if (ws_aspect_cone.sites.empty()) @@ -1225,6 +1231,8 @@ int main(int argc, char** argv) { codegen_config.ws_cull_depth_sites = ws_cull_depth; codegen_config.ws_cull_plane_nx_sites = ws_cull_plane_nx; codegen_config.ws_cull_xclip_load_sites = ws_cull_xclip_load; + codegen_config.ws_cull_nclip_keep_sites = ws_cull_nclip_keep; + codegen_config.ws_cull_branch_keep_sites = ws_cull_branch_keep; codegen_config.ws_cull_keep_sites = ws_cull_keep; codegen_config.ws_cull_angle_sites = ws_cull_angle; codegen_config.ws_aspect_cone = ws_aspect_cone; diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index dc1df97a..212f7c93 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -206,6 +206,15 @@ negsub_sites = ["0x80012340"] std::vector{0x80012340u}, "parser preserves negsub cull sites"); + const auto branch_keep = write_config(root, "branch-keep", R"toml( +[widescreen.cull] +branch_keep_sites = ["0x80012340"] +)toml"); + const auto branch_keep_config = PSXRecompV4::load_game_config(branch_keep); + check(branch_keep_config.ws_cull_branch_keep_sites == + std::vector{0x80012340u}, + "parser preserves branch keep sites"); + const auto vxrange = write_config(root, "vxrange", R"toml( [widescreen.cull] vxrange_sites = ["0x80012340"] @@ -680,6 +689,16 @@ void codegen_tests() { std::string::npos, "activation bias gains the same isolated resident-object lead"); + PSXRecomp::CodeGenConfig branch_keep_config; + branch_keep_config.ws_cull_branch_keep_sites.insert(0x80010000u); + const std::string branch_keep = generate_first_instruction( + 0x14400002u, {}, false, branch_keep_config); // bne v0,zero,+2 + check(branch_keep.find( + "psx_ws_x_margin() > 0 ? 0 : (cpu->gpr[2] != cpu->gpr[0])") != + std::string::npos && + branch_keep.find("ws branch keep") != std::string::npos, + "codegen emits guarded branch keep predicate"); + PSXRecomp::CodeGenConfig plane_nx_config; plane_nx_config.ws_cull_plane_nx_sites.insert(0x80010000u); const std::string plane_nx = generate_first_instruction( diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 6de04789..15cd78ff 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -205,6 +205,7 @@ extern void gte_precision_timeline_invalidate(void); extern void gte_precision_speculative_begin(void); extern void gte_precision_speculative_end(void); extern void gte_precision_store_word(uint32_t addr, uint8_t reg); +extern void gte_precision_tracking_set(int enabled); /* Sub-pixel vertex precision ([video] geometry_correction). Enables the side * cache that retains the 16.16 projection fraction the GTE discards when it * saturates SXY to integer screen pixels. Guest-visible GTE state is diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index c7f4ed56..7e06f072 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -316,6 +316,7 @@ uint32_t gpu_texture_correction_hits(void); * with no sprite-tag helper: gte.cpp notes every RTPS/RTPT projection; a frame * that projects enough vertices is stamped as gameplay. */ void gpu_ws_set_gte_game_mode(int on); +void gpu_ws_set_precise_nclip(int on); void psx_ws_note_gte_project(int nverts); /* Optional authoritative gameplay-state gate. When configured, it replaces * heuristic gameplay classification for native-wide presentation. */ diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 8ebfd79b..78866c24 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -86,6 +86,7 @@ static uint32_t ws_anchor_addr = 0; /* scratchpad addr of anchor SXY */ static int ws_hud_sprt = 0; /* edge-anchor untagged HUD SPRTs */ static int ws_auto_ui_squash; static int ws_auto_ui_dense; +static int ws_active(void); static uint64_t ws_auto_ui_candidate_count; static uint64_t ws_auto_ui_transform_count; #define WS_UI_PREPASS_MAX 2048u @@ -166,6 +167,7 @@ static int ws_gte_game_mode_cfg = 0; static uint32_t ws_gte_frame = (uint32_t)-1; static uint32_t ws_gte_count = 0; static uint32_t ws_last_gte_stamp = (uint32_t)-1000; +static int ws_precise_nclip_cfg = 0; #define WS_GAMEPLAY_STATE_VALUES_MAX 16 static uint32_t ws_gameplay_state_addr = 0; static uint32_t ws_gameplay_state_values[WS_GAMEPLAY_STATE_VALUES_MAX]; @@ -180,6 +182,8 @@ static int ws_gameplay_state_value_count = 0; * this many consecutive frames (save/options/memory-card) — reverts to 4:3. */ #define WS_GTE_GAME_MODE_HYSTERESIS 45u void gpu_ws_set_gte_game_mode(int on) { ws_gte_game_mode_cfg = on ? 1 : 0; } +void gpu_ws_set_precise_nclip(int on) { ws_precise_nclip_cfg = on ? 1 : 0; } +int gpu_ws_precise_nclip_enabled(void) { return ws_precise_nclip_cfg && ws_active(); } void gpu_ws_set_gameplay_state_gate(uint32_t addr, const uint32_t *values, int nvalues) { if (nvalues < 0) nvalues = 0; @@ -2977,9 +2981,11 @@ uint32_t gpu_texture_correction_hits(void) { * triangle match prevents screen-space HUD/sprites that happen to share one * coordinate from receiving world-geometry correction. The integer delta * folds in draw offsets and any widescreen adjustment already applied. */ -static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, +static void prepare_precise_triangle(int i0, int i1, int i2, + uint32_t w0, uint32_t w1, uint32_t w2, const int32_t vx[3], const int32_t vy[3]) { uint32_t words[3] = { w0, w1, w2 }; + int word_indices[3] = { i0, i1, i2 }; int32_t fx[3], fy[3]; const int geometry_enabled = gte_geometry_correction_enabled(); gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); @@ -2990,7 +2996,16 @@ static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, for (int i = 0; i < 3; i++) { int32_t raw_x, raw_y; parse_vertex(words[i], &raw_x, &raw_y); - if (!gte_geometry_correction_lookup(words[i], &fx[i], &fy[i])) { + int found = 0; + if (gp0_cmd_source_addr != 0xFFFFFFFFu) { + uint32_t addr = (gp0_cmd_source_addr + + (uint32_t)word_indices[i] * 4u) & 0x1FFFFCu; + found = gte_precision_load_word(addr, words[i], &fx[i], &fy[i], + NULL); + } + if (!found) + found = gte_geometry_correction_lookup(words[i], &fx[i], &fy[i]); + if (!found) { gr_set_precise_triangle(0, 0,0, 0,0, 0,0); return; } @@ -3209,7 +3224,8 @@ static void gp0_exec_mono_tri(void) { } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], + prepare_precise_triangle(1, 2, 3, + gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], vx, vy); gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); } @@ -3275,14 +3291,16 @@ static void gp0_exec_mono_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], + prepare_precise_triangle(1, 2, 3, + gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], tx, ty); gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); } if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[3], gp0_cmd_buf[2], + prepare_precise_triangle(3, 2, 4, + gp0_cmd_buf[3], gp0_cmd_buf[2], gp0_cmd_buf[4], tx, ty); gr_draw_flat_triangle(vx[2], vy[2], vx[1], vy[1], vx[3], vy[3], color); } @@ -3306,7 +3324,8 @@ static void gp0_exec_shaded_tri(void) { } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], + prepare_precise_triangle(1, 3, 5, + gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], vx, vy); gr_draw_gouraud_triangle(vx[0], vy[0], c[0], vx[1], vy[1], c[1], @@ -3352,7 +3371,8 @@ static void gp0_exec_shaded_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], + prepare_precise_triangle(1, 3, 5, + gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], tx, ty); gr_draw_gouraud_triangle(vx[0], vy[0], c[0], vx[1], vy[1], c[1], @@ -3361,7 +3381,8 @@ static void gp0_exec_shaded_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], + prepare_precise_triangle(5, 3, 7, + gp0_cmd_buf[5], gp0_cmd_buf[3], gp0_cmd_buf[7], tx, ty); gr_draw_gouraud_triangle(vx[2], vy[2], c[2], vx[1], vy[1], c[1], @@ -3433,7 +3454,8 @@ static void gp0_exec_textured_tri(void) { if (draw_area_out_bbox(vx, vy, 3)) return; setup_textured_draw(color24, semi_trans, raw_texture); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], + prepare_precise_triangle(1, 3, 5, + gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], vx, vy); prepare_texture_triangle(1, 3, 5); gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], @@ -3515,7 +3537,8 @@ static void gp0_exec_textured_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], + prepare_precise_triangle(1, 3, 5, + gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], tx, ty); prepare_texture_triangle(1, 3, 5); gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], @@ -3526,7 +3549,8 @@ static void gp0_exec_textured_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], + prepare_precise_triangle(5, 3, 7, + gp0_cmd_buf[5], gp0_cmd_buf[3], gp0_cmd_buf[7], tx, ty); prepare_texture_triangle(5, 3, 7); gr_draw_textured_triangle(vx[2], vy[2], u[2], v[2], @@ -3569,7 +3593,8 @@ static void gp0_exec_shaded_textured_tri(void) { if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], + prepare_precise_triangle(1, 4, 7, + gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], vx, vy); prepare_texture_triangle(1, 4, 7); gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], @@ -3620,7 +3645,8 @@ static void gp0_exec_shaded_textured_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], + prepare_precise_triangle(1, 4, 7, + gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], tx, ty); prepare_texture_triangle(1, 4, 7); gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], @@ -3631,7 +3657,8 @@ static void gp0_exec_shaded_textured_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[7], gp0_cmd_buf[4], + prepare_precise_triangle(7, 4, 10, + gp0_cmd_buf[7], gp0_cmd_buf[4], gp0_cmd_buf[10], tx, ty); prepare_texture_triangle(7, 4, 10); gr_draw_shaded_textured_triangle(vx[2], vy[2], u[2], v[2], c[2], diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 7c8ffd94..db046724 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -7,6 +7,7 @@ #include extern "C" uint32_t psx_read_word(uint32_t addr); +extern "C" int gpu_ws_precise_nclip_enabled(void); namespace PSXRecomp { namespace GTE { @@ -982,12 +983,22 @@ void gte_rtpt(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- void gte_nclip(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - int16_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); - int16_t sy0 = static_cast(gte->SXY[0] >> 16); - int16_t sx1 = static_cast(gte->SXY[1] & 0xFFFF); - int16_t sy1 = static_cast(gte->SXY[1] >> 16); - int16_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); - int16_t sy2 = static_cast(gte->SXY[2] >> 16); + int32_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); + int32_t sy0 = static_cast(gte->SXY[0] >> 16); + int32_t sx1 = static_cast(gte->SXY[1] & 0xFFFF); + int32_t sy1 = static_cast(gte->SXY[1] >> 16); + int32_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); + int32_t sy2 = static_cast(gte->SXY[2] >> 16); + if (gpu_ws_precise_nclip_enabled() && + s_precise_sxy[0].valid && s_precise_sxy[1].valid && + s_precise_sxy[2].valid) { + sx0 = s_precise_sxy[0].x16 >> 16; + sy0 = s_precise_sxy[0].y16 >> 16; + sx1 = s_precise_sxy[1].x16 >> 16; + sy1 = s_precise_sxy[1].y16 >> 16; + sx2 = s_precise_sxy[2].x16 >> 16; + sy2 = s_precise_sxy[2].y16 >> 16; + } int64_t mac0 = (int64_t)sx0 * (sy1 - sy2) + (int64_t)sx1 * (sy2 - sy0) + (int64_t)sx2 * (sy0 - sy1); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e5d50bde..65de1371 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1387,6 +1387,7 @@ static bool g_ws_hud_sprt = false; /* Runtime-only transition cleanup; kept out of gpu.h because generated game * units include that ABI header and do not need this frontend-only setter. */ extern "C" void gpu_ws_set_clear_reveal(int on); +extern "C" void gpu_ws_set_precise_nclip(int on); extern "C" void gpu_ws_set_nw_textured_edges(int on, int scale_pct); extern "C" void gpu_ws_set_signed_x_bound_sites(const uint32_t*, const uint32_t*, int); /* Widescreen engages at game entry (fntrace_is_game_started): the BIOS boot @@ -10345,6 +10346,7 @@ int main(int argc, char** argv) { gc.ws_bg2d_packet_cap); /* [widescreen] gte_game_mode — 3D-title gameplay detector (Ape). */ gpu_ws_set_gte_game_mode(gc.ws_gte_game_mode ? 1 : 0); + gpu_ws_set_precise_nclip(gc.ws_precise_nclip ? 1 : 0); gpu_ws_set_gameplay_state_gate( gc.ws_gameplay_state_addr, gc.ws_gameplay_state_values.data(), @@ -12048,6 +12050,8 @@ session_reboot: * the draw path is the faithful integer one, unchanged. */ gte_geometry_correction_set(g_video_geometry_correction); gpu_texture_correction_set(g_video_perspective_texturing); + gte_precision_tracking_set(g_video_geometry_correction || + g_video_perspective_texturing); if (g_video_geometry_correction || g_video_perspective_texturing) { std::fprintf(stdout, "psxrecomp: geometry correction %s, perspective texturing %s%s\n", From 7993f412c0958a8b39b44e54ebf2a3e0ee8ab7e5 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 20:09:37 -0700 Subject: [PATCH 3/5] Add opt-in controller device defaults --- recompiler/src/config_loader.cpp | 25 ++++++++++++++++------ recompiler/src/config_loader.h | 9 ++++++++ recompiler/tests/recompiler_patch_test.cpp | 16 ++++++++++++++ runtime/src/main.cpp | 4 ++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 2f220785..c7862e45 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -329,10 +329,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& } } } - if (!cfg.contains("runtime")) return rt; - const toml::value& runtime = toml::find(cfg, "runtime"); - if (runtime.contains("language")) // [runtime].language convenience alias - rt.language = toml::find(runtime, "language"); + if (cfg.contains("runtime")) { + const toml::value& runtime = toml::find(cfg, "runtime"); + if (runtime.contains("language")) // [runtime].language convenience alias + rt.language = toml::find(runtime, "language"); if (runtime.contains("debug_port")) { const auto port = toml::find(runtime, "debug_port"); @@ -504,9 +504,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (runtime.contains("overlay_backend")) { rt.overlay_backend = toml::find(runtime, "overlay_backend"); } - if (runtime.contains("overlay_native_block")) { - for (const auto& a : toml::find>(runtime, "overlay_native_block")) { - rt.overlay_native_block.push_back(parse_hex(a, "runtime.overlay_native_block")); + if (runtime.contains("overlay_native_block")) { + for (const auto& a : toml::find>(runtime, "overlay_native_block")) { + rt.overlay_native_block.push_back(parse_hex(a, "runtime.overlay_native_block")); + } } } @@ -704,6 +705,16 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& toml::find(ct, "p2_mode"), PAD_MODE_ANALOG); rt.has_default_mode = true; } + if (ct.contains("p1_device")) { + rt.default_p1_device = toml::find(ct, "p1_device"); + if (!rt.default_p1_device.empty()) + rt.has_default_p1_device = true; + } + if (ct.contains("p2_device")) { + rt.default_p2_device = toml::find(ct, "p2_device"); + if (!rt.default_p2_device.empty()) + rt.has_default_p2_device = true; + } if (ct.contains("lock_mode")) { rt.controller_lock_mode = toml::find(ct, "lock_mode"); } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 2486ec19..871b9d0d 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -497,6 +497,15 @@ struct RuntimeConfig { int default_p1_mode = PAD_MODE_ANALOG; int default_p2_mode = PAD_MODE_ANALOG; + // p1_device / p2_device: optional per-game default input sources for + // fresh settings and launcher-less boots. Same vocabulary as settings.toml: + // "none", "keyboard", "auto"/"gamepad"/"controller", or an SDL GUID. + // Per-install settings.toml still overrides these defaults. + bool has_default_p1_device = false; + bool has_default_p2_device = false; + std::string default_p1_device; + std::string default_p2_device; + // lock_mode: when true the launcher HIDES the whole pad-mode selector // (Hybrid | Analog | D-Pad) and forces every port to default_p1_mode. For a // game that supports exactly one pad type — e.g. Tomba 2, whose driver only diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index 212f7c93..43cebaf1 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -142,6 +142,22 @@ buffer_ms = 60 check(audio_config.runtime.audio_buffer_ms == 60, "parser preserves per-game audio buffer target"); + const auto controller_defaults = write_config(root, "controller-defaults", R"toml( +[controller] +p1_device = "keyboard" +p2_device = "gamepad" +p2_mode = "digital" +)toml"); + const auto controller_defaults_config = + PSXRecompV4::load_game_config(controller_defaults); + check(controller_defaults_config.runtime.has_default_p1_device && + controller_defaults_config.runtime.default_p1_device == "keyboard" && + controller_defaults_config.runtime.has_default_p2_device && + controller_defaults_config.runtime.default_p2_device == "gamepad" && + controller_defaults_config.runtime.default_p2_mode == + PSXRecompV4::PAD_MODE_DIGITAL, + "parser preserves per-game controller device defaults"); + const auto bad_audio_buffer = write_config(root, "bad-audio-buffer", R"toml( [runtime] diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 65de1371..3a8d2de4 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -10504,6 +10504,10 @@ int main(int argc, char** argv) { if (i >= 2) player_mode[i] = gc.runtime.default_p1_mode; } } + if (gc.runtime.has_default_p1_device) + player_device[0] = gc.runtime.default_p1_device; + if (PSX_MAX_PLAYERS >= 2 && gc.runtime.has_default_p2_device) + player_device[1] = gc.runtime.default_p2_device; for (int i = 0; i < PSX_MAX_PLAYERS; ++i) ctrl_locked_mode[i] = player_mode[i]; ctrl_lock_mode = gc.runtime.controller_lock_mode; From b2eeb0f866b1a2b26a570811dfbe0adefa8ab922 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 20:51:23 -0700 Subject: [PATCH 4/5] Add opt-in netplay local viewport extraction --- recompiler/src/config_loader.cpp | 35 ++++++++ recompiler/src/config_loader.h | 9 ++ recompiler/tests/recompiler_patch_test.cpp | 14 ++++ runtime/include/gpu.h | 5 ++ runtime/src/debug_server.c | 4 + runtime/src/gpu.c | 95 +++++++++++++++++++++ runtime/src/main.cpp | 97 ++++++++++++++++++++-- 7 files changed, 253 insertions(+), 6 deletions(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index c7862e45..3af4a0c4 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1193,6 +1193,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { bool has_netplay_required_leadout = false; uint32_t netplay_required_leadout_lba = 0; std::string netplay_required_disc_fp; + std::string netplay_local_viewport; + std::string netplay_local_viewport_aspect; if (cfg.contains("netplay")) { const toml::value& np = toml::find(cfg, "netplay"); if (np.contains("require_cue")) @@ -1209,6 +1211,37 @@ GameConfig load_game_config(const fs::path& config_path_in) { for (char& c : netplay_required_disc_fp) c = (char)std::tolower((unsigned char)c); } + if (np.contains("local_viewport")) { + netplay_local_viewport = toml::find(np, "local_viewport"); + for (char& c : netplay_local_viewport) + c = (char)std::tolower((unsigned char)c); + if (!netplay_local_viewport.empty() && + netplay_local_viewport != "vertical_split") { + throw std::runtime_error(fmt::format( + "[netplay] local_viewport must be \"vertical_split\", got '{}'", + netplay_local_viewport)); + } + } + if (np.contains("local_viewport_aspect")) { + netplay_local_viewport_aspect = + toml::find(np, "local_viewport_aspect"); + for (char& c : netplay_local_viewport_aspect) + c = (char)std::tolower((unsigned char)c); + if (!netplay_local_viewport_aspect.empty() && + netplay_local_viewport_aspect != "16:9" && + netplay_local_viewport_aspect != "21:9" && + netplay_local_viewport_aspect != "adaptive") { + throw std::runtime_error(fmt::format( + "[netplay] local_viewport_aspect must be \"16:9\", " + "\"21:9\", or \"adaptive\", got '{}'", + netplay_local_viewport_aspect)); + } + if (!netplay_local_viewport_aspect.empty() && + netplay_local_viewport.empty()) { + throw std::runtime_error( + "[netplay] local_viewport_aspect requires local_viewport"); + } + } } // [recompiler] @@ -1995,6 +2028,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*has_netplay_required_leadout*/ has_netplay_required_leadout, /*netplay_required_leadout_lba*/ netplay_required_leadout_lba, /*netplay_required_disc_fp*/ netplay_required_disc_fp, + /*netplay_local_viewport*/ netplay_local_viewport, + /*netplay_local_viewport_aspect*/ netplay_local_viewport_aspect, /*seeds_path*/ seeds_path, /*bios_thunks_path*/ bios_thunks_path, /*bios_config_path*/ bios_config_path, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 871b9d0d..d15fb6b5 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -685,6 +685,15 @@ struct GameConfig { bool has_netplay_required_leadout = false; uint32_t netplay_required_leadout_lba = 0; std::string netplay_required_disc_fp; // lowercase hex SHA-256 + // local_viewport = "vertical_split": while real netplay is active, crop + // presentation to this peer's left/right split-screen half. This is a + // presentation-only helper for titles that still render native split-screen + // in netplay; unset keeps every peer seeing the full framebuffer. + std::string netplay_local_viewport; + // Optional display aspect to use with local_viewport. Accepted values: + // "16:9", "21:9", or "adaptive" (initial 16:9, live-window capped 21:9). + // Unset keeps netplay at the title's normal mod-cleared aspect. + std::string netplay_local_viewport_aspect; // [recompiler] block std::filesystem::path seeds_path; // absolute path to seeds (text or json) diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index 43cebaf1..04892dea 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -158,6 +158,20 @@ p2_mode = "digital" PSXRecompV4::PAD_MODE_DIGITAL, "parser preserves per-game controller device defaults"); + const auto netplay_viewport = write_config(root, "netplay-viewport", R"toml( +[runtime] + +[netplay] +local_viewport = "vertical_split" +local_viewport_aspect = "adaptive" +)toml"); + const auto netplay_viewport_config = + PSXRecompV4::load_game_config(netplay_viewport); + check(netplay_viewport_config.netplay_local_viewport == "vertical_split", + "parser preserves netplay local viewport mode"); + check(netplay_viewport_config.netplay_local_viewport_aspect == "adaptive", + "parser preserves netplay local viewport aspect"); + const auto bad_audio_buffer = write_config(root, "bad-audio-buffer", R"toml( [runtime] diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 7e06f072..2385b3b4 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -75,6 +75,11 @@ typedef struct { int32_t offset_x, offset_y; } GpuDrawArea; void gpu_get_draw_area(GpuDrawArea* out); +/* Non-zero when the frame that just reached vblank used two side-by-side draw + * areas. Presentation-only helpers use this to expand a local split-screen + * viewport without changing the underlying framebuffer or netplay hashes. */ +int gpu_last_frame_vertical_split_screen(void); +void gpu_vertical_split_debug(int *active, int *left_age, int *right_age); uint16_t gpu_vram_peek(int x, int y); /* Shaded quad vertex capture (Phase 4.5 debug). */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 61cd2464..bf15c4e9 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5221,6 +5221,8 @@ static void handle_gpu_state(int id, const char *json) gpu_get_draw_area(&da); uint64_t nop, fill, draw, env, copy; gpu_get_gp0_stats(&nop, &fill, &draw, &env, ©); + int split_active = 0, split_left_age = 0, split_right_age = 0; + gpu_vertical_split_debug(&split_active, &split_left_age, &split_right_age); GpuWsDebug ws; gpu_ws_get_debug(&ws); send_fmt("{\"id\":%d,\"ok\":true," @@ -5235,6 +5237,7 @@ static void handle_gpu_state(int id, const char *json) "\"gp0_nop\":%llu,\"gp0_fill\":%llu,\"gp0_draw\":%llu,\"gp0_env\":%llu,\"gp0_copy\":%llu," "\"draw_area\":[%u,%u,%u,%u]," "\"draw_offset\":[%d,%d]," + "\"vertical_split\":{\"active\":%d,\"left_age\":%d,\"right_age\":%d}," "\"ws\":{\"configured\":%d,\"active\":%d,\"game_mode\":%d," "\"present_native_43\":%d,\"x_margin\":%d," "\"activation_margin\":%d,\"squash\":[%d,%d]," @@ -5263,6 +5266,7 @@ static void handle_gpu_state(int id, const char *json) (unsigned long long)copy, da.left, da.top, da.right, da.bottom, da.offset_x, da.offset_y, + split_active, split_left_age, split_right_age, ws.configured, ws.active, ws.game_mode, ws.present_native_43, ws.x_margin, ws.activation_margin, ws.xnum, ws.xden, diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 78866c24..2584bef9 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2169,6 +2169,20 @@ static uint32_t texture_disable; /* bit 15 */ static uint32_t draw_area_left, draw_area_top; static uint32_t draw_area_right, draw_area_bottom; +typedef struct GpuVerticalSplitTrace { + uint8_t left_seen; + uint8_t right_seen; + uint16_t display_w; + uint16_t display_h; +} GpuVerticalSplitTrace; + +static GpuVerticalSplitTrace split_trace_this; +static GpuVerticalSplitTrace split_trace_last; +static uint8_t split_recent_left_age = 255; +static uint8_t split_recent_right_age = 255; +static uint16_t split_recent_display_w = 0; +static uint16_t split_recent_display_h = 0; + /* Draw offset (set by GP0(E5h)) */ static int32_t draw_offset_x, draw_offset_y; /* Instrumentation: per-vblank range/count of GP0(E5) draw-offset-Y sets. If a @@ -2235,6 +2249,59 @@ static int ws_is_fb_base(uint32_t bx) { for (int i = 0; i < ws_fb_n; i++) if (ws_fb_base[i] == bx) return 1; return 0; } + +static void split_trace_reset(GpuVerticalSplitTrace *trace) { + memset(trace, 0, sizeof(*trace)); +} + +static void split_trace_note_draw_area(void) { + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width < 256 || di.height < 128) + return; + if ((di.width & 1u) != 0) + return; + if (di.display_x + di.width > 1024u || di.display_y + di.height > 512u) + return; + if (draw_area_right <= draw_area_left || draw_area_bottom <= draw_area_top) + return; + + const int disp_l = (int)di.display_x; + const int disp_r = (int)(di.display_x + di.width - 1u); + const int mid = disp_l + (int)(di.width / 2u); + const int tol = (int)di.width / 32 > 8 ? (int)di.width / 32 : 8; + + const int area_l = (int)draw_area_left; + const int area_r = (int)draw_area_right; + + if (split_trace_this.display_w != 0 && + (split_trace_this.display_w != (uint16_t)di.width || + split_trace_this.display_h != (uint16_t)di.height)) { + split_trace_reset(&split_trace_this); + } + split_trace_this.display_w = (uint16_t)di.width; + split_trace_this.display_h = (uint16_t)di.height; + + if (area_l <= disp_l + tol && + area_r >= mid - 1 - tol && area_r <= mid - 1 + tol) { + split_trace_this.left_seen = 1; + } + if (area_l >= mid - tol && area_l <= mid + tol && + area_r >= disp_r - tol) { + split_trace_this.right_seen = 1; + } +} + +int gpu_last_frame_vertical_split_screen(void) { + return split_recent_left_age <= 8 && split_recent_right_age <= 8; +} + +void gpu_vertical_split_debug(int *active, int *left_age, int *right_age) { + if (active) *active = gpu_last_frame_vertical_split_screen(); + if (left_age) *left_age = split_recent_left_age; + if (right_age) *right_age = split_recent_right_age; +} + /* Point the renderer's wide mirror at the current back buffer (draw_area_left) * when native-wide is active and that buffer is a known display buffer; else * disable mirroring for this draw. Called when the draw env changes. */ @@ -2366,6 +2433,12 @@ static void gpu_reset_state(int clear_vram) { draw_area_top = 0; draw_area_right = 0; draw_area_bottom = 0; + split_trace_reset(&split_trace_this); + split_trace_reset(&split_trace_last); + split_recent_left_age = 255; + split_recent_right_age = 255; + split_recent_display_w = 0; + split_recent_display_h = 0; draw_offset_x = 0; draw_offset_y = 0; texture_window_value = 0; @@ -2658,6 +2731,26 @@ void gpu_vblank_tick(void) { g_doff_cnt_last = g_doff_cnt_this; } g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; + split_trace_last = split_trace_this; + if (split_trace_this.display_w != 0 && + (split_recent_display_w != split_trace_this.display_w || + split_recent_display_h != split_trace_this.display_h)) { + split_recent_left_age = 255; + split_recent_right_age = 255; + split_recent_display_w = split_trace_this.display_w; + split_recent_display_h = split_trace_this.display_h; + } + if (split_trace_this.left_seen) { + split_recent_left_age = 0; + } else if (split_recent_left_age < 255) { + split_recent_left_age++; + } + if (split_trace_this.right_seen) { + split_recent_right_age = 0; + } else if (split_recent_right_age < 255) { + split_recent_right_age++; + } + split_trace_reset(&split_trace_this); gpustat_poll_count = 0; /* Trusted package-selected plugins run on guest VBlank, independent of * host presentation, pacing, turbo, or skipped frames. */ @@ -3934,6 +4027,7 @@ static void gp0_exec_draw_area_tl(void) { uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; draw_area_left = param & 0x3FF; draw_area_top = (param >> 10) & 0x3FF; + split_trace_note_draw_area(); gr_set_draw_area((int)draw_area_left, (int)draw_area_top, (int)draw_area_right, (int)draw_area_bottom); ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ @@ -3946,6 +4040,7 @@ static void gp0_exec_draw_area_br(void) { uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; draw_area_right = param & 0x3FF; draw_area_bottom = (param >> 10) & 0x3FF; + split_trace_note_draw_area(); gr_set_draw_area((int)draw_area_left, (int)draw_area_top, (int)draw_area_right, (int)draw_area_bottom); ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 3a8d2de4..306606cb 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2374,6 +2374,36 @@ static void shutdown_runtime(void); * there instead of killing the process. */ static int g_netplay_from_lobby = 0; static int g_netplay_vsync_forced_off = 0; +/* game.toml [netplay] local_viewport = "vertical_split": during real netplay, + * present only this peer's native split-screen half and stretch it to the + * window. Presentation-only; the guest still renders the original framebuffer. */ +static int g_netplay_local_viewport = 0; /* 0 off, 1 vertical split */ +/* Optional aspect for netplay local-view extraction. Mirrors trusted mod aspect + * activation, but remains game.toml opt-in so normal netplay stays vanilla. */ +static int g_netplay_local_viewport_aspect = 0; /* 0 off, 1 16:9, 2 21:9, 3 adaptive */ + +static void apply_netplay_local_viewport_aspect(bool netplay_enabled) { + if (!netplay_enabled || + g_netplay_local_viewport != 1 || + g_netplay_local_viewport_aspect == 0) { + return; + } + + switch (g_netplay_local_viewport_aspect) { + case 1: + (void)psx_mod_set_fixed_display_aspect(16u, 9u); + break; + case 2: + (void)psx_mod_set_fixed_display_aspect(21u, 9u); + break; + case 3: + (void)psx_mod_set_fixed_display_aspect(16u, 9u); + (void)psx_mod_set_adaptive_display_aspect(21u, 9u); + break; + default: + break; + } +} /* Host-only: lockstep already couples peers. Driver vsync on top of the * wall-clock pacer double-blocks the vblank callback (present is before the @@ -4604,6 +4634,36 @@ static void netplay_note_present(void) { s_present_last_ms = now ? now : 1ull; } +static int netplay_local_viewport_slot(void) { + if (g_netplay_local_viewport != 1 || !psx_netplay_active()) + return -1; + if (!gpu_last_frame_vertical_split_screen()) + return -1; + int slot = psx_netplay_local_slot(); + return (slot == 0 || slot == 1) ? slot : -1; +} + +static int crop_present_to_netplay_local_viewport(uint32_t* pixels, + int* width, + int height) { + if (!pixels || !width || *width < 2 || height <= 0) + return 0; + const int slot = netplay_local_viewport_slot(); + if (slot < 0) + return 0; + + const int src_w = *width; + const int crop_w = src_w / 2; + const int crop_x = slot == 0 ? 0 : (src_w - crop_w); + for (int y = 0; y < height; ++y) { + uint32_t* dst = pixels + (size_t)y * (size_t)crop_w; + uint32_t* src = pixels + (size_t)y * (size_t)src_w + crop_x; + memmove(dst, src, (size_t)crop_w * sizeof(uint32_t)); + } + *width = crop_w; + return 1; +} + static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { unsigned n = s_present_gaps_n; unsigned idx; @@ -6285,6 +6345,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (g_gl_active) gl_renderer_set_interpolation_suspended( fmv_frame || mdec_recently_active(2)); + const bool local_viewport_crop = netplay_local_viewport_slot() >= 0; + bool local_viewport_crop_applied = false; /* Canonical present width. Native-wide does NOT widen the canonical read * (that bled across adjacent framebuffers); it composites into a separate @@ -6295,7 +6357,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * wide compositor, present the wider surface (canonical width + EXTRA) * from the displayed buffer's surface. FMV/menu frames stay 4:3. */ bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && - ws_native_wide_active() && gr_wide_supported()); + ws_native_wide_active() && gr_wide_supported() && + !local_viewport_crop); if (wide_present) present_w = w + (uint32_t)ws_nw_extra(); /* Native-wide invariant: canonical (320-wide) content is NEVER @@ -6321,7 +6384,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * do NOT flush_cpu_uploads (MDEC already wrote the CPU mirror; forcing * FBO uploads every frame cut MotK intro from ~50 to ~30 FPS). */ #ifndef PSX_SDL_NO_RENDER - if (g_gl_active && g_gl_fbo_present && !di.depth24) { + if (g_gl_active && g_gl_fbo_present && !di.depth24 && + !local_viewport_crop) { if (wide_present) { /* GPU-direct native-wide present: blit the displayed buffer's * wide FBO straight to the window (GPU-side, like the canonical @@ -6348,7 +6412,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * VRAM image (deterministic blit, no readback), mirroring the GL path; * 24-bit (FMV) frames go through the CPU present (Phase 3). The Vulkan * window has no SDL_Renderer, so we must never fall through below. */ - if (g_vk_active) { + if (g_vk_active && !local_viewport_crop) { if (di.depth24) { /* 24-bit (FMV): packed RGB lives in the CPU mirror — do NOT * sync_cpu (FBO readback clobbers RGB888). Batch per-scanline @@ -6444,9 +6508,18 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h, di.display_x); + int present_px_w = (int)present_w * active_scale; + int present_px_h = (int)h * active_scale; + if (crop_present_to_netplay_local_viewport(sdl_pixel_buf, + &present_px_w, + present_px_h)) { + pin_43 = false; + local_viewport_crop_applied = true; + } + smooth_60_present(sdl_pixel_buf, - present_w * (uint32_t)active_scale, - h * (uint32_t)active_scale, + (uint32_t)present_px_w, + (uint32_t)present_px_h, !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); /* Frame blending (CRT-persistence masker for 30fps double-buffered @@ -6471,7 +6544,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { !g_smooth_60fps.load(std::memory_order_acquire)) { static uint32_t prev_buf[640 * 512]; static uint32_t prev_px = 0; - const uint32_t npx = present_w * h; + const uint32_t npx = local_viewport_crop_applied + ? (uint32_t)(present_px_w * present_px_h) + : present_w * h; if (npx <= (uint32_t)(640 * 512)) { if (prev_px == npx) { for (uint32_t i = 0; i < npx; i++) { @@ -6499,6 +6574,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { #ifndef PSX_SDL_NO_RENDER int src_w = (int)present_w * active_scale; int src_h = (int)h * active_scale; + if (netplay_local_viewport_slot() >= 0 && src_w >= 2) + src_w /= 2; if (g_gl_active) { /* OpenGL present: upload the active display rect and draw a full-screen * quad. SDL_GL_SwapWindow handles vsync; the wall-clock pacer above @@ -10253,6 +10330,12 @@ int main(int argc, char** argv) { g_netplay_disc_expect.required_leadout_lba = gc.netplay_required_leadout_lba; g_netplay_disc_expect.required_disc_fp = gc.netplay_required_disc_fp; + g_netplay_local_viewport = + (gc.netplay_local_viewport == "vertical_split") ? 1 : 0; + g_netplay_local_viewport_aspect = + (gc.netplay_local_viewport_aspect == "16:9") ? 1 : + (gc.netplay_local_viewport_aspect == "21:9") ? 2 : + (gc.netplay_local_viewport_aspect == "adaptive") ? 3 : 0; if (!gc.discs.empty()) resolved_disc = gc.discs.front(); if (gc.runtime.has_memcard_dir) memcard_dir = gc.runtime.memcard_dir; if (gc.runtime.has_window_title) window_title = gc.runtime.window_title; @@ -11838,6 +11921,7 @@ int main(int argc, char** argv) { g_turbo_loads_enabled = 0; g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); + apply_netplay_local_viewport_aspect(net_cfg.enabled); if (g_mod_controller_mode_override[0] >= 0) player_mode[0] = g_mod_controller_mode_override[0]; if (g_mod_controller_mode_override[1] >= 0) @@ -13472,6 +13556,7 @@ soft_return_lobby: return 1; } } + apply_netplay_local_viewport_aspect(net_cfg.enabled); std::printf("psxrecomp: rematch from lobby (netplay=%d)\n", net_cfg.enabled ? 1 : 0); std::fflush(stdout); From c18d820fb828772a490ccc8a0b67b4c78320476e Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 15 Aug 2026 21:02:38 -0700 Subject: [PATCH 5/5] Correct split viewport projection aspect --- runtime/src/main.cpp | 118 ++++++++++++++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 29 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 306606cb..709a5504 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1185,6 +1185,13 @@ static int g_video_aspect_den = 3; static bool g_ws_adaptive_view = false; static int g_ws_adaptive_max_num = 16; static int g_ws_adaptive_max_den = 9; +/* game.toml [netplay] local_viewport = "vertical_split": during real netplay, + * present only this peer's native split-screen half and stretch it to the + * window. Presentation-only; the guest still renders the original framebuffer. */ +static int g_netplay_local_viewport = 0; /* 0 off, 1 vertical split */ +/* Optional aspect for netplay local-view extraction. Mirrors trusted mod aspect + * activation, but remains game.toml opt-in so normal netplay stays vanilla. */ +static int g_netplay_local_viewport_aspect = 0; /* 0 off, 1 16:9, 2 21:9, 3 adaptive */ extern "C" int psx_mod_set_fixed_display_aspect( uint32_t numerator, uint32_t denominator) { @@ -1423,6 +1430,81 @@ static int aspect_gcd(int a, int b) { return a > 0 ? a : 1; } +static int64_t aspect_gcd64(int64_t a, int64_t b) { + if (a < 0) a = -a; + if (b < 0) b = -b; + while (b) { int64_t t = a % b; a = b; b = t; } + return a > 0 ? a : 1; +} + +static void netplay_local_viewport_projection_aspect( + int present_num, int present_den, int* proj_num, int* proj_den) { + if (!proj_num || !proj_den) return; + *proj_num = present_num; + *proj_den = present_den; + + if (g_netplay_local_viewport != 1 || + !psx_netplay_active() || + !gpu_last_frame_vertical_split_screen() || + present_num * 3 <= present_den * 4) { + return; + } + + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width < 2 || di.height == 0) + return; + + /* The normal widescreen squash assumes a 4:3 source. A split-screen peer + * source is only half the display width, so derive the equivalent aspect + * that produces source_aspect / target_aspect as the X squash factor: + * + * source = (display_w / 2) / display_h + * effective = (4:3) * target / source + */ + int64_t n = (int64_t)present_num * 8 * (int64_t)di.height; + int64_t d = (int64_t)present_den * 3 * (int64_t)di.width; + if (n <= 0 || d <= 0) return; + int64_t gcd = aspect_gcd64(n, d); + n /= gcd; + d /= gcd; + if (n <= 0 || d <= 0 || n > INT32_MAX || d > INT32_MAX) + return; + + *proj_num = (int)n; + *proj_den = (int)d; +} + +static int g_ws_projection_num = 4; +static int g_ws_projection_den = 3; +static int g_ws_projection_mode = -1; +static void refresh_widescreen_projection() { + if (!g_ws_engaged) return; + + const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; + const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; + int proj_num = g_video_aspect_num; + int proj_den = g_video_aspect_den; + if (mode == 1) { + netplay_local_viewport_projection_aspect( + g_video_aspect_num, g_video_aspect_den, &proj_num, &proj_den); + } + + if (mode == g_ws_projection_mode && + proj_num == g_ws_projection_num && + proj_den == g_ws_projection_den) { + return; + } + + g_ws_projection_mode = mode; + g_ws_projection_num = proj_num; + g_ws_projection_den = proj_den; + gte_set_display_aspect(mode == 1 ? proj_num : 4, + mode == 1 ? proj_den : 3); + gpu_ws_configure(proj_num, proj_den, g_ws_anchor_addr, + g_ws_hud_sprt ? 1 : 0, mode); +} + /* Follow the host window without feeding its absolute pixel size into guest * rendering. Only the ratio matters: gpu_ws_configure derives the PSX-native * sidecar width from it, just as the fixed 16:9/21:9 modes do. */ @@ -1455,14 +1537,7 @@ static void update_adaptive_widescreen() { SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale); } - if (g_ws_engaged) { - const bool wide = num * 3 != den * 4; - const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; - gte_set_display_aspect(mode == 1 ? num : 4, - mode == 1 ? den : 3); - gpu_ws_configure(num, den, g_ws_anchor_addr, - g_ws_hud_sprt ? 1 : 0, mode); - } + refresh_widescreen_projection(); } /* SDL GL attributes are global inputs to the next context creation. Set the @@ -1480,13 +1555,8 @@ static void configure_core_gl_context_attributes() { * Re-engages with the chosen mode in place if widescreen is already running. */ extern "C" void psx_ws_set_native_wide(int on) { g_ws_native_wide = on ? 1 : 0; - if (g_ws_engaged && g_video_aspect_num * 3 != g_video_aspect_den * 4) { - int mode = g_ws_native_wide ? 2 : 1; - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); - } + g_ws_projection_mode = -1; + refresh_widescreen_projection(); } extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } @@ -2374,13 +2444,6 @@ static void shutdown_runtime(void); * there instead of killing the process. */ static int g_netplay_from_lobby = 0; static int g_netplay_vsync_forced_off = 0; -/* game.toml [netplay] local_viewport = "vertical_split": during real netplay, - * present only this peer's native split-screen half and stretch it to the - * window. Presentation-only; the guest still renders the original framebuffer. */ -static int g_netplay_local_viewport = 0; /* 0 off, 1 vertical split */ -/* Optional aspect for netplay local-view extraction. Mirrors trusted mod aspect - * activation, but remains game.toml opt-in so normal netplay stays vanilla. */ -static int g_netplay_local_viewport_aspect = 0; /* 0 off, 1 16:9, 2 21:9, 3 adaptive */ static void apply_netplay_local_viewport_aspect(bool netplay_enabled) { if (!netplay_enabled || @@ -6229,14 +6292,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { extern int fntrace_is_game_started(void); if (fntrace_is_game_started()) { g_ws_engaged = true; - int mode = g_ws_native_wide ? 2 : 1; - /* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio - * (identity squash). Squash mode: feed the real wide aspect. */ - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); + g_ws_projection_mode = -1; + refresh_widescreen_projection(); } + } else { + refresh_widescreen_projection(); } /* Rollback resim (§33/§47): short catch-up keeps hold-last; long catch-up