diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 21bc9a5e..72798954 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -219,6 +219,10 @@ struct ModPlugin { * run only inside the sandboxed own-view render * (psx_mod_render_local_view), never in the shared simulation. */ bool netplay_local_view = false; + /* netplay = "input": the plugin only transforms this player's own pad + * before it is staged (psx_mod_set_pad_transform). Online it stays on per + * player: the post-transform pad is what every peer simulates. */ + bool netplay_input = false; }; struct ModResource { @@ -354,6 +358,7 @@ struct ModResolution { std::string package_id; std::string feature_id; bool netplay_local_view = false; + bool netplay_input = false; }; std::vector plugins; struct Resource { diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 772f2cc1..5154896b 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -33,6 +33,9 @@ bool mod_runtime_commit_netplay_view(const std::filesystem::path& disc_path, std::string* error = nullptr); /* 1 while the session plan is such an own-view plan. */ bool mod_runtime_netplay_view_active(); +/* 1 while that plan holds a [[plugin]] netplay = "input" (a pad transform + * applied to this player's own pad at netplay staging). */ +bool mod_runtime_netplay_input_active(); /* "package/feature" keys of `plan` that qualify as own-view features. */ std::vector mod_runtime_netplay_view_features(const ModResolution& plan); const std::string& mod_runtime_fingerprint(); diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index c08f7438..e3780b12 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -32,7 +32,8 @@ typedef struct PsxNetplayRbBindings { int *input_delay; void (*publish_sio)(uint32_t tick); /* publish history or sealed rows to SIO */ void (*apply_frame_slot)(int slot, uint32_t tick, uint16_t buttons, - int8_t sx, int8_t sy, uint8_t analog); + int8_t sx, int8_t sy, uint8_t analog, + uint8_t rx, uint8_t ry); } PsxNetplayRbBindings; void psx_netplay_rb_bind(const PsxNetplayRbBindings *b); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 68d66c5c..0c87c662 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7826,6 +7826,8 @@ static void handle_unwatch(int id, const char *json) send_err(id, "watchpoint not found"); } +static int s_trigger_override; +static uint32_t s_trigger_lt, s_trigger_rt; static void handle_set_input(int id, const char *json) { char val_str[32]; @@ -7848,9 +7850,25 @@ static void handle_set_input(int id, const char *json) int v = ax[i] < 0 ? 0x80 : (ax[i] > 255 ? 255 : ax[i]); s_axis_st[i] = (uint8_t)v; } + /* Optional host triggers lt/rt (0..255) for a title pad transform, as a + * gamepad's analog triggers would give it. Absent -> released. */ + { + const int lt = json_get_int(json, "lt", -1), rt = json_get_int(json, "rt", -1); + s_trigger_override = lt >= 0 || rt >= 0; + s_trigger_lt = (uint32_t)(lt < 0 ? 0 : (lt > 255 ? 255 : lt)); + s_trigger_rt = (uint32_t)(rt < 0 ? 0 : (rt > 255 ? 255 : rt)); + } send_ok(id); } +int debug_server_get_trigger_override(uint32_t *lt, uint32_t *rt) +{ + if (!s_trigger_override) return 0; + *lt = s_trigger_lt; + *rt = s_trigger_rt; + return 1; +} + static void handle_press(int id, const char *json) { int buttons = json_get_int(json, "buttons", -1); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a1755afa..51645475 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6169,6 +6169,63 @@ static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { /* Stage local pad + poll admit until published. Parks the guest fiber when * called from the vblank callback (or before scheduler entry). Latches one * sample per sim tick; stalls on INPUT_CONFIRM desync. */ +static void pad_ext_host_extras(void*, int s, uint32_t* flags, uint32_t* lt, + uint32_t* rt); +#ifndef PSX_NO_DEBUG_TOOLS +extern "C" int debug_server_get_trigger_override(uint32_t* lt, uint32_t* rt); +#endif + +/* Netplay input transform ([[plugin]] netplay = "input"): this player's own + * title pad transform runs on its local pad before staging, so the + * transformed pad (buttons, sticks, presented type, NeGcon I/II) is what every + * peer receives and simulates. Each peer transforms only its own seat. */ +static void netplay_transform_local_pad(PsxNetPad* pad, int override_active) { + if (!PSXRecompV4::mod_runtime_netplay_input_active() || + psx_netplay_is_spectator()) + return; + const int seat = psx_netplay_local_slot(); + if (seat < 0 || seat >= PSX_MAX_PLAYERS) return; + PSXModPadFrame f{}; + f.struct_size = sizeof f; + f.player = (uint32_t)seat; + f.buttons = pad->buttons; + f.lx = pad->lx; f.ly = pad->ly; f.rx = pad->rx; f.ry = pad->ry; + f.type = pad->analog; + if (override_active) { +#ifndef PSX_NO_DEBUG_TOOLS + uint32_t lt = 0, rt = 0; + if (debug_server_get_trigger_override(<, &rt)) { + f.host_flags = PSX_MOD_PAD_HOST_GAMEPAD | PSX_MOD_PAD_HOST_LT | + PSX_MOD_PAD_HOST_RT; + f.host_lt = lt; + f.host_rt = rt; + } +#endif + } else { + const int card = psx_netplay_input_player(); + pad_ext_host_extras(nullptr, card >= 0 ? card : 0, &f.host_flags, + &f.host_lt, &f.host_rt); + } + PSXModPadOutput stock{}, o{}; + stock.struct_size = sizeof stock; + stock.buttons = f.buttons; stock.type = f.type; + stock.lx = f.lx; stock.ly = f.ly; stock.rx = f.rx; stock.ry = f.ry; + if (!mod_pad_transform_run((uint32_t)seat, &f, &stock, &o)) return; + pad->buttons = (uint16_t)o.buttons; + pad->analog = (uint8_t)o.type; + if (o.type == PSX_MOD_PAD_NEGCON) { + pad->lx = (uint8_t)o.lx; + pad->ly = (uint8_t)o.negcon_l; + pad->rx = (uint8_t)o.negcon_i; + pad->ry = (uint8_t)o.negcon_ii; + } else if (o.type == PSX_MOD_PAD_DIGITAL) { + pad->lx = pad->ly = pad->rx = pad->ry = 0x80; + } else { + pad->lx = (uint8_t)o.lx; pad->ly = (uint8_t)o.ly; + pad->rx = (uint8_t)o.rx; pad->ry = (uint8_t)o.ry; + } +} + static void netplay_barrier_admit(int override) { if (!psx_netplay_active()) return; /* Launcher/game window teardown can leave a queued SDL_QUIT; draining it @@ -6333,6 +6390,7 @@ static void netplay_barrier_admit(int override) { } else { capture_local_human_pad(&local); } + netplay_transform_local_pad(&local, override >= 0); psx_netplay_stage_local(&local); } else if (psx_start_bisect_spin_log() && !g_headless) { /* Dense SDL-only samples while admit waits without capture. */ diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 1964a519..70748a79 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -2540,10 +2540,12 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, { const std::string netplay = toml::find_or(v, "netplay", ""); - if (!netplay.empty() && netplay != "local_view") + if (!netplay.empty() && netplay != "local_view" && + netplay != "input") throw std::runtime_error( - "plugin netplay must be \"local_view\""); + "plugin netplay must be \"local_view\" or \"input\""); plugin.netplay_local_view = netplay == "local_view"; + plugin.netplay_input = netplay == "input"; } read_conditions(v, out.options, plugin.feature_id, plugin.when, "plugin"); @@ -3803,6 +3805,7 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, resolved.package_id = package->id; resolved.feature_id = plugin->feature_id; resolved.netplay_local_view = plugin->netplay_local_view; + resolved.netplay_input = plugin->netplay_input; result.plugins.push_back(std::move(resolved)); } for (const ModResource& resource : package->resources) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 879ece05..fd24c037 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1409,7 +1409,7 @@ std::vector mod_runtime_netplay_view_features(const ModResolution& for (const ModResolution::Plugin& p : plan.plugins) { const std::string key = p.package_id + "/" + p.feature_id; auto it = ok.find(key); - const bool good = p.netplay_local_view; + const bool good = p.netplay_local_view || p.netplay_input; ok[key] = it == ok.end() ? good : (it->second && good); } for (const ModResolution::Write& w : plan.writes) @@ -1428,6 +1428,14 @@ std::vector mod_runtime_netplay_view_features(const ModResolution& return out; } +bool mod_runtime_netplay_input_active() { + const RuntimeMods& s = state(); + if (!s.netplay_view_plan || !s.plan.ok) return false; + for (const ModResolution::Plugin& p : s.plan.plugins) + if (p.netplay_input) return true; + return false; +} + bool mod_runtime_netplay_view_active() { const RuntimeMods& s = state(); return s.netplay_view_plan && s.plan.ok && !s.plan.plugins.empty(); diff --git a/runtime/src/netplay_input_hist.c b/runtime/src/netplay_input_hist.c index 5917809d..e3c46782 100644 --- a/runtime/src/netplay_input_hist.c +++ b/runtime/src/netplay_input_hist.c @@ -41,6 +41,8 @@ void netplay_ih_pad_to_frame(const PsxNetPad *pad, uint32_t tick, uint8_t predic out->stick_x = u8_to_i8_stick(n.lx); out->stick_y = u8_to_i8_stick(n.ly); out->analog = n.analog; + out->rx = n.rx; /* the rest of the pad: right stick, NeGcon I/II */ + out->ry = n.ry; } void netplay_ih_frame_to_pad(const RNetRbFrame *frame, PsxNetPad *pad) @@ -56,6 +58,8 @@ void netplay_ih_frame_to_pad(const RNetRbFrame *frame, PsxNetPad *pad) pad->lx = i8_to_u8_stick(frame->stick_x); pad->ly = i8_to_u8_stick(frame->stick_y); pad->analog = frame->analog <= PSX_NETPAD_TYPE_MAX ? frame->analog : 0u; + pad->rx = frame->rx; + pad->ry = frame->ry; psx_netplay_normalize_pad(pad); } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 1b2b2785..a49ddd59 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -2501,7 +2501,8 @@ static void np_publish_hist_sio(uint32_t tick) } static void np_rb_apply_frame_slot(int slot, uint32_t tick, uint16_t buttons, - int8_t sx, int8_t sy, uint8_t analog) + int8_t sx, int8_t sy, uint8_t analog, + uint8_t rx, uint8_t ry) { RNetRbFrame row; PsxNetPad pad; @@ -2511,6 +2512,8 @@ static void np_rb_apply_frame_slot(int slot, uint32_t tick, uint16_t buttons, row.stick_x = sx; row.stick_y = sy; row.analog = analog <= PSX_NETPAD_TYPE_MAX ? analog : 0u; + row.rx = rx; + row.ry = ry; row.is_valid = 1; netplay_ih_frame_to_pad(&row, &pad); force_session_pads_connected(g_np.slot_count); @@ -2613,7 +2616,8 @@ static void np_scrub_ahead_predicted(int slot, rnet_u32 release_tick, continue; if (row.buttons == released->buttons && row.stick_x == released->stick_x && - row.stick_y == released->stick_y) + row.stick_y == released->stick_y && + row.rx == released->rx && row.ry == released->ry) continue; scrub = *released; scrub.tick = t; @@ -2674,7 +2678,8 @@ static void np_tip_hold_coalesce_ahead(void) netplay_ih_pad_to_frame(&pad, t, 0, &wire_frame); if (wire_frame.buttons == tip_row.buttons && wire_frame.stick_x == tip_row.stick_x && - wire_frame.stick_y == tip_row.stick_y) + wire_frame.stick_y == tip_row.stick_y && + wire_frame.rx == tip_row.rx && wire_frame.ry == tip_row.ry) continue; edge = t; break; @@ -2862,6 +2867,7 @@ static void np_rollback_reconcile_wire(void) rnet_u32 wire; int pads_differ; int buttons_differ; + int rest_differ; if (!netplay_ih_get(&g_np.ih, slot, t, &published)) continue; @@ -2877,7 +2883,11 @@ static void np_rollback_reconcile_wire(void) netplay_ih_frame_to_contract(&published, &pub_c); netplay_ih_frame_to_contract(&wire_frame, &wire_c); buttons_differ = (pub_c.buttons != wire_c.buttons); - pads_differ = buttons_differ || + /* The rest of the pad (right stick, NeGcon I/II) is not in the + * contract view; any difference there is a real mispredict. */ + rest_differ = published.rx != wire_frame.rx || + published.ry != wire_frame.ry; + pads_differ = buttons_differ || rest_differ || (pub_c.stick_x != wire_c.stick_x) || (pub_c.stick_y != wire_c.stick_y); completed = (sim > t) ? 1u : 0u; @@ -2888,7 +2898,7 @@ static void np_rollback_reconcile_wire(void) * release) without opening an episode. Distinct from the old * ungated menu soft-promote that forked RAM: HC fail-closed * means a real state-affecting press still rewinds. */ - if (buttons_differ && completed && + if (buttons_differ && !rest_differ && completed && np_hc_silent_promote_enabled() && netplay_hc_confirm_through(&g_np.hc, t)) { (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); @@ -2937,6 +2947,8 @@ static void np_rollback_reconcile_wire(void) gate_ctx.tick = t; d = rnet_input_contract_stick_replace_decide( &pub_c, &wire_c, completed, ¶ms, &gates); + if (rest_differ && completed) + d = nRNetInputContractRewind; if (rnet_input_contract_decision_is_rewind(d)) { /* FMV/settle only: soft-promote releases (skip must rewind on * press). Menu soft-promote + hold-last invent forked RAM diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index e08de2cc..a424dbff 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -1482,7 +1482,8 @@ static void dump_post_diverge_diag(const char *why) hist.is_predicted ? "P" : ""); if (have_s && (seal.buttons != hist.buttons || seal.stick_x != hist.stick_x || - seal.stick_y != hist.stick_y)) + seal.stick_y != hist.stick_y || + seal.rx != hist.rx || seal.ry != hist.ry)) fprintf(stderr, "!"); } else { fprintf(stderr, "/h=----"); @@ -4170,7 +4171,7 @@ static void publish_sealed_sio(uint32_t tick) if (!rnet_rb_get_sealed_frame(g_rb, slot, tick, &row) || !row.is_valid) continue; g_b.apply_frame_slot(slot, tick, row.buttons, row.stick_x, row.stick_y, - row.analog); + row.analog, row.rx, row.ry); } }