diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index a0338656..89b5923c 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -318,3 +318,36 @@ Generate & rebuild / prepare flows should point at the **`.cue`**, not a lone This document will grow as N-way rollback confirmation, SFU soak on 5P titles, and further ICE/SFU policy land. + +## Per-peer full-screen views + +`[netplay].local_viewport = "vertical_split"` presents the camera belonging to +the peer's mapped controller port, including lobby seat reordering. +`local_viewport_aspect = "16:9"` fixes the presentation aspect independently +of monitor size. `local_viewport_renderer = "projection"` widens projection +inside the native split framebuffer; `native_wide` selects the separate wide +compositor. Offline rendering keeps the title's original renderer choice. + +`local_viewport_aspect = "fixed"` uses the admitted mod plan's 21:9 choice or +defaults to 16:9. It disables adaptive resizing for the session. Enhanced +native-wide local views present their per-camera surface directly through GL, +including both double-buffer bands, and retain the full composite rather than +copying canonical split-divider columns into it. Trusted title plugins may opt +into forward-netplay render passes; rollback resimulation stays excluded (see +[RENDER_PASSES.md](RENDER_PASSES.md)). + +Projection uses the PSX display's pixel aspect: each half of a 4:3 display +spans 2:3, so a 16:9 local view needs a 3/8 horizontal projection ratio. +The GL path presents the selected high-resolution FBO half directly. +Source-guarded HUD groups drawn in a full-display area and spanning the seam +are copied completely into both halves, preserving glyphs and shadows. + +Titles may configure `local_viewport_state_addr` and +`local_viewport_state_values` to release the crop immediately during modal +menus. V82 uses its modal-menu flag at 0x8006B4F0 (running value 0). Its +`local_viewport_width_sites = ["0x8001C248", "0x8002E01C"]` identifies exact +LW sites for terrain/object frusta. The generated and dirty-RAM paths apply +the same inverse projection ratio to half-display widths while unsplit is +active; ordinary widths, pause and offline execution remain unchanged. +Regenerate title code after changing these sites. At most 16 unique aligned +main-RAM sites are allowed, and they require the projection renderer. diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 50c5ce17..3763d602 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -833e65ea87826617c22ddb8598694feb750f9d4f9faa432976e006af40911129 +9c92d6060348bc2478abb2370e9c9142ee9c0d1d0ec8dc05d9be98177545e6e2 diff --git a/generated/SCPH1001.emitter.sha b/generated/SCPH1001.emitter.sha index 67da092b..8d0cbb2e 100644 --- a/generated/SCPH1001.emitter.sha +++ b/generated/SCPH1001.emitter.sha @@ -1 +1 @@ -5fdf6a427293e5169e5f9c249d6194a25e6b9cc5a78ae3116d956c6d0feefe3e +26c707e2203f6886cc6c73098d2072fc811b278f55521fa0e945fa30b2def17f diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 4409e4ef..bfb40bda 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -152,6 +152,7 @@ struct CodeGenConfig { // plane_nx_sites). The configured lw routes through the runtime helper // (inverse-aspect scale while revealed, identity at 4:3). std::set ws_cull_plane_nx_sites; + std::set netplay_local_viewport_width_sites; // Per-primitive X-reject bound load sites ([widescreen.cull] // xclip_load_sites). The configured lw routes through the runtime helper diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 9ace7ceb..0baaf4aa 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1278,6 +1278,27 @@ std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t in } // Overlay variant at the same address: leave nonmatching code unchanged. } + // Exact LW sites that build each unsplit camera's horizontal frustum. + if (config_.netplay_local_viewport_width_sites.count(addr)) { + if (opcode == 0x23) { // lw + uint32_t rs = get_rs(instr), rt = get_rt(instr); + int16_t offset = get_imm16(instr); + std::string laddr = (offset == 0) + ? reg_name(rs) + : fmt::format("{} + {}", reg_name(rs), (int)offset); + uint32_t mask = 1u << rs; + return fmt::format("{} = (uint32_t)psx_ws_local_viewport_width((int32_t)psx_cyc_load_word(cpu, {}, {}, 0x{:X}u));" + " /* unsplit camera frustum width */{}", + reg_name(rt), laddr, rt, mask, comment); + } + if (!config_.overlay_mode) { + fmt::print(stderr, "ERROR: [netplay] local_viewport_width site 0x{:08X} is not " + "lw (opcode 0x{:02X})\n", addr, opcode); + std::exit(1); + } + // Overlay variant at the same address: leave nonmatching code unchanged. + } + // Side frustum-plane normal-X load: `lw rt,off(rs)` reads the nx of a // plane sign-tested per corner (dot = nx*px + nz*pz). The helper scales // nx by the inverse aspect factor (4*den)/(3*num) while revealed, which @@ -3589,6 +3610,8 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { ss << "extern void psx_ws_mmx6_bg_stage_init(void); /* ws 2D stage reveal invalidation (gpu.c) */\n"; ss << "extern int psx_ws_x_margin(void); /* widescreen cull-margin term (gpu.c) */\n"; ss << "extern int32_t psx_ws_player_x_bound(int32_t vanilla); /* typed gameplay X bound */\n"; + if (!config_.netplay_local_viewport_width_sites.empty()) + ss << "extern int32_t psx_ws_local_viewport_width(int32_t vanilla); /* netplay unsplit frustum width */\n"; ss << "extern int32_t psx_ws_screen_x_bound(int32_t vanilla); /* typed screen-pixel X bound */\n"; ss << "extern int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); /* ws auto screen-x cull (gpu.c) */\n"; ss << "extern int psx_ws_cull_slti(uint32_t sx, uint32_t imm); /* ws cull signed right edge (gpu.c) */\n"; diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 40f2e106..17d433f0 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -2,6 +2,7 @@ #include "config_loader.h" +#include #include #include #include @@ -109,6 +110,8 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { h.words("cull_clip_edge_x_load", c.ws_cull_clip_edge_x_load_sites); h.u32(ws_cull_clip_edge_width(c)); } + if (!c.netplay_local_viewport_width_sites.empty()) + h.words("local_viewport_width", c.netplay_local_viewport_width_sites); h.tag("flags"); h.u32(c.ws_auto_screen_x_cull ? 1u : 0u); @@ -1441,6 +1444,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { std::string netplay_local_viewport; std::string netplay_local_viewport_aspect; std::string netplay_local_viewport_renderer; + std::vector netplay_local_viewport_width_sites; uint32_t netplay_local_viewport_state_addr = 0; std::vector netplay_local_viewport_state_values; if (cfg.contains("netplay")) { @@ -1486,10 +1490,11 @@ GameConfig load_game_config(const fs::path& config_path_in) { if (!netplay_local_viewport_aspect.empty() && netplay_local_viewport_aspect != "16:9" && netplay_local_viewport_aspect != "21:9" && + netplay_local_viewport_aspect != "fixed" && netplay_local_viewport_aspect != "adaptive") { throw std::runtime_error(fmt::format( "[netplay] local_viewport_aspect must be \"16:9\", " - "\"21:9\", or \"adaptive\", got '{}'", + "\"21:9\", \"fixed\", or \"adaptive\", got '{}'", netplay_local_viewport_aspect)); } if (!netplay_local_viewport_aspect.empty() && @@ -1498,6 +1503,10 @@ GameConfig load_game_config(const fs::path& config_path_in) { "[netplay] local_viewport_aspect requires local_viewport"); } } + if (np.contains("local_viewport_width_sites")) { + for (const auto& value : toml::find>(np, "local_viewport_width_sites")) + netplay_local_viewport_width_sites.push_back(parse_hex(value, "netplay.local_viewport_width_sites")); + } if (np.contains("local_viewport_renderer")) { netplay_local_viewport_renderer = toml::find(np, "local_viewport_renderer"); @@ -1515,6 +1524,19 @@ GameConfig load_game_config(const fs::path& config_path_in) { "[netplay] local_viewport_renderer requires local_viewport"); } } + if (!netplay_local_viewport_width_sites.empty()) { + if (netplay_local_viewport_renderer != "projection") + throw std::runtime_error("[netplay] local_viewport_width_sites requires projection renderer"); + if (netplay_local_viewport_width_sites.size() > 16) + throw std::runtime_error("[netplay] local_viewport_width_sites supports at most 16 sites"); + std::unordered_set unique; + for (uint32_t pc : netplay_local_viewport_width_sites) { + if ((pc & 3) || (pc & 0x1fffffff) >= 0x800000) + throw std::runtime_error("[netplay] local_viewport_width_sites requires aligned main RAM addresses"); + if (!unique.insert(pc & 0x1fffffff).second) + throw std::runtime_error("[netplay] duplicate local_viewport_width_sites address"); + } + } { const bool has_addr = np.contains("local_viewport_state_addr"); const bool has_values = np.contains("local_viewport_state_values"); @@ -2437,6 +2459,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*netplay_local_viewport*/ netplay_local_viewport, /*netplay_local_viewport_aspect*/ netplay_local_viewport_aspect, /*netplay_local_viewport_renderer*/ netplay_local_viewport_renderer, + /*netplay_local_viewport_width_sites*/ netplay_local_viewport_width_sites, /*netplay_local_viewport_state_addr*/ netplay_local_viewport_state_addr, /*netplay_local_viewport_state_values*/ netplay_local_viewport_state_values, /*seeds_path*/ seeds_path, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index aa7b3e26..12a217aa 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -903,6 +903,9 @@ struct GameConfig { // rejects everything outside the half (THPS2), as their single-player // [widescreen] native_wide = false path does. std::string netplay_local_viewport_renderer; + // Exact LW sites loading the native per-camera width for world frusta. + // Projection unsplit widens that cone while retaining the half-size raster. + std::vector netplay_local_viewport_width_sites; // local_viewport_state_addr / _values: optional guest word gating the // local viewport. Split frames crop to this peer's half only while the // word holds one of the values (e.g. a level running); otherwise, as on a diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index f32cbba7..d341770d 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -234,6 +234,7 @@ static int psxrecomp_game_main(int argc, char** argv) { std::set ws_cull_negsub; // [widescreen.cull] negsub_sites std::set ws_cull_vxrange; // [widescreen.cull] vxrange_sites std::set ws_cull_depth; // [widescreen.cull] depth_sites + std::set netplay_local_viewport_width_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 @@ -310,6 +311,7 @@ static int psxrecomp_game_main(int argc, char** argv) { ws_cull_vxrange.insert(cfg.ws_cull_vxrange_sites.begin(), cfg.ws_cull_vxrange_sites.end()); 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()); + netplay_local_viewport_width_sites.insert(cfg.netplay_local_viewport_width_sites.begin(), cfg.netplay_local_viewport_width_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_nclip_exact.insert(cfg.ws_cull_nclip_exact_sites.begin(), cfg.ws_cull_nclip_exact_sites.end()); @@ -418,6 +420,7 @@ static int psxrecomp_game_main(int argc, char** argv) { ws_cull_vxrange.insert(wscfg.ws_cull_vxrange_sites.begin(), wscfg.ws_cull_vxrange_sites.end()); 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()); + netplay_local_viewport_width_sites.insert(wscfg.netplay_local_viewport_width_sites.begin(), wscfg.netplay_local_viewport_width_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_nclip_exact.insert(wscfg.ws_cull_nclip_exact_sites.begin(), wscfg.ws_cull_nclip_exact_sites.end()); @@ -1489,6 +1492,7 @@ static int psxrecomp_game_main(int argc, char** argv) { codegen_config.ws_cull_vxrange_sites = ws_cull_vxrange; codegen_config.ws_cull_depth_sites = ws_cull_depth; codegen_config.ws_cull_plane_nx_sites = ws_cull_plane_nx; + codegen_config.netplay_local_viewport_width_sites = netplay_local_viewport_width_sites; 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_nclip_exact_sites = ws_cull_nclip_exact; diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index bfde820b..bf7206c4 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -1251,6 +1251,32 @@ void mod_instruction_codegen_test(const fs::path& root) { "unconfigured code emits no instruction callbacks"); } +void local_viewport_tests(const fs::path& root) { + const std::string prefix = "\n[netplay]\nlocal_viewport=\"vertical_split\"\nlocal_viewport_renderer=\"projection\"\n"; + const auto cfg = PSXRecompV4::load_game_config(write_config(root,"unsplit",prefix + + "local_viewport_width_sites=[\"0x80010000\"]\nlocal_viewport_state_addr=\"0x80018000\"\nlocal_viewport_state_values=[\"0x0\"]\n")); + check(cfg.netplay_local_viewport_width_sites == std::vector{0x80010000}, "unsplit width sites parse"); + check(cfg.netplay_local_viewport_renderer == "projection" && cfg.netplay_local_viewport_state_addr == 0x80018000 && + cfg.netplay_local_viewport_state_values == std::vector{0}, "unsplit renderer and pause gate parse"); + auto changed = cfg; changed.netplay_local_viewport_width_sites.clear(); + check(PSXRecompV4::overlay_codegen_config_hash(cfg) != PSXRecompV4::overlay_codegen_config_hash(changed), "unsplit sites invalidate cached code"); + for (auto bad : {"0x80010001", "0x1F800000", "0x80800000"}) { + auto path = write_config(root,"unsplit-bad",prefix+"local_viewport_width_sites=[\""+bad+"\"]\n"); + check_throws([&]{PSXRecompV4::load_game_config(path);}, "aligned main RAM", "unsplit rejects invalid site"); + } + auto dup = write_config(root,"unsplit-duplicate",prefix+"local_viewport_width_sites=[\"0x80010000\",\"0xA0010000\"]\n"); + check_throws([&]{PSXRecompV4::load_game_config(dup);},"duplicate", "unsplit rejects aliased duplicates"); + auto no_projection = write_config(root,"unsplit-renderer","\n[netplay]\nlocal_viewport_width_sites=[\"0x80010000\"]\n"); + check_throws([&]{PSXRecompV4::load_game_config(no_projection);},"requires projection", "unsplit width requires projection"); + PSXRecomp::CodeGenConfig codegen{}; + codegen.netplay_local_viewport_width_sites.insert(0x80010000); + for (bool overlay : {false,true}) { + auto code=generate_first_instruction(0x8F820EDCu,{},overlay,codegen); + check(code.find("psx_ws_local_viewport_width((int32_t)psx_cyc_load_word") != std::string::npos, "unsplit keeps LW cycle accounting"); + } + check(generate_first_instruction(0x8F820EDCu,{},false).find("= (uint32_t)psx_ws_local_viewport_width") == std::string::npos, "ordinary loads remain native"); +} + void cfg_fallthrough_reachability_test() { constexpr uint32_t base = 0x80010000u; PSXRecomp::PS1Executable exe{}; @@ -1302,6 +1328,7 @@ int main() { cfg_codegen_load_delay_test(); mod_function_completion_codegen_test(); mod_instruction_codegen_test(root); + local_viewport_tests(root); cfg_fallthrough_reachability_test(); } catch (const std::exception& e) { fmt::print(stderr, "FAIL unexpected exception: {}\n", e.what()); diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index c625b2ba..b596394d 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -98,6 +98,11 @@ void gpu_get_draw_area(GpuDrawArea* out); * 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); +int32_t psx_ws_local_viewport_width(int32_t vanilla); +void gpu_ws_set_local_viewport_width_sites(const uint32_t *sites, int count); +/* The title's local viewport uses the native-wide per-camera renderer. */ +void gpu_ws_set_local_viewport_native_wide(int enabled); +int psx_ws_is_local_viewport_width_site(uint32_t pc); void gpu_vertical_split_debug(int *active, int *left_age, int *right_age); uint16_t gpu_vram_peek(int x, int y); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index e4266db4..4468fbab 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9858,13 +9858,15 @@ static const char *write_wide_png(const char *path, int *out_w, int *out_h) if (di.disabled || di.width == 0 || di.height == 0) return "display disabled"; int scale = gr_scale(); if (scale < 1) scale = 1; - int present_w = (int)di.width + extra; + int local_width = gpu_ws_netplay_local_viewport_width(); + int base_x = local_width > 0 ? gpu_ws_netplay_local_viewport_base_x() : (int)di.display_x; + int present_w = local_width > 0 ? local_width : (int)di.width + extra; int W = present_w * scale, H = (int)di.height * scale; uint32_t *buf = (uint32_t *)malloc((size_t)W * H * sizeof(uint32_t)); if (!buf) return "alloc failed"; int n = gr_render_wide_display(buf, W * (int)sizeof(uint32_t), - (int)di.display_x, (int)di.display_y, (int)di.height); + base_x, (int)di.display_y, (int)di.height); if (n <= 0) { free(buf); return "no wide surface for displayed buffer"; } /* ARGB8888 (0xAARRGGBB) -> RGB, written in the buffer's row order (so the diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 38f49b74..58fd8c36 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -2384,7 +2384,9 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, case 0x23: { /* LW */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; if (addr & 3) return interp_exception(cpu, 4, addr, pc); /* LoadAddressError */ - if (psx_ws_is_cull_plane_nx_site(pc)) + if (psx_ws_is_local_viewport_width_site(pc)) + cpu->gpr[rt] = (uint32_t)psx_ws_local_viewport_width((int32_t)psx_cyc_load_word(cpu, addr, rt, 1u << rs)); + else if (psx_ws_is_cull_plane_nx_site(pc)) /* Side-plane normal-X: inverse-aspect scale while revealed. */ cpu->gpr[rt] = (uint32_t)psx_ws_plane_nx( (int32_t)psx_cyc_load_word(cpu, addr, rt, 1u << rs)); diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 6277daab..450b1f17 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -111,6 +111,15 @@ static int ws_auto_ui_squash; static int ws_auto_ui_in_place; static int ws_auto_ui_dense; static int ws_active(void); +static int ws_local_viewport_cfg = 0; +/* [netplay] local_viewport_renderer = "native_wide": split frames are always + * world frames for the per-camera wide surfaces. Projection views keep the + * title's own scene classification. */ +static int ws_local_viewport_native = 0; +static int ws_vertical_split_active(void); +static int ws_local_native_split(void) { + return ws_local_viewport_cfg && ws_local_viewport_native && ws_vertical_split_active(); +} static int gp0_command_word_count(uint8_t opcode); static uint64_t ws_auto_ui_candidate_count; static uint64_t ws_auto_ui_transform_count; @@ -384,6 +393,7 @@ static int ws_mod_world_scene(void) { } static int ws_game_mode(void) { if (ws_mod_world_scene()) return 1; + if (ws_local_native_split()) return 1; int state_match = ws_gameplay_state_matches(); if (state_match >= 0) return state_match; if (ws_full_2d_mode()) return 1; @@ -421,6 +431,7 @@ static int ws_game_mode(void) { static WsSceneLatch ws_scene_latch; static int ws_2d_only_scene(void) { if (ws_mod_world_scene()) return 0; + if (ws_local_native_split()) return 0; if (ws_full_2d_mode() || ws_gte_game_mode_cfg) return 0; uint32_t f = (uint32_t)s_frame_count; return ws_scene_is_2d(&ws_scene_latch, f, @@ -504,10 +515,31 @@ static int ws_native_wide_configured(void) { return ws_mode == 2 && ws_cfg_num * 3 > ws_cfg_den * 4; } -static int ws_local_viewport_cfg = 0; + static int ws_local_viewport_slot = 0; static int ws_local_viewport_draw_target(int *base_x); static int ws_vertical_split_active(void); +#define WS_LOCAL_WIDTH_SITES_MAX 16 +static uint32_t ws_local_width_sites[WS_LOCAL_WIDTH_SITES_MAX]; +static int ws_local_width_count; +void gpu_ws_set_local_viewport_width_sites(const uint32_t *sites, int count) { + ws_local_width_count = count < 0 ? 0 : count > WS_LOCAL_WIDTH_SITES_MAX ? WS_LOCAL_WIDTH_SITES_MAX : count; + for (int i = 0; i < ws_local_width_count; ++i) ws_local_width_sites[i] = sites[i] & 0x1FFFFFFFu; +} +int psx_ws_is_local_viewport_width_site(uint32_t pc) { + for (int i = 0; i < ws_local_width_count; ++i) + if (ws_local_width_sites[i] == (pc & 0x1FFFFFFFu)) return 1; + return 0; +} +int32_t psx_ws_local_viewport_width(int32_t vanilla) { + GpuDisplayInfo di; gpu_get_display_info(&di); + if (!ws_local_viewport_cfg || ws_mode != 1 || !ws_vertical_split_active() || + vanilla <= 0 || (int64_t)vanilla * 2 != (int)di.width) return vanilla; + return (int32_t)(((int64_t)vanilla * ws_xden + ws_xnum - 1) / ws_xnum); +} +void gpu_ws_set_local_viewport_native_wide(int enabled) { + ws_local_viewport_native = enabled ? 1 : 0; +} void gpu_ws_set_netplay_local_viewport(int enabled, int slot) { ws_local_viewport_cfg = enabled ? 1 : 0; ws_local_viewport_slot = slot == 1 ? 1 : 0; @@ -3172,7 +3204,9 @@ static int ws_local_viewport_draw_target(int *base_x) { return 0; GpuDisplayInfo di; gpu_get_display_info(&di); - if (draw_area_intersects_rect(base, (int)di.display_y, + /* Native double buffering draws the next frame in the other Y band. + * The per-camera wide surface spans every band, just like the 1P surface. */ + if (draw_area_intersects_rect(base, (int)draw_area_top, src_w, (int)di.height)) { if (base_x) *base_x = base; return 1; @@ -4638,12 +4672,34 @@ static void gp0_exec_mono_tri(void) { } /* Execute mono quad (GP0 0x28-0x2B) — two triangles: (0,1,2) and (2,1,3) */ +/* A native split divider belongs to the combined framebuffer. Preserve it in + * canonical VRAM, but never mirror it into an expanded individual camera. */ +static int ws_local_viewport_divider(int x0, int y0, int x1, int y1, + uint16_t color, int semi_trans) { + int base, src_w; + if (color || semi_trans || !ws_native_wide_active() || + !ws_local_viewport_layout(&base, &src_w, NULL, NULL)) return 0; + GpuDisplayInfo di; gpu_get_display_info(&di); + const int seam = (int)di.display_x + (int)di.width / 2; + return x0 + draw_offset_x >= seam - 1 && x1 + draw_offset_x <= seam + 1 && + x1 > x0 && y0 <= 0 && y1 >= (int)di.height; +} + static void gp0_exec_mono_quad(void) { int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); int32_t vx[4], vy[4]; for (int i = 0; i < 4; i++) parse_vertex(gp0_cmd_buf[1 + i], &vx[i], &vy[i]); + if (vx[0] == vx[2] && vx[1] == vx[3] && vy[0] == vy[1] && vy[2] == vy[3] && + ws_local_viewport_divider(vx[0], vy[0], vx[1], vy[2], color, semi_trans)) { + gr_wide_disable_target(); + gr_set_semi_transparency(0, (int)semi_transparency); + gr_draw_flat_rect(vx[0] + draw_offset_x, vy[0] + draw_offset_y, + vx[1] - vx[0], vy[2] - vy[0], color); + ws_nw_sync_target(); + return; + } int rej_a = gpu_triangle_rejected(vx, vy, 0, 1, 2); int rej_b = gpu_triangle_rejected(vx, vy, 2, 1, 3); if (rej_a && rej_b) return; @@ -5169,6 +5225,15 @@ static void gp0_exec_mono_line(void) { parse_vertex(gp0_cmd_buf[1], &x0, &y0); parse_vertex(gp0_cmd_buf[2], &x1, &y1); if (psx_gpu_line_oversize(x0, y0, x1, y1)) return; + if (x0 == x1 && ws_local_viewport_divider(x0, y0 < y1 ? y0 : y1, + x0 + 1, (y0 > y1 ? y0 : y1) + 1, color, semi_trans)) { + gr_wide_disable_target(); + gr_set_semi_transparency(0, (int)semi_transparency); + gr_draw_line(x0 + draw_offset_x, y0 + draw_offset_y, + x1 + draw_offset_x, y1 + draw_offset_y, color); + ws_nw_sync_target(); + return; + } int32_t vx[2] = { x0, x1 }; ws_nw_hud_shift_vertices(vx, 2); x0 = vx[0]; x1 = vx[1]; @@ -5214,6 +5279,13 @@ static void gp0_exec_mono_rect(void) { int h = (gp0_cmd_buf[2] >> 16) & 0xFFFFu; if (w > 1023) w = 1023; if (h > 511) h = 511; + if (ws_local_viewport_divider(x0, y0, x0 + w, y0 + h, color, semi_trans)) { + gr_wide_disable_target(); + gr_set_semi_transparency(0, (int)semi_transparency); + gr_draw_flat_rect(x0 + draw_offset_x, y0 + draw_offset_y, w, h, color); + ws_nw_sync_target(); + return; + } ws_expand_fullscreen_rect(&x0, y0, &w, h); /* Same auto_ui squash the textured rect path gets. Without it a flat * -coloured HUD mark keeps its 4:3 X while the textured primitives of the @@ -6178,7 +6250,8 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { * 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; + ws_auto_ui_dense = ws_ui_prepass_count >= 32u && !ws_gte_game_mode_cfg && + !ws_local_native_split(); if (ws_ui_prepass_count == 0) return; const int32_t group_origin = ws_disp_x(); diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 8a37e461..795f9682 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -2489,7 +2489,10 @@ static int wide_fast_center_valid(void) { /* An explicitly stretched sky differs inside the canonical viewport too. * Keep the full composite for those scenes, including later foreground * draws. Tags are installed before DMA, so no earlier center draws skip. */ - return s_wide_fast && !gpu_ws_background_requires_full_composite(); + // The native split seam occupies canonical edge columns that the expanded + // camera renders as world. Copying those columns reinstates the divider. + return s_wide_fast && gpu_ws_netplay_local_viewport_width() <= 0 && + !gpu_ws_background_requires_full_composite(); } static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h); /* def below */ /* True if [lo,hi] (canonical draw-x) lies strictly inside the 4:3 frame, so the diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c78729b9..d5ed49ae 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1479,10 +1479,15 @@ static bool ensure_sdl_pixel_buf_capacity(size_t pixels) { 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 int g_netplay_local_viewport_aspect = 0; /* 0 off, 1 16:9, 2 21:9, 3 adaptive, 4 fixed mod choice */ /* [netplay] local_viewport_renderer = "projection": widen the projection into * the local half instead of rendering native-wide columns beside it. */ static int g_netplay_local_viewport_projection = 0; +/* Native-wide per-camera views ([netplay] local_viewport_renderer = + * "native_wide"). Projection views keep the shared presentation path. */ +static bool netplay_local_viewport_native_wide(void) { + return g_netplay_local_viewport == 1 && !g_netplay_local_viewport_projection; +} extern "C" int psx_mod_set_fixed_display_aspect( uint32_t numerator, uint32_t denominator) { @@ -1948,7 +1953,10 @@ static void refresh_widescreen_projection() { const bool local_native_wide = g_netplay_local_viewport == 1 && !g_netplay_local_viewport_projection && psx_netplay_active() && gpu_last_frame_vertical_split_screen(); - const bool native_wide = (g_netplay_local_viewport == 1) + /* Native-wide local views follow the title's own renderer offline; + * projection local views keep their per-camera projection throughout. */ + const bool native_wide = (g_netplay_local_viewport == 1 && + (g_netplay_local_viewport_projection || psx_netplay_active())) ? local_native_wide : (g_ws_native_wide != 0); const int mode = wide ? (native_wide ? 2 : 1) : 0; @@ -3271,6 +3279,12 @@ static void apply_netplay_local_viewport_aspect(bool netplay_enabled) { (void)psx_mod_set_fixed_display_aspect(16u, 9u); (void)psx_mod_set_adaptive_display_aspect(21u, 9u); break; + case 4: + // The admitted mod plan selects a fixed ratio before play. Window + // resizing cannot change it; other offline choices default to 16:9. + (void)psx_mod_set_fixed_display_aspect( + g_video_aspect_num * 9 == g_video_aspect_den * 21 ? 21u : 16u, 9u); + break; default: break; } @@ -8138,7 +8152,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * wide (compositor unsupported, or the surface fallback below) * pillarboxes 4:3 like FMV/menus instead. Only squash mode (1) may * stretch: its canonical content is pre-squashed FOR the stretch. */ - const bool nw_pin = g_ws_engaged && g_ws_native_wide; + const bool nw_pin = g_ws_engaged && + (netplay_local_viewport_native_wide() ? g_ws_projection_mode == 2 + : g_ws_native_wide != 0); /* Ring the classification now that it's final (only the software/CPU * wide path below can still fall back — it amends this entry). A @@ -8171,7 +8187,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { return ep; } if (g_gl_active && g_gl_fbo_present && !di.depth24 && - !local_viewport_crop) { + (!local_viewport_crop || local_viewport_wide)) { if (wide_present) { /* GPU-direct native-wide present: blit the displayed buffer's * wide FBO straight to the window (GPU-side, like the canonical @@ -8180,7 +8196,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * GL-only slowdown (SW's wide path is pure CPU, no GPU sync, so * SW stayed smooth). Falls through to the CPU readout path only if * the wide surface for this buffer doesn't exist yet. */ - if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, + const int wide_base = local_viewport_wide + ? gpu_ws_netplay_local_viewport_base_x() : (int)di.display_x; + if (gl_renderer_present_wide_fbo(wide_base, (int)di.display_y, (int)h, g_video_aa ? 1 : 0)) { netplay_note_present(); return ep; @@ -14048,9 +14066,13 @@ int main(int argc, char** argv) { 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 == "fixed") ? 4 : (gc.netplay_local_viewport_aspect == "adaptive") ? 3 : 0; + gpu_ws_set_local_viewport_width_sites(gc.netplay_local_viewport_width_sites.data(), (int)gc.netplay_local_viewport_width_sites.size()); g_netplay_local_viewport_projection = (gc.netplay_local_viewport_renderer == "projection") ? 1 : 0; + gpu_ws_set_local_viewport_native_wide( + g_netplay_local_viewport == 1 && !g_netplay_local_viewport_projection); gpu_ws_set_local_viewport_state_gate( gc.netplay_local_viewport_state_addr, gc.netplay_local_viewport_state_values.data(), diff --git a/runtime/tests/test_gl_readback_region.c b/runtime/tests/test_gl_readback_region.c index b707a1b8..7102e378 100644 --- a/runtime/tests/test_gl_readback_region.c +++ b/runtime/tests/test_gl_readback_region.c @@ -23,6 +23,8 @@ int gpu_ws_nw_flat_backdrop_enabled(void){return 0;} int g_ws_tex_edge_pct=0; int psx_ws_prim_is_tagged(void){return 0;} void gpu_depth24_upload_span_reset(void){} +/* Netplay unsplit view and forward-pass opt-in: off in these fixtures. */ +int gpu_ws_netplay_local_viewport_width(void){return 0;} void frame_interpolation_schedule_reset(FrameInterpolationSchedule *p){memset(p,0,sizeof(*p));} void frame_flip_tracker_reset(FrameFlipTracker *p){memset(p,0,sizeof(*p));p->period=1;} static int checks,failures; diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index a0738199..12ba8479 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -80,6 +80,8 @@ int g_ws_tex_edge_pct=0; int psx_ws_prim_is_tagged(void){return 0;} void psx_ws_dbg_gate_frame_snapshot(void){} void gpu_depth24_upload_span_reset(void){} +/* Netplay unsplit view and forward-pass opt-in: off in these fixtures. */ +int gpu_ws_netplay_local_viewport_width(void){return 0;} /* Guest clock the renderer's stereo-pair freshness reads; test stand-ins, as * in test_gl_readback_region.c. */ uint64_t psx_cycle_count=0; diff --git a/runtime/tests/test_ws_shared_split_ui_exec.c b/runtime/tests/test_ws_shared_split_ui_exec.c index a6f1fef0..3047241c 100644 --- a/runtime/tests/test_ws_shared_split_ui_exec.c +++ b/runtime/tests/test_ws_shared_split_ui_exec.c @@ -86,7 +86,62 @@ static void expect_shared(int shared) { } } +static void width_sites(void) { + counter(1,0); + hres1=0; hres2=1; // 368 would not represent V82; use its 320 mode below. + hres1=1; hres2=0; + GpuDisplayInfo di; gpu_get_display_info(&di); assert(di.width==320); + uint32_t sites[]={0x8001C248,0x8002E01C}; + gpu_ws_set_local_viewport_width_sites(sites,2); + assert(psx_ws_is_local_viewport_width_site(0xA001C248)); + assert(!psx_ws_is_local_viewport_width_site(0x8001C24C)); + for(int slot=0;slot<2;slot++) { + gpu_ws_set_netplay_local_viewport(1,slot); + assert(psx_ws_local_viewport_width(160)==427); + assert(psx_ws_local_viewport_width(320)==320); + assert(psx_ws_local_viewport_width(INT32_MAX)==INT32_MAX); + assert(psx_ws_local_viewport_width(-1)==-1); + } + test_ram[GATE/4]=1; assert(psx_ws_local_viewport_width(160)==160); + test_ram[GATE/4]=0; ws_mode=2; assert(psx_ws_local_viewport_width(160)==160); + ws_mode=1; gpu_ws_set_netplay_local_viewport(0,0); assert(psx_ws_local_viewport_width(160)==160); +} + +static void native_wide_buffers(void) { + counter(1, 0); + hres1 = 1; hres2 = 0; + ws_mode = 2; ws_cfg_num = 16; ws_cfg_den = 9; + for (int slot = 0; slot < 2; ++slot) { + ws_local_viewport_slot = slot; + assert(gpu_ws_netplay_local_viewport_width() == 426); + for (int band = 0; band < 2; ++band) { + display_area_y = 240 * (1 - band); + draw_area_top = draw_offset_y = 240 * band; + draw_area_bottom = draw_area_top + 239; + draw_area_left = slot ? 161 : 0; + draw_area_right = slot ? 319 : 159; + int base = -1; + assert(ws_local_viewport_draw_target(&base)); + assert(base == 160 * slot); + draw_area_left = slot ? 0 : 161; + draw_area_right = slot ? 159 : 319; + assert(!ws_local_viewport_draw_target(&base)); + assert(ws_local_viewport_divider(160, 0, 161, 240, 0, 0)); + assert(!ws_local_viewport_divider(160, 0, 161, 20, 0, 0)); + assert(!ws_local_viewport_divider(160, 0, 161, 240, 1, 0)); + assert(!ws_local_viewport_divider(160, 0, 161, 240, 0, 1)); + assert(!ws_local_viewport_divider(159, 0, 163, 240, 0, 0)); + } + } + ws_cfg_num = 21; + assert(gpu_ws_netplay_local_viewport_width() == 560); + ws_local_viewport_cfg = 0; + assert(!ws_local_viewport_divider(160, 0, 161, 240, 0, 0)); +} + int main(void) { + native_wide_buffers(); + width_sites(); counter(1, 0); expect_shared(1); for (unsigned slot = 0; slot < 2; slot++) {