netplay: input transforms stay per player online; rollback rows carry rx/ry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-06 19:37:35 -07:00
parent d54bf48068
commit 6f62f560bf
10 changed files with 124 additions and 11 deletions
+5
View File
@@ -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<Plugin> plugins;
struct Resource {
+3
View File
@@ -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<std::string> mod_runtime_netplay_view_features(const ModResolution& plan);
const std::string& mod_runtime_fingerprint();
+2 -1
View File
@@ -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);
+18
View File
@@ -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);
+58
View File
@@ -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(&lt, &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. */
+5 -2
View File
@@ -2540,10 +2540,12 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
{
const std::string netplay =
toml::find_or<std::string>(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) {
+9 -1
View File
@@ -1409,7 +1409,7 @@ std::vector<std::string> 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<std::string> 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();
+4
View File
@@ -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);
}
+17 -5
View File
@@ -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, &params, &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
+3 -2
View File
@@ -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);
}
}