Add opt-in netplay content negotiation with a backward-compatible LAN protocol

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-10-05 20:30:19 -07:00
parent d7fb33eb85
commit 66482a7cd8
27 changed files with 1873 additions and 92 deletions
+13 -3
View File
@@ -1052,7 +1052,14 @@ path (UI boxes; none seen in R4's 3D); a PGXP depth buffer; savestates of the
hook and base flavors are not interchangeable; above native resolution the GL
native pack comes from the corrected high-resolution surface, so a title that
reads VRAM back to the CPU would see it (R4 issued no GP0 C0 read from boot
through a 2P race).
through a 2P race). Vigilante 8: 2nd Offense does: in battle it reads one
VRAM pixel with GP0 C0 about every third frame (`c0_history`, now an
always-on ring), and the reads that land in the drawn frame return the
corrected pixels, so PGXP on and off write different RAM values (same PC
path and cycles; measured over 3000 frames from one savestate). Netplay
therefore draws its CPU-authoritative VRAM faithfully
(`sw_set_faithful_authority`: no PGXP override, no texture filter), so
peers agree whatever their shadows or Display filter.
### G1.12 — Precise culling, the mod as a title's one switch (2026-10-01)
@@ -1083,8 +1090,11 @@ DuckStation's "PGXP culling" with a minimal footprint.
This changes guest-visible MAC0, and so the game's control flow, which is
why it is opt-in at three levels:
- only the mod arms it, so netplay, which clears every mod, never runs it,
and there is deliberately no `[video]` key for it;
- only the mod arms it, and there is deliberately no `[video]` key for it;
- a netplay session never arms it (`PSXPgxpSessionConfig.netplay`), even
when the published netplay plan carries the mod (content negotiation,
MOD_PACKAGES.md) and even from `PSX_PGXP_CULLING`: the shadows it reads
are host-only, and a rollback load drops them on one peer only;
- it needs geometry correction (`psx_pgxp_session_resolve`);
- it is held off in every pass that is compared against another execution:
the speculative and replay passes, which record no shadows, and the whole
+25 -3
View File
@@ -23,6 +23,25 @@ appears when the host advertises `GameInfo.netplay_supported`.
## Rollback netcode
Titles that opt in with `[netplay] content_negotiation = true` (game.toml,
default false) get content negotiation; every other title keeps vanilla
netplay (mods cleared), the MOTK1 LAN protocol and plan-free lobby caps.
Online rooms can publish a host-selected mod plan: package versions, enabled
features/options and a portable plan fingerprint. Every peer resolves and
verifies that plan locally before launch. External media is allowed only when
the manifest declares its exact size and SHA-256 and resolution creates a
verified immutable byte snapshot; path-only resources and folders are refused.
Paths and donor bytes are never exchanged. Local bindings may differ, but the
verified content must match. The guest's offline selection is restored on return
to the launcher and is not overwritten by the session. LAN / Direct IP retains
the vanilla-content policy.
The read-only debug command `netplay_status` reports active mode, player count,
simulation tick, matched-state watermark, admission stall and input desync state.
Headless peers accept the usual injected local pad inputs through the same
session-slot routing as windowed peers.
Default match mode for opted-in titles is **rollback** (GGPO-style tip
prediction + resimulation), with **delay-sync** still available as an opt-out
(“Disable Rollback” in lobby settings, or `PSX_NET_MODE=delay`).
@@ -298,9 +317,12 @@ Generate & rebuild / prepare flows should point at the **`.cue`**, not a lone
netplay.
- **VERSION / lobby match pin:** peers should run the same release pin so
generated code and protocol stay compatible.
- **Mods:** disabled for all netplay sessions (lobby / LAN / direct / rematch).
Launcher `commit_netplay` and the runtime clear the in-session plan without
touching the user's offline mod selection. Synced mod plans are deferred.
- **Mods:** disabled for netplay sessions unless the title sets
`[netplay] content_negotiation = true`. With it, online and LAN lobbies publish the host's portable verified plan.
Each peer prepares its own required media, then compares the complete content
fingerprint before entering guest execution. Direct launches retain local
choices and require matching plans. No assets or local paths are transferred;
the guest's saved offline selection survives the session.
---
+93
View File
@@ -756,6 +756,66 @@ on a fixed region -> next.
Ledger `recompiler/tests/test_segment_aware_codegen.py` (synthetic KUSEG EXE,
Beetle fetch transcription vs psx_icache.c): 10 known gaps, model checks exact.
Not a live Beetle run (no oracle binary on the Mac); §7.2 lists the oracle runs.
- **2026-10-02 (Phase 5 V8:2 enhanced netplay/HFR):** Owner requested the
single-player enhanced renderer in multiplayer, fixed 16:9/21:9, and the
high-frame-rate branch in the combined mods build, including true netplay
render passes (`beads-eio.12.14`, `beads-eio.3.232`). Isolated integration
worktree `_wt-v82-enhanced-net-fw` preserves the modern core and prior LAN
content changes. Forward-netplay passes require explicit title opt-in and
skip resimulation; dual-raster pass draws bypass software authoritative VRAM.
Native local wide views now use the direct GL presenter and both Y buffers.
Split dividers remain canonical-only; full local surfaces avoid copying
canonical seam columns back into the expanded view. Nine focused framework
tests pass, including both buffer bands/ratios and netplay pass gates.
Paired Meteor captures show the same local car in native and intermediate
frames; 2,023 passes verified with zero machine-state mismatches or failures.
Fresh 21:9 Meteor Versus: 743 additional passes, zero verify mismatches,
confirmed ticks through 24,904; all 682 compared core checkpoints matched
through tick 24,896. Owner accepted the playtest and deferred the observed
intermediate-frame HUD identity/position jitter until after v0.1.0.
The visible pair uses 144 Hz target with costly verification disabled.
No simulation cadence or generated instruction semantics changed; the title
regenerated one shard to add guarded draw-boundary callbacks.
- **2026-10-02 (V8:2 private release, beads-eio.12.15):** Production builds
disable debug tooling. Added the missing no-op GP0 capture-freeze accessor
for that build and resolved imported SDL target aliases in header probes
for Ubuntu CMake 3.28. Regenerated both BIOS profiles with the current
emitter: generated C is unchanged; fingerprints now match. Windows and
Linux title builds pass all 21 checks; each overlay cache compiled 59
native shards without failures. Game media, captures and personal state
remain outside source and player packages. No guest timing changes.
- **2026-10-02 (Phase 5 V8:2 LAN content):** Resumed interrupted mod sync
work under `beads-eio.3.218`. LAN carries the host's verified portable plan
in settings and start messages. Media preparation precedes guest fingerprint
verification; direct peers compare all SHA-256 words before execution.
Offline choices remain intact. Five focused framework checks pass. Owner
validation isolated a separate missing simultaneous two-player carousel in
the title; its new hooks expose original cars to both players. Owner verified
both original cars load into a match, correct selection voices and imported
music work, and the latest offline Meteor Crater view looks fine. All 21
title checks pass with the owner package fixture.
No faithful timing-core changes or generated-code edits.
- **2026-10-01 (Phase 5 V8:2 music and widescreen):** Owner resumed original
soundtrack selection/rotation and adaptive native-wide rendering through
32:9 (`beads-eio.12.8`, `beads-eio.12.7`). Preserve the uncommitted netplay
implementation; keep native 1x as the performance default. Adapt title
render/culling hooks from the original V8 mod with sequel instruction guards.
Donor audio and all converted media remain external. No timing-core change.
- **2026-10-01 (Phase 5 combined V8 netplay):** Owner resumed two-player
netplay only (`beads-eio.12.6`); widescreen and original music remain parked.
`beads-eio.3.218` ports Toshinden's generic online mod-plan negotiation and
admits only immutable, size/SHA-256-verified external resources. Host plan
mismatches fail before replacement; offline selections survive session exit.
Read-only `netplay_status` exposes the existing hash-confirm watermark.
Headless netplay now accepts injected local input and honors controller mode.
Two isolated peers reached native V8:2 Versus gameplay, independently drove
both cars and matched state hashes through tick 43944. Online combined-content
play and WAN conditions remain for human validation. No timing/codegen change.
- **2026-09-30 (Phase 5 owner-requested source checkpoint):** Preserve the
existing V8:2 combined-content title and generic callback/disc-extent support
on local `checkpoint/v82-combined-20260930` branches. Source, tests and
@@ -1881,3 +1941,36 @@ on a fixed region -> next.
`PSX_PRECISE_SLICE` left in tree (inert). −8 mechanism located in
code_generator.cpp (delay-slot-is-leader undercount). Tree builds + boots clean.
NEXT: P1 (cycle-audit) → P2 (delay-slot ownership fix).
### 2026-10-01 ? Shared owner-media setup (beads-eio.3.220)
Format 9 adds shared launcher source bindings, hidden derived resources, and
linked trusted preparers at offline/online commit. Preparation inherits the
main disc and user cache; output binding is transactional and the existing
size/SHA snapshot checks remain authoritative. Shared-picker/reload, disabled
preparation, inherited disc, failure preservation and corrupt-output checks
pass with mod_packages_test and mod_runtime_test. V8:2 supplies its own bundled
conversion worker; no title asset rules enter the framework. V8:2 terrain-ray
arithmetic is an enhancement in the title plugin. Owner live game untouched.
### 2026-10-02: V82 extra-frame HUD replay investigation
V82 split rendering interleaves world(0)/HUD(1), then world(1)/HUD(0).
Replaying a complete split section pairs the local world with the remote HUD.
The title now captures both HUD CPU contexts and replays the matching HUD after
its local world. This exposed a framework contract defect: forced replay could
hand straight-line flow into a compiled continuation before checking its stop
PC. Forced spans now retain interpreter ownership through the boundary instead
of entering that continuation. Normal native execution and callee dispatch
remain unchanged. Actual LAN replay verification has passed thousands of spans
with no state mismatch, watchdog or VRAM leak. Consecutive presentation captures
confirmed stable local HUDs at fixed 16:9 and 21:9, while a comparison build
reproduced both swapping icons and shifting bounds. Single-player Meteor Crater
was also entered through the menus and produced verified extra frames.
The presented-image ring now assigns each swap a unique sequence ID.
Guest-frame lookup merged multiple extra presentations and could not verify
HUD stability. Added duplicate-frame/freeze/eviction regression coverage and
retained legacy frame lookup. Six focused framework checks and all 21 title
checks pass on Windows and Linux, and both runtime builds link successfully.
+1 -1
View File
@@ -1 +1 @@
9c92d6060348bc2478abb2370e9c9142ee9c0d1d0ec8dc05d9be98177545e6e2
13265e961396ac707b39701fc5787ae62c34e9c92c4b8b7fe3ea69e1cc86791e
+1 -1
View File
@@ -1 +1 @@
26c707e2203f6886cc6c73098d2072fc811b278f55521fa0e945fa30b2def17f
2b30598a6f3db8c1a0c03eaa1f121d241fa53bd69e789ae202caf4d51ebe7ab6
+4
View File
@@ -1447,8 +1447,11 @@ GameConfig load_game_config(const fs::path& config_path_in) {
std::vector<uint32_t> netplay_local_viewport_width_sites;
uint32_t netplay_local_viewport_state_addr = 0;
std::vector<uint32_t> netplay_local_viewport_state_values;
bool netplay_content_negotiation = false;
if (cfg.contains("netplay")) {
const toml::value& np = toml::find(cfg, "netplay");
if (np.contains("content_negotiation"))
netplay_content_negotiation = toml::find<bool>(np, "content_negotiation");
if (np.contains("require_cue"))
netplay_require_cue = toml::find<bool>(np, "require_cue");
if (np.contains("required_tracks"))
@@ -2462,6 +2465,7 @@ GameConfig load_game_config(const fs::path& config_path_in) {
/*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,
/*netplay_content_negotiation*/ netplay_content_negotiation,
/*seeds_path*/ seeds_path,
/*bios_thunks_path*/ bios_thunks_path,
/*bios_config_path*/ bios_config_path,
+6
View File
@@ -912,6 +912,12 @@ struct GameConfig {
// pause menu drawn across both halves, every peer sees the whole frame.
uint32_t netplay_local_viewport_state_addr = 0;
std::vector<uint32_t> netplay_local_viewport_state_values;
// [netplay] content_negotiation: opt in to host-published mod plans in
// online/LAN lobbies, full-plan content agreement before LAN/direct guest
// execution, the MOTK6 LAN plan messages and headless injected-pad
// routing. Off (default): netplay clears every mod, and the LAN wire
// protocol and lobby messages stay unchanged.
bool netplay_content_negotiation = false;
// [recompiler] block
std::filesystem::path seeds_path; // absolute path to seeds (text or json)
+13
View File
@@ -812,6 +812,10 @@ if(BUILD_TESTING)
../recompiler/lib/toml11)
add_test(NAME text_xlate_fix_only_test COMMAND text_xlate_fix_only_test)
add_executable(netplay_content_gate_test tests/test_netplay_content_gate.c)
target_include_directories(netplay_content_gate_test PRIVATE include)
add_test(NAME netplay_content_gate_test COMMAND netplay_content_gate_test)
add_executable(mod_runtime_test
tests/test_mod_runtime.cpp
src/iso_reader_c.cpp
@@ -1045,6 +1049,15 @@ if(BUILD_TESTING)
target_include_directories(gpu_uv_test PRIVATE src)
add_test(NAME gpu_uv_test COMMAND gpu_uv_test)
# Netplay CPU-authoritative VRAM ignores PGXP overrides and texture
# filtering (sw_set_faithful_authority).
add_executable(sw_faithful_authority_test tests/test_sw_faithful_authority.c)
target_include_directories(sw_faithful_authority_test PRIVATE include src)
if(NOT WIN32)
target_link_libraries(sw_faithful_authority_test PRIVATE m)
endif()
add_test(NAME sw_faithful_authority_test COMMAND sw_faithful_authority_test)
add_executable(gpu_vk_upload_alignment_test
tests/test_gpu_vk_upload_alignment.c)
target_include_directories(gpu_vk_upload_alignment_test PRIVATE src)
+8
View File
@@ -42,6 +42,14 @@ uint32_t sw_perspective_triangle_count(void);
void sw_set_texture_filter(int bilinear);
int sw_texture_filter(void);
/* Netplay CPU-authoritative VRAM (dual-raster SW@1x or the software GPU):
* on = ignore the precise/perspective overrides and texture filtering, so the
* guest-visible VRAM (GP0 C0 readback, savestates) is the hardware image on
* every peer. The filter request is remembered and restored when it goes off.
* The GPU presentation surface keeps every enhancement. */
void sw_set_faithful_authority(int on);
int sw_faithful_authority(void);
/* Draw state — must be set by gpu.c before each primitive */
void sw_set_semi_transparency(int enabled, int mode);
void sw_set_mask_bits(int set_bit, int check_bit);
+20 -4
View File
@@ -18,14 +18,30 @@ bool mod_runtime_initialize(const std::filesystem::path& root,
const std::filesystem::path& exe_path = {},
std::string* error = nullptr);
bool mod_runtime_commit(const std::filesystem::path& disc_path = {},
std::string* error = nullptr);
std::string* error = nullptr,
bool save_selection = true);
/* Prepare enabled media without installing a guest plan or saving choices. */
bool mod_runtime_prepare_resources(const std::filesystem::path& disc_path,
std::string* error = nullptr);
/* Drop the in-session mod plan for a netplay launch without rewriting the
* user's persisted offline selection on disk. Netplay is always vanilla for
* now (no synced mod plans). */
/* Apply a host-published online/LAN plan without rewriting persisted offline
* choices. An explicit empty host plan means vanilla; direct launches without
* a published plan retain local choices and require transport consensus. */
bool mod_runtime_commit_for_netplay(const std::filesystem::path& disc_path = {},
std::string* error = nullptr);
/* Direct/LAN: retain the locally selected verified plan. The transport must
* compare session_plan_fp() on every peer before executing the guest. */
bool mod_runtime_commit_for_direct_netplay(const std::filesystem::path& disc_path,
std::string* error = nullptr);
bool mod_runtime_clear_for_netplay(std::string* error = nullptr);
/* [netplay] content_negotiation from game.toml. Off (default): the launcher's
* netplay commit clears the plan exactly like mod_runtime_clear_for_netplay. */
void mod_runtime_set_netplay_content_negotiation(bool enabled);
bool mod_runtime_netplay_content_negotiation();
/* Restore the pre-session selection before returning to the launcher. */
void mod_runtime_end_netplay();
void mod_runtime_set_session_plan_fp(const std::string& fp);
const std::string& mod_runtime_session_plan_fp();
std::string mod_runtime_plan_fingerprint_portable();
const std::string& mod_runtime_fingerprint();
const std::filesystem::path& mod_runtime_effective_disc_path();
/* Read an effective-disc file as whole sectors (true end-of-file tail bytes
+44
View File
@@ -0,0 +1,44 @@
#ifndef PSX_NETPLAY_CONTENT_GATE_H
#define PSX_NETPLAY_CONTENT_GATE_H
#include <stdint.h>
#include <string.h>
#define NP_CONTENT_MAGIC 0x504D4431u /* PMD1: full 256-bit plan, four fragments */
typedef struct NpContentGate {
uint32_t words[8], occupied, matched, acknowledged;
uint8_t chunks[9], enabled, mismatch, local;
} NpContentGate;
static int np_content_init(NpContentGate *g, const char *hex, uint32_t occupied, int local) {
unsigned i;
memset(g, 0, sizeof(*g));
if (!hex || !hex[0]) return 1;
if (strlen(hex) != 64 || local < 0 || local >= 9 || (occupied & ~0x1ffu)) return 0;
for (i=0;i<64;++i) {
unsigned c=(unsigned char)hex[i], v;
if(c>='0' && c<='9') v=c-'0';
else if(c>='a' && c<='f') v=c-'a'+10;
else if(c>='A' && c<='F') v=c-'A'+10;
else return 0;
g->words[i/8]=(g->words[i/8]<<4)|v;
}
g->enabled=1;g->local=(uint8_t)local;
g->occupied=occupied|(1u<<local);g->matched=1u<<local;
return 1;
}
static void np_content_note(NpContentGate *g, unsigned slot, unsigned flags,
uint32_t a, uint32_t b, uint32_t seen) {
unsigned chunk=flags&3u; uint32_t bit;
if(!g->enabled || slot>=9 || slot==g->local) return;
bit=1u<<slot;
if(!(g->occupied&bit)) return;
if((flags&0x80u) || a!=g->words[chunk*2] || b!=g->words[chunk*2+1]) {
g->mismatch=1;return;
}
g->chunks[slot]|=(uint8_t)(1u<<chunk);
if(g->chunks[slot]==15) g->matched|=bit;
if((seen&g->occupied)==g->occupied) g->acknowledged|=bit;
}
static int np_content_ready(const NpContentGate *g) {
return !g->enabled || (!g->mismatch && g->matched==g->occupied &&
((g->acknowledged|(1u<<g->local))&g->occupied)==g->occupied);
}
#endif
+8 -2
View File
@@ -32,7 +32,11 @@
* The mod arms both geometry and texture correction; its CPU-mode option adds
* tier-2 propagation and its culling option precise culling. Precise culling
* changes guest-visible NCLIP results, so it has no [video] key (netplay does
* not clear [video]); it needs geometry correction.
* not clear [video]); it needs geometry correction. A netplay session never
* arms it, whatever the mod, the env override or the plan say: a published
* netplay plan can carry the mod (content negotiation, MOD_PACKAGES.md), and
* the shadows it reads are host-only state that a rollback load drops on one
* peer and not the other, so the guest's NCLIP results would fork.
*/
#ifdef __cplusplus
@@ -51,6 +55,7 @@ typedef struct PSXPgxpSessionInputs {
int mod_cpu_mode;
int mod_culling;
int mod_only;
int netplay;
} PSXPgxpSessionInputs;
typedef struct PSXPgxpSessionArm {
@@ -78,7 +83,7 @@ static inline PSXPgxpSessionArm psx_pgxp_session_resolve(
arm.cpu_mode = psx_pgxp_session_pick(in->env_cpu_mode,
use_video && in->video_cpu_mode,
in->mod_enabled && in->mod_cpu_mode);
arm.culling = arm.geometry &&
arm.culling = !in->netplay && arm.geometry &&
psx_pgxp_session_pick(in->env_culling, 0,
in->mod_enabled && in->mod_culling);
return arm;
@@ -102,6 +107,7 @@ typedef struct PSXPgxpSessionConfig {
int position_fallback;
int preserve_projection;
int mod_only;
int netplay; /* this session is a netplay match: precise culling off */
} PSXPgxpSessionConfig;
/* Session start, before plugin activation: forget any mod request. */
+45
View File
@@ -29,6 +29,11 @@ extern "C" {
#define PSX_LOBBY_SPECTATOR_SLOT_BASE 64
#define PSX_LOBBY_MAX_LAN_EPS 4
#define PSX_LOBBY_LANG_LEN 16
#define PSX_LOBBY_MAX_MODS 12
#define PSX_LOBBY_MOD_ID_LEN 96
#define PSX_LOBBY_MOD_VER_LEN 32
#define PSX_LOBBY_MOD_NAME_LEN 64
#define PSX_LOBBY_MOD_FEATS_LEN 384
#ifndef PSX_GAME_VERSION
#define PSX_GAME_VERSION "dev"
@@ -79,6 +84,21 @@ typedef struct PsxLobbyOnlinePlayer {
} PsxLobbyOnlinePlayer;
#define PSX_LOBBY_MAX_ONLINE 64
typedef struct PsxLobbyModPkg {
char id[PSX_LOBBY_MOD_ID_LEN];
char ver[PSX_LOBBY_MOD_VER_LEN];
char name[PSX_LOBBY_MOD_NAME_LEN];
char feats[PSX_LOBBY_MOD_FEATS_LEN];
int builtin;
uint32_t size;
} PsxLobbyModPkg;
typedef struct PsxLobbyModOffer {
int valid;
int count;
PsxLobbyModPkg pkgs[PSX_LOBBY_MAX_MODS];
} PsxLobbyModOffer;
typedef struct PsxLobbyMember {
/* Seat index in the shared namespace: a player seat, or
* spectator_slot_base + gallery index. Pass it back to kick / move as-is. */
@@ -111,6 +131,10 @@ typedef struct PsxLobbyMember {
* (recomp-net-server WS_LOBBY.md "Host relay"). */
char path[8];
int path_fresh;
/* Peer mod catalog offer from join/set_ready (0 if legacy/missing). */
int mod_offer_valid;
int mod_count;
PsxLobbyModPkg mods[PSX_LOBBY_MAX_MODS];
} PsxLobbyMember;
/*
@@ -203,8 +227,18 @@ typedef struct PsxLobbyMatchCaps {
/* Retail match: CRC-32 of the image every peer boots. 0 = not sent (an
* older host); peers then accept any retail dump, as before. */
uint32_t session_bios_crc;
/* Host-authoritative online mod plan. Empty mod_plan_fp/mod_count means vanilla. */
char mod_plan_fp[72];
int mod_count;
PsxLobbyModPkg mods[PSX_LOBBY_MAX_MODS];
} PsxLobbyMatchCaps;
/* The same portable plan is used by WebSocket and LAN lobbies. No media paths
* or bytes are serialized. A failed/truncated message must never mean vanilla. */
int psx_lobby_encode_match_caps(char *out, size_t cap, const PsxLobbyMatchCaps *caps);
int psx_lobby_decode_match_caps(const char *json, PsxLobbyMatchCaps *out);
void psx_lobby_set_local_match_caps(const PsxLobbyMatchCaps *caps);
typedef struct PsxLobbyJoinInfo {
int ok;
char lobby_id[PSX_LOBBY_ID_LEN];
@@ -360,6 +394,17 @@ const PsxLobbyJoinInfo *psx_lobby_join_info(void);
/* Latest host match_caps (valid==0 until create/join/launch delivers one). */
const PsxLobbyMatchCaps *psx_lobby_match_caps(void);
/* Local mod package/version catalog advertised to the lobby on join. */
void psx_lobby_set_mod_offer(const PsxLobbyModOffer *offer);
const PsxLobbyModOffer *psx_lobby_mod_offer(void);
int psx_lobby_need_mods_count(void);
int psx_lobby_need_mods_get(int index, PsxLobbyModPkg *out);
int psx_lobby_need_mods_can_transfer(void);
int psx_lobby_mod_xfer_start(void);
void psx_lobby_mod_xfer_cancel(void);
int psx_lobby_mod_xfer_progress(void);
int psx_lobby_mod_xfer_failed(char *err, size_t err_cap);
/* Host: push updated caps while in lobby (clears ready via lobby_update). */
int psx_lobby_set_match_caps(const PsxLobbyMatchCaps *caps);
+8
View File
@@ -98,6 +98,9 @@ typedef struct PsxNetplayConfig {
* Must be identical on every peer (the lobby decides it at start).
* Env PSX_NET_GUEST_MEMCARD=1 overrides. */
int guest_memcard;
/* Nonempty for direct/LAN sessions: full portable mod-plan SHA-256.
* Gameplay waits for every occupied peer to agree. */
char content_fingerprint[65];
uint32_t session_id;
char bind_hostport[64];
char peer_hostport[64];
@@ -106,6 +109,9 @@ typedef struct PsxNetplayConfig {
void psx_netplay_config_defaults(PsxNetplayConfig *cfg);
void psx_netplay_apply_env(PsxNetplayConfig *cfg);
/* Internal RB_SYNC demultiplexing; returns 1 for a content identity packet. */
int psx_netplay_content_note(uint32_t epoch, uint32_t word0, uint32_t word1,
uint32_t seen, uint8_t slot, uint8_t op, uint8_t flags);
int psx_netplay_active(void);
int psx_netplay_is_running(void);
/* "ice" | "lan" | "none" */
@@ -115,6 +121,8 @@ int psx_netplay_ice_failed(void);
/* Optional JSONL samples when PSX_NET_DIAG=1 (saves/netplay/net_diag.jsonl). */
void psx_netplay_diag_tick(void);
int psx_netplay_local_slot(void);
/* Guest controller port of this peer; differs from session slot after seat swaps. */
int psx_netplay_local_port(void);
/* 1 while this build is watching rather than playing. */
int psx_netplay_is_spectator(void);
/* 1 when the local peer is the host running the match from the gallery. */
+32 -4
View File
@@ -99,6 +99,9 @@ static int g_raw_texture; /* 1 = skip modulation */
/* Texture filtering: 0 = nearest (native PSX), 1 = bilinear. */
static int g_texture_filter = 0;
/* The player's filter request, applied unless faithful authority is on. */
static int g_texture_filter_req = 0;
static int g_faithful_authority = 0; /* see sw_set_faithful_authority */
/* ------------------------------------------------------------------ */
/* Helpers */
@@ -475,6 +478,7 @@ void sw_renderer_init(uint16_t *vram) {
g_mod_r = g_mod_g = g_mod_b = 16; /* neutral = 128/8 = 16 (no modulation) */
g_raw_texture = 0;
g_texture_filter = 0;
g_texture_filter_req = 0;
}
/* ------------------------------------------------------------------ */
@@ -509,11 +513,29 @@ void sw_renderer_set_scale(int scale) {
int sw_renderer_scale(void) { return g_scale; }
/* Faithful authority (netplay): while this rasterizer writes the guest's
* authoritative VRAM -- the netplay dual-raster SW@1x mirror or the netplay
* software GPU -- it draws exactly what hardware draws. The PGXP overrides and
* texture filtering are presentation enhancements; here they would put host-
* only state (PGXP shadows, which a rollback load drops on one peer only, and
* each player's Display filter) into pixels the game can read back with GP0
* C0 (V8:2 samples its own frame), forking guest RAM between peers. */
void sw_set_faithful_authority(int on) {
g_faithful_authority = on ? 1 : 0;
g_texture_filter = g_faithful_authority ? 0 : g_texture_filter_req;
if (g_faithful_authority) {
g_precise_valid = 0;
g_perspective_valid = 0;
}
}
int sw_faithful_authority(void) { return g_faithful_authority; }
void sw_set_precise_triangle(int enabled,
int32_t x0, int32_t y0,
int32_t x1, int32_t y1,
int32_t x2, int32_t y2) {
g_precise_valid = enabled ? 1 : 0;
g_precise_valid = (enabled && !g_faithful_authority) ? 1 : 0;
g_precise_x16[0] = x0; g_precise_y16[0] = y0;
g_precise_x16[1] = x1; g_precise_y16[1] = y1;
g_precise_x16[2] = x2; g_precise_y16[2] = y2;
@@ -521,11 +543,14 @@ void sw_set_precise_triangle(int enabled,
void sw_set_perspective_triangle(int enabled,
float q0, float q1, float q2) {
g_perspective_valid = enabled && q0 > 0.0f && q1 > 0.0f && q2 > 0.0f;
const int requested = enabled && q0 > 0.0f && q1 > 0.0f && q2 > 0.0f;
g_perspective_valid = requested && !g_faithful_authority;
g_perspective_q[0] = q0;
g_perspective_q[1] = q1;
g_perspective_q[2] = q2;
if (g_perspective_valid) g_perspective_triangles++;
/* Counts requests: under faithful authority the GPU presentation surface
* still draws them perspective-correct. */
if (requested) g_perspective_triangles++;
}
uint32_t sw_perspective_triangle_count(void) {
@@ -552,7 +577,10 @@ static inline void precise_consumed(void) {
g_perspective_valid = 0;
}
void sw_set_texture_filter(int bilinear) { g_texture_filter = bilinear ? 1 : 0; }
void sw_set_texture_filter(int bilinear) {
g_texture_filter_req = bilinear ? 1 : 0;
g_texture_filter = g_faithful_authority ? 0 : g_texture_filter_req;
}
int sw_texture_filter(void) { return g_texture_filter; }
/* ------------------------------------------------------------------ */
+517 -26
View File
@@ -1475,6 +1475,24 @@ static bool ensure_sdl_pixel_buf_capacity(size_t pixels) {
sdl_pixel_buf_capacity = pixels;
return true;
}
/* [netplay] content_negotiation (opt-in, default off). Off keeps every netplay
* session vanilla (mods cleared), the LAN MOTK1 wire protocol, lobby caps
* without mod fields, and the neutral headless idle pad. */
static int g_netplay_content_negotiation = 0;
/* Install the netplay session's mod plan without touching the persisted
* offline selection. Default: vanilla (clear every mod). Opted in: the
* host-published lobby plan, or the locally selected verified plan for a
* direct/LAN session without published caps (peers then compare its
* fingerprint before guest execution). */
static bool netplay_commit_mods(const std::filesystem::path& disc,
std::string* error) {
if (!g_netplay_content_negotiation)
return PSXRecompV4::mod_runtime_clear_for_netplay(error);
const PsxLobbyMatchCaps* caps = psx_lobby_match_caps();
return (!caps || !caps->valid)
? PSXRecompV4::mod_runtime_commit_for_direct_netplay(disc, error)
: PSXRecompV4::mod_runtime_commit_for_netplay(disc, error);
}
/* 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. */
@@ -2311,6 +2329,10 @@ static int ensure_sw_sdl_present(void) {
* hr FBO is present-only (g_gl_fbo_present=1, never glReadPixels). Fallback
* when no GL context: pure software SDL present at scale 1. */
extern "C" void psx_frontend_netplay_force_sw_gpu(void) {
/* Every branch below makes the CPU rasterizer the guest's VRAM authority,
* so it draws the hardware image: no PGXP override, no texture filter
* (sw_set_faithful_authority). Presentation keeps them where GL presents. */
sw_set_faithful_authority(1);
#ifndef PSX_SDL_NO_RENDER
/* Already locked on dual-raster GL quality present. */
if (s_netplay_sw_gpu_locked && netplay_gl_dual_quality()) {
@@ -3269,7 +3291,7 @@ static void apply_netplay_local_viewport_aspect(bool netplay_enabled) {
return;
}
gpu_ws_set_netplay_local_viewport(1, psx_netplay_local_slot());
gpu_ws_set_netplay_local_viewport(1, psx_netplay_local_port());
switch (g_netplay_local_viewport_aspect) {
case 1:
(void)psx_mod_set_fixed_display_aspect(16u, 9u);
@@ -3788,6 +3810,7 @@ static void teardown_game_session_keep_lobby(void) {
s_netplay_gl_present = 0;
s_netplay_sim_native_scale = 0;
gl_renderer_set_cpu_auth_dual(0);
sw_set_faithful_authority(0);
psx_lobby_set_ready(0);
psx_lobby_clear_launch_pending();
#if defined(PSX_HAS_LOBBY_CLIENT)
@@ -6042,7 +6065,7 @@ static int netplay_local_viewport_slot(void) {
return -1;
if (!gpu_last_frame_vertical_split_screen())
return -1;
int slot = psx_netplay_local_slot();
int slot = psx_netplay_local_port();
return (slot == 0 || slot == 1) ? slot : -1;
}
@@ -6237,9 +6260,12 @@ static void netplay_barrier_admit(int override) {
/* An explicit debug-server override is the local player's input
* windowed or headless: headless LAN peers are how netplay is
* exercised without windows. With no override, headless has no
* human device and plays a neutral pad. */
if (override >= 0) {
capture_override_pad(override, &local);
* human device and plays a neutral pad -- unless the title opted
* into content negotiation, whose headless fixtures use the same
* negotiated pad mode and local-to-session routing as windowed
* injected input. */
if (override >= 0 || (g_headless && g_netplay_content_negotiation)) {
capture_override_pad(override >= 0 ? override : 0xFFFF, &local);
} else if (g_headless) {
local.buttons = 0xFFFFu;
local.lx = local.ly = local.rx = local.ry = 0x80u;
@@ -8633,6 +8659,7 @@ static PSXRecompV4::NetplayDiscExpect netplay_expect_for_disc(
return e;
}
static std::string g_session_disc_fp;
static std::filesystem::path g_launcher_disc_path;
static bool g_session_netplay_disc_ok = false;
#if defined(RECOMP_LAUNCHER)
@@ -8934,6 +8961,7 @@ namespace {
int ae_disc_verify(const char* disc_path, RecompLauncherCDiscVerify* out) {
if (!disc_path || !disc_path[0] || !out) return 0;
g_launcher_disc_path = disc_path;
std::memset(out, 0, sizeof(*out));
/* Which serial THIS disc should carry -- the set's per-disc value, or
* the game's own for a single-disc title. The expected CRC is disc
@@ -9099,6 +9127,7 @@ namespace {
bool g_lnch_remote_lan = false;
std::string g_lnch_lan_endpoint;
uint32_t g_lnch_lan_session_id = 1;
uint32_t g_lnch_lan_start_plan_session = 0;
static constexpr int kAeLanMaxSlots = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS;
struct AeLanLobbyState {
@@ -9114,6 +9143,7 @@ namespace {
uint32_t session_id = 1;
std::string slot_name[kAeLanMaxSlots];
std::string slot_id[kAeLanMaxSlots]; /* stable per-seat player id */
std::string mod_caps_json; /* host plan; local media stays on each peer */
};
AeLanLobbyState g_lnch_remote_lan_state{};
int g_lnch_lan_my_slot = -1;
@@ -9889,6 +9919,7 @@ namespace {
state->slot_name[guest] = state->joiner_name;
}
ae_np_lan_sync_legacy_names(*state);
std::getline(f, state->mod_caps_json);
return !state->endpoint.empty();
}
@@ -9916,6 +9947,8 @@ namespace {
f << state.slot_name[i] << "\n";
for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i)
f << state.slot_id[i] << "\n";
if (!state.mod_caps_json.empty())
f << state.mod_caps_json << "\n";
return (bool)f;
}
@@ -9976,6 +10009,11 @@ namespace {
if (off3 <= 0 && off4 <= 0 && off5 <= 0) return;
for (int i = 0; i < kAeLanMaxSlots; ++i) {
if (!g_lnch_lan_peer_ok[i]) continue;
if (!state.mod_caps_json.empty()) {
const std::string plan = "MOTK6 MODS\n" +
std::to_string(state.session_id) + "\n" + state.mod_caps_json;
ae_np_lan_udp_sendto(g_lnch_lan_peers[i], plan.c_str());
}
if (off3 > 0) ae_np_lan_udp_sendto(g_lnch_lan_peers[i], msg3);
if (off4 > 0) ae_np_lan_udp_sendto(g_lnch_lan_peers[i], msg4);
if (off5 > 0) ae_np_lan_udp_sendto(g_lnch_lan_peers[i], msg5);
@@ -10929,6 +10967,216 @@ namespace {
return 0;
}
static std::string g_lnch_mod_plan_error;
static void ae_copy_lobby_text(char* out, size_t cap, const char* in) {
if (!out || cap == 0) return;
std::snprintf(out, cap, "%s", in ? in : "");
}
static const RecompLauncherCModProvider* ae_mod_provider(void) {
return PSXRecompV4::mod_runtime_launcher_provider();
}
static int ae_mod_package_get_by_id(const char* id,
RecompLauncherCModPackage* out) {
const RecompLauncherCModProvider* mods = ae_mod_provider();
if (!mods || !mods->package_count || !mods->package_get || !id || !id[0])
return 0;
const int count = mods->package_count(mods->ctx);
for (int i = 0; i < count; ++i) {
RecompLauncherCModPackage pkg{};
if (!mods->package_get(mods->ctx, i, &pkg)) continue;
if (std::strcmp(pkg.id, id) != 0) continue;
if (out) *out = pkg;
return 1;
}
return 0;
}
static int ae_mod_pkg_installed(const PsxLobbyModPkg* need,
char* reason, size_t reason_cap) {
const RecompLauncherCModProvider* mods = ae_mod_provider();
RecompLauncherCModPackage pkg{};
if (reason && reason_cap) reason[0] = '\0';
if (!need || !need->id[0]) return 0;
if (!ae_mod_package_get_by_id(need->id, &pkg)) {
ae_copy_lobby_text(reason, reason_cap, "not installed");
return 0;
}
if (need->ver[0] && std::strcmp(pkg.version, need->ver) != 0) {
int found_version = 0;
if (mods && mods->version_count && mods->version_get) {
const int versions = mods->version_count(mods->ctx, need->id);
for (int i = 0; i < versions; ++i) {
RecompLauncherCModVersion ver{};
if (mods->version_get(mods->ctx, need->id, i, &ver) &&
std::strcmp(ver.version, need->ver) == 0) {
found_version = 1;
break;
}
}
}
if (!found_version) {
ae_copy_lobby_text(reason, reason_cap, "version mismatch");
return 0;
}
}
if (pkg.has_error) {
ae_copy_lobby_text(reason, reason_cap,
pkg.status[0] ? pkg.status
: "does not match your game image");
return 0;
}
return 1;
}
static void ae_fill_lobby_mod_pkg_from_package(PsxLobbyModPkg* out,
const RecompLauncherCModPackage& pkg) {
if (!out) return;
std::memset(out, 0, sizeof(*out));
ae_copy_lobby_text(out->id, sizeof(out->id), pkg.id);
ae_copy_lobby_text(out->ver, sizeof(out->ver), pkg.version);
ae_copy_lobby_text(out->name, sizeof(out->name), pkg.name);
out->builtin = pkg.removable ? 0 : 1;
out->size = 0;
}
static void ae_np_refresh_mod_offer(void) {
if (!g_netplay_content_negotiation) return;
const RecompLauncherCModProvider* mods = ae_mod_provider();
PsxLobbyModOffer offer{};
if (!mods || !mods->package_count || !mods->package_get) {
psx_lobby_set_mod_offer(nullptr);
return;
}
offer.valid = 1;
const int count = mods->package_count(mods->ctx);
for (int i = 0; i < count && offer.count < PSX_LOBBY_MAX_MODS; ++i) {
RecompLauncherCModPackage pkg{};
if (!mods->package_get(mods->ctx, i, &pkg) || !pkg.id[0]) continue;
if (mods->version_count && mods->version_get) {
const int versions = mods->version_count(mods->ctx, pkg.id);
for (int v = 0; v < versions && offer.count < PSX_LOBBY_MAX_MODS; ++v) {
RecompLauncherCModVersion ver{};
if (!mods->version_get(mods->ctx, pkg.id, v, &ver) ||
!ver.version[0])
continue;
ae_fill_lobby_mod_pkg_from_package(&offer.pkgs[offer.count], pkg);
ae_copy_lobby_text(offer.pkgs[offer.count].ver,
sizeof(offer.pkgs[offer.count].ver),
ver.version);
offer.pkgs[offer.count].builtin = ver.removable ? 0 : 1;
offer.count++;
}
} else {
ae_fill_lobby_mod_pkg_from_package(&offer.pkgs[offer.count], pkg);
offer.count++;
}
}
psx_lobby_set_mod_offer(&offer);
}
static void ae_append_feat_token(char* out, size_t cap, const char* token) {
if (!out || cap == 0 || !token || !token[0]) return;
const size_t len = std::strlen(out);
if (len >= cap - 1) return;
std::snprintf(out + len, cap - len, "%s%s", len ? "," : "", token);
}
static void ae_append_feature_options(char* token, size_t cap,
const RecompLauncherCModProvider* mods,
const RecompLauncherCModFeature& feature) {
if (!token || cap == 0 || !mods || !mods->feature_option_get)
return;
const size_t base_len = std::strlen(token);
size_t len = base_len;
for (int i = 0; i < feature.option_count; ++i) {
RecompLauncherCModOption opt{};
if (!mods->feature_option_get(mods->ctx, feature.package_id,
feature.id, i, &opt))
continue;
if (!opt.id[0]) continue;
const char* sep = (len == base_len) ? "=" : "+";
const int wrote = std::snprintf(token + len, len < cap ? cap - len : 0,
"%s%s~%s", sep, opt.id, opt.value);
if (wrote < 0 || (size_t)wrote >= (len < cap ? cap - len : 0)) {
token[base_len] = '\0';
return;
}
len += (size_t)wrote;
}
}
static int ae_np_fill_caps_mod_plan(PsxLobbyMatchCaps* caps) {
const RecompLauncherCModProvider* mods = ae_mod_provider();
g_lnch_mod_plan_error.clear();
if (!caps) {
g_lnch_mod_plan_error = "missing match caps";
return 0;
}
caps->mod_plan_fp[0] = '\0';
caps->mod_count = 0;
std::memset(caps->mods, 0, sizeof(caps->mods));
if (!PSXRecompV4::mod_runtime_prepare_resources(g_launcher_disc_path,
&g_lnch_mod_plan_error)) return 0;
if (!mods || !mods->package_count || !mods->package_get)
return 1;
if (mods->feature_count && mods->feature_get) {
const int feature_count = mods->feature_count(mods->ctx);
for (int i = 0; i < feature_count; ++i) {
RecompLauncherCModFeature feature{};
if (!mods->feature_get(mods->ctx, i, &feature) || !feature.enabled)
continue;
int pkg_index = -1;
for (int j = 0; j < caps->mod_count; ++j) {
if (std::strcmp(caps->mods[j].id, feature.package_id) == 0) {
pkg_index = j;
break;
}
}
if (pkg_index < 0) {
if (caps->mod_count >= PSX_LOBBY_MAX_MODS) {
g_lnch_mod_plan_error = "too many enabled mods for lobby caps";
return 0;
}
RecompLauncherCModPackage pkg{};
if (!ae_mod_package_get_by_id(feature.package_id, &pkg))
continue;
pkg_index = caps->mod_count++;
ae_fill_lobby_mod_pkg_from_package(&caps->mods[pkg_index], pkg);
}
char token[PSX_LOBBY_MOD_FEATS_LEN] = {};
ae_copy_lobby_text(token, sizeof(token), feature.id);
ae_append_feature_options(token, sizeof(token), mods, feature);
ae_append_feat_token(caps->mods[pkg_index].feats,
sizeof(caps->mods[pkg_index].feats), token);
}
} else {
const int count = mods->package_count(mods->ctx);
for (int i = 0; i < count && caps->mod_count < PSX_LOBBY_MAX_MODS; ++i) {
RecompLauncherCModPackage pkg{};
if (!mods->package_get(mods->ctx, i, &pkg) || !pkg.enabled)
continue;
ae_fill_lobby_mod_pkg_from_package(&caps->mods[caps->mod_count], pkg);
caps->mod_count++;
}
}
if (caps->mod_count > 0) {
const std::string fp = PSXRecompV4::mod_runtime_plan_fingerprint_portable();
if (fp.empty()) {
g_lnch_mod_plan_error =
"enabled mods did not produce a portable netplay fingerprint";
return 0;
}
ae_copy_lobby_text(caps->mod_plan_fp, sizeof(caps->mod_plan_fp), fp.c_str());
}
return 1;
}
PsxLobbyMatchCaps ae_netplay_caps_from_settings(const RecompLauncherCSettings* s) {
PsxLobbyMatchCaps caps{};
caps.valid = 1;
@@ -10953,6 +11201,11 @@ namespace {
if (s) caps.multitap_analog = s->multitap_analog != 0;
caps.guest_memcard = g_lnch_guest_memcard != 0;
caps.guest_memcard_active = 0; /* settled at request_start */
if (g_netplay_content_negotiation && !ae_np_fill_caps_mod_plan(&caps)) {
caps.mod_plan_fp[0] = '\0';
caps.mod_count = 0;
std::memset(caps.mods, 0, sizeof(caps.mods));
}
return caps;
}
@@ -10976,6 +11229,17 @@ namespace {
}
static void ae_np_push_match_caps(const RecompLauncherCSettings* settings) {
if (g_lnch_hosting_lan && g_netplay_content_negotiation) {
AeLanLobbyState state;
PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings);
char wire[8192];
if (!g_lnch_mod_plan_error.empty() || !ae_np_read_lan_state(&state) ||
!psx_lobby_encode_match_caps(wire, sizeof(wire), &caps)) return;
state.mod_caps_json = wire;
psx_lobby_set_local_match_caps(&caps);
if (ae_np_write_lan_state(state)) ae_np_lan_send_update_to_peers(state);
return;
}
if (!psx_lobby_in_lobby() || !psx_lobby_is_host()) return;
const PsxLobbyMatchCaps* cur = psx_lobby_match_caps();
PsxLobbyMatchCaps caps = (cur && cur->valid)
@@ -10996,6 +11260,12 @@ namespace {
if (settings) caps.multitap_analog = settings->multitap_analog != 0;
caps.guest_memcard = g_lnch_guest_memcard != 0;
caps.guest_memcard_active = 0;
if (g_netplay_content_negotiation && !ae_np_fill_caps_mod_plan(&caps)) {
std::fprintf(stderr, "psxrecomp: cannot publish lobby mod plan: %s\n",
g_lnch_mod_plan_error.c_str());
return;
}
ae_np_refresh_mod_offer();
(void)psx_lobby_set_match_caps(&caps);
}
@@ -11618,11 +11888,13 @@ namespace {
psx_lobby_set_game_identity(g_lnch_netplay_game_name.c_str(), psx_lobby_game_version());
psx_lobby_set_disc_fp(g_session_disc_fp.c_str());
psx_lobby_set_max_slots(g_lnch_game_players);
ae_np_refresh_mod_offer();
const int rc = psx_lobby_connect(ae_np_default_url(nullptr));
/* connect resets g_lc; re-apply so create/join never advertise "". */
psx_lobby_set_game_identity(g_lnch_netplay_game_name.c_str(), psx_lobby_game_version());
psx_lobby_set_disc_fp(g_session_disc_fp.c_str());
psx_lobby_set_max_slots(g_lnch_game_players);
ae_np_refresh_mod_offer();
return rc;
}
@@ -11683,7 +11955,7 @@ namespace {
if (g_lnch_lan_udp == kAeLanSockInvalid) return;
for (;;) {
char buf[2560]; /* MOTK5 UPDATE: 8 seats x (id, name, 7 ints) */
char buf[8448]; /* bounded 8 KiB plan plus LAN protocol fields */
sockaddr_in from{};
#ifdef _WIN32
int fromlen = (int)sizeof(from);
@@ -11697,6 +11969,25 @@ namespace {
if (n <= 0) break;
buf[n] = '\0';
/* Content negotiation: a guest accepts lobby datagrams (and so a
* plan) only from the host endpoint it joined. */
if (g_lnch_remote_lan && g_netplay_content_negotiation) {
char host[64]; in_addr expected{};
if (!ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host)) ||
inet_pton(AF_INET, host, &expected) != 1 ||
from.sin_addr.s_addr != expected.s_addr ||
ntohs(from.sin_port) != ae_np_lan_endpoint_port(g_lnch_lan_endpoint)) continue;
}
if (std::strncmp(buf, "MOTK6 MODS\n", 11) == 0 && g_lnch_remote_lan &&
g_netplay_content_negotiation) {
const char* json = std::strchr(buf + 11, '\n');
PsxLobbyMatchCaps caps{};
if (json && psx_lobby_decode_match_caps(json + 1, &caps)) {
g_lnch_remote_lan_state.mod_caps_json = json + 1;
psx_lobby_set_local_match_caps(&caps);
}
continue;
}
if (std::strncmp(buf, "MOTK1 PING", 10) == 0 && g_lnch_hosting_lan) {
ae_np_lan_udp_sendto(from, "MOTK1 PONG\n");
continue;
@@ -12118,7 +12409,24 @@ namespace {
}
continue;
}
if (std::strncmp(buf, "MOTK1 START\n", 12) == 0) {
/* MOTK6 START = MOTK1 START fields plus the host plan. Sent only by
* content-negotiation hosts; everyone else keeps MOTK1 START. */
if (std::strncmp(buf, "MOTK6 START\n", 12) == 0 && g_lnch_remote_lan &&
g_netplay_content_negotiation) {
const char* json = buf + 12;
for (int i = 0; i < 6 && json; ++i) {
json = std::strchr(json, '\n');
if (json) ++json;
}
PsxLobbyMatchCaps caps{};
if (!json || !psx_lobby_decode_match_caps(json, &caps)) continue;
g_lnch_remote_lan_state.mod_caps_json = json;
psx_lobby_set_local_match_caps(&caps);
g_lnch_lan_start_plan_session = (uint32_t)std::strtoul(buf + 12, nullptr, 10);
buf[4] = '1'; /* shared START fields below */
}
if (std::strncmp(buf, "MOTK1 START\n", 12) == 0 &&
(g_lnch_remote_lan || !g_netplay_content_negotiation)) {
g_lnch_remote_lan_state.started = true;
/* MOTK1 START\n<session>\n[<delay>\n<prediction>\n<rollback>\n
* [<session_bios>\n[<guest_memcard>\n[<session_bios_crc>\n]]]]
@@ -12177,6 +12485,12 @@ namespace {
* cannot silently desync. */
ae_np_set_session_bios_token("openbios");
}
if (g_lnch_lan_start_plan_session == g_lnch_remote_lan_state.session_id &&
!g_lnch_remote_lan_state.mod_caps_json.empty()) {
PsxLobbyMatchCaps caps{};
if (psx_lobby_decode_match_caps(g_lnch_remote_lan_state.mod_caps_json.c_str(), &caps))
ae_np_set_session_bios_token(caps.session_bios, caps.session_bios_crc);
}
continue;
}
if (std::strncmp(buf, "MOTK1 KICK\n", 11) == 0 ||
@@ -12227,9 +12541,15 @@ namespace {
if (!g_lnch_hosting_lan && !g_lnch_joined_lan && psx_lobby_in_lobby()) {
static PsxLobbyBiosOffer s_last_offer{};
static PsxLobbyMemcardOffer s_last_mc{};
static PsxLobbyModOffer s_last_mod_offer{};
ae_np_refresh_bios_offer_from_disk();
ae_np_refresh_mod_offer();
const PsxLobbyBiosOffer* cur = psx_lobby_bios_offer();
const PsxLobbyMemcardOffer* mc = psx_lobby_memcard_offer();
const PsxLobbyModOffer* mo = psx_lobby_mod_offer();
const int mod_offer_changed = g_netplay_content_negotiation &&
mo && (!s_last_mod_offer.valid ||
std::memcmp(mo, &s_last_mod_offer, sizeof(*mo)) != 0);
const int offer_changed =
!cur || !s_last_offer.valid ||
cur->can_openbios != s_last_offer.can_openbios ||
@@ -12237,11 +12557,13 @@ namespace {
cur->prefer_openbios != s_last_offer.prefer_openbios ||
(mc && mc->valid &&
(!s_last_mc.valid || mc->has_card != s_last_mc.has_card ||
mc->share != s_last_mc.share));
mc->share != s_last_mc.share)) ||
mod_offer_changed;
if (!psx_lobby_local_ready() || offer_changed) {
(void)psx_lobby_set_ready(1);
if (cur) s_last_offer = *cur;
if (mc) s_last_mc = *mc;
if (mo) s_last_mod_offer = *mo;
}
}
}
@@ -12576,11 +12898,13 @@ namespace {
* Online create always uses psx_lobby_create — even when the UI passes
* a concrete LAN IPv4 for MotK-style host_bind rewrite. */
if (lan_only) {
if (!g_lnch_mod_plan_error.empty()) return -1;
/* LAN/Direct IP: exact port required — fail if busy. */
if (psx_lobby_in_lobby())
(void)psx_lobby_leave();
if (!ae_np_write_lan_lobby(lobby_name, endpoint, password, max_slots))
return -4;
ae_np_push_match_caps(settings);
if (host_endpoint)
std::snprintf(host_endpoint, 96, "%s", endpoint);
return 0;
@@ -12596,6 +12920,12 @@ namespace {
if (host_endpoint)
std::snprintf(host_endpoint, 96, "%s", endpoint);
if (!g_lnch_mod_plan_error.empty()) {
std::fprintf(stderr, "psxrecomp: cannot create modded lobby: %s\n",
g_lnch_mod_plan_error.c_str());
return -1;
}
if (g_lnch_hosting_lan) {
std::error_code ec;
std::filesystem::remove(ae_np_lan_file(), ec);
@@ -12607,6 +12937,7 @@ namespace {
g_lnch_remote_lan_state = {};
g_lnch_lan_endpoint.clear();
psx_lobby_set_max_slots(max_slots);
ae_np_refresh_mod_offer();
/* Ensure TOC fp survives connect/reset before the lobby stores it. */
psx_lobby_set_disc_fp(g_session_disc_fp.c_str());
return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby",
@@ -12629,6 +12960,7 @@ namespace {
if (guest_bind)
std::snprintf(guest_bind, 64, "%s", bind_buf);
}
ae_np_refresh_mod_offer();
if (lobby_id && strncmp(lobby_id, "lan:", 4) == 0) {
const char* endpoint = lobby_id + 4;
if (!endpoint[0]) return -1;
@@ -12720,6 +13052,8 @@ namespace {
}
int ae_np_leave(void*) {
if (g_netplay_content_negotiation && (g_lnch_hosting_lan || g_lnch_joined_lan))
psx_lobby_set_local_match_caps(nullptr);
if (g_lnch_hosting_lan) {
for (int i = 0; i < kAeLanMaxSlots; ++i) {
if (g_lnch_lan_peer_ok[i])
@@ -13021,8 +13355,10 @@ namespace {
int ae_np_local_ready(void*) { return psx_lobby_local_ready(); }
int ae_np_all_ready(void*) { return psx_lobby_all_ready(); }
int ae_np_set_ready(void*, int ready) {
if (ready)
if (ready) {
ae_np_refresh_bios_offer_from_disk();
ae_np_refresh_mod_offer();
}
return psx_lobby_set_ready(ready);
}
@@ -13139,6 +13475,8 @@ namespace {
int ae_np_request_start(void*, const RecompLauncherCSettings* settings) {
if (g_lnch_hosting_lan) {
ae_np_push_match_caps(settings);
if (!g_lnch_mod_plan_error.empty()) return -1;
AeLanLobbyState state;
if (!ae_np_read_lan_state(&state) || ae_np_lan_occupied(state) < 2)
return -1;
@@ -13162,13 +13500,22 @@ namespace {
: "openbios",
session_crc);
ae_np_log_settled_bios("LAN");
if (g_netplay_content_negotiation) {
PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings);
caps.session_bios_crc = session_crc;
char caps_wire[8192];
if (!g_lnch_mod_plan_error.empty() ||
!psx_lobby_encode_match_caps(caps_wire, sizeof(caps_wire), &caps)) return -1;
state.mod_caps_json = caps_wire;
psx_lobby_set_local_match_caps(&caps);
}
state.started = true;
state.session_id += 1u;
if (state.session_id == 0) state.session_id = 1;
g_lnch_lan_session_id = state.session_id;
if (!ae_np_write_lan_state(state)) return -1;
ae_np_lan_send_update_to_peers(state);
char start_msg[128];
char start_msg[8448]; /* include the complete encoded plan */
int delay = g_lnch_lobby_input_delay;
int pred = g_lnch_lobby_input_prediction;
if (delay < 2) delay = 2;
@@ -13182,14 +13529,24 @@ namespace {
char session_crc_text[16];
netplay_bios_format_crc(g_lnch_session_bios_crc, session_crc_text,
sizeof(session_crc_text));
std::snprintf(start_msg, sizeof(start_msg),
"MOTK1 START\n%u\n%d\n%d\n%d\n%s\n%d\n%s\n",
(unsigned)state.session_id, delay, pred,
g_lnch_rollback ? 1 : 0,
g_lnch_session_bios[0] ? g_lnch_session_bios
: "openbios",
g_lnch_lan_guest_memcard_active ? 1 : 0,
session_crc_text);
if (g_netplay_content_negotiation)
std::snprintf(start_msg, sizeof(start_msg),
"MOTK6 START\n%u\n%d\n%d\n%d\n%s\n%d\n%s",
(unsigned)state.session_id, delay, pred,
g_lnch_rollback ? 1 : 0,
g_lnch_session_bios[0] ? g_lnch_session_bios
: "openbios",
g_lnch_lan_guest_memcard_active ? 1 : 0,
state.mod_caps_json.c_str());
else
std::snprintf(start_msg, sizeof(start_msg),
"MOTK1 START\n%u\n%d\n%d\n%d\n%s\n%d\n%s\n",
(unsigned)state.session_id, delay, pred,
g_lnch_rollback ? 1 : 0,
g_lnch_session_bios[0] ? g_lnch_session_bios
: "openbios",
g_lnch_lan_guest_memcard_active ? 1 : 0,
session_crc_text);
for (int i = 0; i < kAeLanMaxSlots; ++i) {
if (g_lnch_lan_peer_ok[i])
ae_np_lan_udp_sendto(g_lnch_lan_peers[i], start_msg);
@@ -13199,6 +13556,7 @@ namespace {
if (!psx_lobby_is_host()) return -1;
/* Publish current BIOS offer, then ensure host seat is ready. */
ae_np_refresh_bios_offer_for(settings);
ae_np_refresh_mod_offer();
(void)psx_lobby_set_ready(1);
PsxLobbyMatchCaps caps{};
char why[192] = {};
@@ -13209,6 +13567,7 @@ namespace {
ae_np_clear_session_bios_token();
return -1;
}
if (!g_lnch_mod_plan_error.empty()) return -1;
ae_np_set_session_bios_token(caps.session_bios[0] ? caps.session_bios
: "openbios",
caps.session_bios_crc);
@@ -13216,6 +13575,97 @@ namespace {
return psx_lobby_request_start(&caps);
}
static void ae_copy_need_mod(RecompLauncherCNetplayNeedMod* out,
const PsxLobbyModPkg& pkg) {
if (!out) return;
std::memset(out, 0, sizeof(*out));
ae_copy_lobby_text(out->id, sizeof(out->id), pkg.id);
ae_copy_lobby_text(out->version, sizeof(out->version), pkg.ver);
ae_copy_lobby_text(out->name, sizeof(out->name), pkg.name);
out->builtin = pkg.builtin;
out->size = pkg.size;
}
int ae_np_need_mods_count(void*) {
return psx_lobby_need_mods_count();
}
int ae_np_need_mods_get(void*, int index,
RecompLauncherCNetplayNeedMod* out) {
PsxLobbyModPkg pkg{};
if (!out || !psx_lobby_need_mods_get(index, &pkg)) return 0;
ae_copy_need_mod(out, pkg);
return 1;
}
int ae_np_need_mods_can_transfer(void*) {
return psx_lobby_need_mods_can_transfer();
}
int ae_np_mod_xfer_start(void*) {
return psx_lobby_mod_xfer_start();
}
void ae_np_mod_xfer_cancel(void*) {
psx_lobby_mod_xfer_cancel();
}
int ae_np_mod_xfer_progress(void*) {
return psx_lobby_mod_xfer_progress();
}
int ae_np_mod_xfer_failed(void*, char* err, size_t err_cap) {
return psx_lobby_mod_xfer_failed(err, err_cap);
}
void ae_np_push_match_caps_cb(void*) {
ae_np_push_match_caps(nullptr);
}
int ae_np_lobby_mods_count(void*) {
const PsxLobbyMatchCaps* caps = psx_lobby_match_caps();
if (!caps || !caps->valid || caps->mod_count <= 0) return 0;
return caps->mod_count;
}
int ae_np_lobby_mods_get(void*, int index,
RecompLauncherCNetplayLobbyMod* out) {
const PsxLobbyMatchCaps* caps = psx_lobby_match_caps();
if (!out || !caps || !caps->valid || index < 0 ||
index >= caps->mod_count)
return 0;
const PsxLobbyModPkg& pkg = caps->mods[index];
std::memset(out, 0, sizeof(*out));
ae_copy_lobby_text(out->id, sizeof(out->id), pkg.id);
ae_copy_lobby_text(out->version, sizeof(out->version), pkg.ver);
ae_copy_lobby_text(out->name, sizeof(out->name), pkg.name);
out->builtin = pkg.builtin;
out->size = pkg.size;
out->installed = ae_mod_pkg_installed(&pkg, out->reason,
sizeof(out->reason));
ae_copy_lobby_text(out->options, sizeof(out->options), pkg.feats);
return 1;
}
int ae_np_lobby_mods_missing(void*) {
const PsxLobbyMatchCaps* caps = psx_lobby_match_caps();
int missing = 0;
if (!caps || !caps->valid || caps->mod_count <= 0) return 0;
for (int i = 0; i < caps->mod_count; ++i)
if (!ae_mod_pkg_installed(&caps->mods[i], nullptr, 0))
missing++;
return missing;
}
int ae_np_lobby_mods_download(void*) {
return psx_lobby_mod_xfer_start();
}
int ae_np_lobby_mods_can_download(void*) {
return psx_lobby_need_mods_can_transfer();
}
int ae_np_launch_pending(void*) {
if ((g_lnch_hosting_lan || g_lnch_joined_lan) &&
!g_lnch_pending_direct_launch.enabled) {
@@ -13226,6 +13676,14 @@ namespace {
if (g_lnch_joined_lan && !g_lnch_session_bios[0])
return g_lnch_pending_direct_launch.enabled ||
psx_lobby_launch_pending();
if (g_netplay_content_negotiation && g_lnch_remote_lan &&
g_lnch_lan_start_plan_session != state.session_id)
return 0;
if (g_netplay_content_negotiation && !state.mod_caps_json.empty()) {
PsxLobbyMatchCaps caps{};
if (!psx_lobby_decode_match_caps(state.mod_caps_json.c_str(), &caps)) return 0;
psx_lobby_set_local_match_caps(&caps);
}
/* Free the lobby UDP port before delay-sync binds it. */
ae_np_lan_udp_close();
g_lnch_lan_session_id = state.session_id ? state.session_id : 1u;
@@ -13721,6 +14179,22 @@ namespace {
g_lnch_netplay_callbacks.lobby_spectator_count = ae_np_lobby_spectator_count;
g_lnch_netplay_callbacks.local_is_spectator = ae_np_local_is_spectator;
g_lnch_netplay_callbacks.spectator_slot = ae_np_spectator_slot;
/* Lobby mod-plan callbacks exist only for content-negotiation titles. */
if (g_netplay_content_negotiation) {
g_lnch_netplay_callbacks.need_mods_count = ae_np_need_mods_count;
g_lnch_netplay_callbacks.need_mods_get = ae_np_need_mods_get;
g_lnch_netplay_callbacks.need_mods_can_transfer = ae_np_need_mods_can_transfer;
g_lnch_netplay_callbacks.mod_xfer_start = ae_np_mod_xfer_start;
g_lnch_netplay_callbacks.mod_xfer_cancel = ae_np_mod_xfer_cancel;
g_lnch_netplay_callbacks.mod_xfer_progress = ae_np_mod_xfer_progress;
g_lnch_netplay_callbacks.mod_xfer_failed = ae_np_mod_xfer_failed;
g_lnch_netplay_callbacks.push_match_caps = ae_np_push_match_caps_cb;
g_lnch_netplay_callbacks.lobby_mods_count = ae_np_lobby_mods_count;
g_lnch_netplay_callbacks.lobby_mods_get = ae_np_lobby_mods_get;
g_lnch_netplay_callbacks.lobby_mods_missing = ae_np_lobby_mods_missing;
g_lnch_netplay_callbacks.lobby_mods_download = ae_np_lobby_mods_download;
g_lnch_netplay_callbacks.lobby_mods_can_download = ae_np_lobby_mods_can_download;
}
gi->netplay = g_lnch_netplay_available
? &g_lnch_netplay_callbacks : nullptr;
#else
@@ -14099,6 +14573,9 @@ int main(int argc, char** argv) {
gc.netplay_local_viewport_state_addr,
gc.netplay_local_viewport_state_values.data(),
(int)gc.netplay_local_viewport_state_values.size());
g_netplay_content_negotiation = gc.netplay_content_negotiation ? 1 : 0;
PSXRecompV4::mod_runtime_set_netplay_content_negotiation(
g_netplay_content_negotiation != 0);
game_discs = gc.discs;
/* Per-disc serial gate, shared by the launch-time disc check and
* the launcher's disc verdict. Keyed by the image's uppercased
@@ -16133,14 +16610,16 @@ int main(int argc, char** argv) {
}
{
/* Netplay must stay vanilla: launcher commit_netplay clears the plan,
* but a following offline-style commit would re-resolve enabled mods
* from disk. Skip commit entirely when this session is netplay. */
/* Netplay is vanilla unless the title opted into content negotiation
* ([netplay] content_negotiation); either way the user's persisted
* offline mod selection is left untouched. */
std::string mod_error;
if (net_cfg.enabled) {
if (!PSXRecompV4::mod_runtime_clear_for_netplay(&mod_error)) {
if (!netplay_commit_mods(resolved_disc, &mod_error)) {
std::fprintf(stderr,
"psxrecomp: cannot clear mods for netplay: %s\n",
g_netplay_content_negotiation
? "psxrecomp: cannot apply netplay mods: %s\n"
: "psxrecomp: cannot clear mods for netplay: %s\n",
mod_error.c_str());
return 1;
}
@@ -16442,6 +16921,9 @@ session_reboot:
pgxp_cfg.position_fallback = g_video_pgxp_position_fallback;
pgxp_cfg.preserve_projection = g_video_pgxp_preserve_projection;
pgxp_cfg.mod_only = g_video_pgxp_mod_only;
/* Precise culling changes guest NCLIP results from host-only shadows; a
* netplay plan may carry the PGXP mod, so the arming holds it off. */
pgxp_cfg.netplay = net_cfg.enabled ? 1 : 0;
PSXPgxpSessionInputs pgxp_in{};
const PSXPgxpSessionArm pgxp_arm = psx_pgxp_session_arm(&pgxp_cfg, &pgxp_in);
if (pgxp_in.env_geometry >= 0) g_video_geometry_correction = pgxp_in.env_geometry;
@@ -17185,6 +17667,14 @@ session_reboot:
if (net_cfg.slot_count > PSX_MAX_PLAYERS)
net_cfg.slot_count = PSX_MAX_PLAYERS;
s_netplay_present_sim_watermark = 0; /* §74: sim restarts per session */
// LAN has no host-published caps. With content negotiation, require
// the full portable SHA-256 to agree before either side executes even
// its first guest frame. Off: no content gate (vanilla sessions).
net_cfg.content_fingerprint[0] = '\0';
if (g_netplay_content_negotiation &&
(net_cfg.transport == 2 || !psx_lobby_match_caps() || !psx_lobby_match_caps()->valid))
std::snprintf(net_cfg.content_fingerprint, sizeof(net_cfg.content_fingerprint),
"%s", PSXRecompV4::mod_runtime_session_plan_fp().c_str());
const int nrc = psx_netplay_start(&net_cfg);
if (nrc != 0) {
const char* const why = netplay_start_failure(nrc, net_cfg);
@@ -17641,6 +18131,7 @@ soft_return_lobby:
/* Netplay soft-exit: tear down the match, keep the lobby seat, and reopen
* the launcher on the LOBBY room so every peer can rematch. */
teardown_game_session_keep_lobby();
PSXRecompV4::mod_runtime_end_netplay();
#if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT)
ae_np_prepare_lobby_rematch();
/* Say why the match ended on the launcher's status line; the
@@ -18260,10 +18751,10 @@ soft_return_lobby:
{
std::string mod_error;
if (net_cfg.enabled) {
if (!PSXRecompV4::mod_runtime_clear_for_netplay(&mod_error)) {
if (!netplay_commit_mods(resolved_disc, &mod_error)) {
std::fprintf(stderr,
"psxrecomp: cannot clear mods for netplay "
"rematch: %s\n",
"psxrecomp: cannot apply netplay mods "
"for rematch: %s\n",
mod_error.c_str());
SDL_Quit();
return 1;
+332 -4
View File
@@ -10,6 +10,7 @@
#include "render_pass_projection.h"
#include "psx_sha256.h"
#include "cpu_state.h"
#include "psx_lobby_client.h"
#if defined(RECOMP_LAUNCHER)
#include "recomp_launcher.h"
@@ -21,6 +22,7 @@
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <optional>
#include <fstream>
#include <iomanip>
#include <map>
@@ -1173,9 +1175,12 @@ int provider_commit(void*, const char* image_path) {
}
int provider_commit_netplay(void*, const char* image_path) {
(void)image_path;
std::string error;
if (!mod_runtime_clear_for_netplay(&error)) {
/* Without [netplay] content_negotiation every netplay session is vanilla. */
if (!(mod_runtime_netplay_content_negotiation()
? mod_runtime_commit_for_netplay(image_path ? std::filesystem::path(image_path) :
std::filesystem::path(), &error)
: mod_runtime_clear_for_netplay(&error))) {
set_error(error);
return 0;
}
@@ -1221,12 +1226,25 @@ RecompLauncherCModProvider provider = {
} // namespace
static std::optional<ModPackageManager> offline_manager_before_netplay;
static bool netplay_content_negotiation_enabled = false;
void mod_runtime_set_netplay_content_negotiation(bool enabled) {
netplay_content_negotiation_enabled = enabled;
}
bool mod_runtime_netplay_content_negotiation() {
return netplay_content_negotiation_enabled;
}
bool mod_runtime_initialize(const std::filesystem::path& root,
const std::string& game_id,
uint32_t game_entry_pc,
const std::filesystem::path& exe_path,
std::string* error) {
RuntimeMods& s = state();
offline_manager_before_netplay.reset();
mod_runtime_set_session_plan_fp({});
s.manager.set_root({});
clear_function_entry_hooks();
s.disc_extents.clear();
@@ -1306,6 +1324,212 @@ bool mod_runtime_clear_for_netplay(std::string* error) {
}
/* One CSV element is "<feature>" or "<feature>=<opt>~<val>[+<opt>~<val>]". */
static void feat_token_split(const std::string& token, std::string& name,
std::string& opts) {
const size_t eq = token.find('=');
if (eq == std::string::npos) {
name = token;
opts.clear();
} else {
name = token.substr(0, eq);
opts = token.substr(eq + 1);
}
}
static bool feat_csv_wants(const char* csv, const std::string& id) {
if (!csv || !csv[0]) return true;
const char* q = csv;
while (*q) {
const char* start = q;
while (*q && *q != ',') ++q;
std::string name, opts;
feat_token_split(std::string(start, q), name, opts);
if (name == id) return true;
if (*q == ',') ++q;
}
return false;
}
static bool feat_csv_apply_options(ModPackageManager& mgr,
const std::string& package_id,
const std::string& feature_id,
const char* csv,
std::string* error) {
if (!csv || !csv[0]) return true;
const char* q = csv;
while (*q) {
const char* start = q;
while (*q && *q != ',') ++q;
std::string name, opts;
feat_token_split(std::string(start, q), name, opts);
if (*q == ',') ++q;
if (name != feature_id || opts.empty()) continue;
size_t pos = 0;
while (pos < opts.size()) {
size_t end = opts.find('+', pos);
if (end == std::string::npos) end = opts.size();
const std::string kv = opts.substr(pos, end - pos);
pos = end + 1;
const size_t tilde = kv.find('~');
if (tilde == std::string::npos || tilde == 0) {
if (error) *error = package_id + "/" + feature_id +
": malformed option in netplay mod plan";
return false;
}
const std::string key = kv.substr(0, tilde);
const std::string val = kv.substr(tilde + 1);
std::string err;
if (!mgr.set_feature_option(package_id, feature_id, key, val, &err)) {
if (error) *error = err.empty()
? (package_id + "/" + feature_id +
": unsupported netplay option " + key)
: err;
return false;
}
}
return true;
}
return true;
}
static bool feat_csv_all_known(const ModPackage& package, const char* csv,
std::string* error) {
if (!csv || !csv[0]) return true;
const char* q = csv;
while (*q) {
const char* start = q;
while (*q && *q != ',') ++q;
std::string name, opts;
feat_token_split(std::string(start, q), name, opts);
bool found = false;
for (const ModFeature& feature : package.features) {
if (feature.id == name) {
found = true;
break;
}
}
if (!found) {
if (error) *error = package.id + ": unknown netplay feature " + name;
return false;
}
if (*q == ',') ++q;
}
return true;
}
static bool apply_host_mod_plan(const PsxLobbyMatchCaps& caps, std::string* error) {
RuntimeMods& s = state();
if (!s.initialized) return true;
for (int i = 0; i < caps.mod_count; ++i) {
const PsxLobbyModPkg& pkg = caps.mods[i];
if (!pkg.id[0]) continue;
std::string err;
if (pkg.ver[0] && !s.manager.select_version(pkg.id, pkg.ver, &err)) {
if (error) *error = err.empty()
? (std::string(pkg.id) + " " + pkg.ver + " is not installed")
: err;
return false;
}
}
for (int pass = 0; pass < 8; ++pass) {
bool changed = false;
for (const auto& item : s.manager.packages()) {
const std::string& package_id = item.first;
const ModPackage* package = s.manager.selected_package(package_id);
if (!package) continue;
int required = -1;
for (int i = 0; i < caps.mod_count; ++i) {
if (package_id == caps.mods[i].id) {
required = i;
break;
}
}
const bool legacy = package->features.size() == 1 &&
package->features.front().legacy;
if (legacy) {
const bool want = required >= 0;
const bool have = package_has_enabled_feature(*package);
if (want == have) continue;
std::string err;
if (!s.manager.set_enabled(package_id, want, &err) && want) {
if (error) *error = err;
return false;
}
changed = true;
continue;
}
for (const ModFeature& feature : package->features) {
const bool want = required >= 0 &&
feat_csv_wants(caps.mods[required].feats, feature.id);
const bool have = s.manager.feature_enabled(package_id, feature.id);
if (want == have) continue;
std::string err;
if (!s.manager.set_feature_enabled(package_id, feature.id, want, &err)) {
if (want) {
if (error) *error = err;
return false;
}
continue;
}
changed = true;
}
}
if (!changed) break;
}
for (int i = 0; i < caps.mod_count; ++i) {
const PsxLobbyModPkg& pkg = caps.mods[i];
if (!pkg.id[0]) continue;
const ModPackage* package = s.manager.selected_package(pkg.id);
if (!package) {
if (error) *error = std::string(pkg.id) + " is not installed";
return false;
}
const bool legacy = package->features.size() == 1 &&
package->features.front().legacy;
if (!legacy && !feat_csv_all_known(*package, pkg.feats, error))
return false;
std::string err;
if (legacy) {
if (!s.manager.set_enabled(pkg.id, true, &err)) {
if (error) *error = err;
return false;
}
continue;
}
for (const ModFeature& feature : package->features) {
const bool want = feat_csv_wants(pkg.feats, feature.id);
if (!s.manager.set_feature_enabled(pkg.id, feature.id, want, &err) && want) {
if (error) *error = err;
return false;
}
if (want && !feat_csv_apply_options(s.manager, pkg.id, feature.id,
pkg.feats, error))
return false;
}
}
return true;
}
static std::string& session_plan_fp() {
static std::string fp;
return fp;
}
/* resolve() hashes and snapshots declared media before returning it. Only
* that form has a path-independent identity; loose folders/files do not. */
static bool netplay_resources_verified(const ModResolution& plan,
std::string* error = nullptr) {
for (const auto& resource : plan.resources) {
if (resource.bytes && resource.sha256.size() == 64) continue;
if (error) *error = resource.package_id + "/" + resource.feature_id +
": netplay requires a verified size and SHA-256 for resource " +
resource.id;
return false;
}
return true;
}
bool mod_runtime_prepare_resources(const std::filesystem::path& disc_path, std::string* error) {
RuntimeMods& s = state();
if (!s.initialized) return true;
@@ -1325,7 +1549,9 @@ bool mod_runtime_prepare_resources(const std::filesystem::path& disc_path, std::
return true;
}
bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error) {
bool mod_runtime_verify_session_plan_fp(std::string* error);
bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error, bool save_selection) {
RuntimeMods& s = state();
if (!s.initialized) return true;
if (disc_path != s.disc_path) {
@@ -1356,6 +1582,11 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err
if (error) *error = s.error;
return false;
}
if (!save_selection && (!netplay_resources_verified(plan, &s.error) ||
!mod_runtime_verify_session_plan_fp(&s.error))) {
if (error) *error = s.error;
return false;
}
for (const ModResolution::Overlay& overlay : plan.overlays) {
if (overlay.expected_sha256.empty()) continue;
std::string actual;
@@ -1375,7 +1606,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err
if (error) *error = s.error;
return false;
}
if (!s.manager.save_state(&s.error)) {
if (save_selection && !s.manager.save_state(&s.error)) {
if (error) *error = s.error;
return false;
}
@@ -1393,6 +1624,103 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err
return true;
}
void mod_runtime_set_session_plan_fp(const std::string& fp) {
session_plan_fp() = fp;
}
const std::string& mod_runtime_session_plan_fp() {
return session_plan_fp();
}
std::string mod_runtime_plan_fingerprint_portable() {
RuntimeMods& s = state();
if (!s.initialized) return {};
ModResolution plan = s.manager.resolve(s.game_id, s.exe_sha256, std::string());
if (!plan.ok || !netplay_resources_verified(plan)) return {};
return plan.fingerprint;
}
bool mod_runtime_verify_session_plan_fp(std::string* error) {
const std::string& want = session_plan_fp();
if (want.empty()) return true;
const std::string got = mod_runtime_plan_fingerprint_portable();
if (!got.empty() && got == want) return true;
if (error) {
*error = "this machine resolves the session's mods differently from the host";
if (!got.empty()) *error += " (host " + want.substr(0, 16) + ", here " + got.substr(0, 16) + ")";
}
return false;
}
bool mod_runtime_commit_for_netplay(const std::filesystem::path& disc_path,
std::string* error) {
RuntimeMods& s = state();
const PsxLobbyMatchCaps* caps = psx_lobby_match_caps();
if (!caps || !caps->valid)
return mod_runtime_commit_for_direct_netplay(disc_path, error);
if (caps->mod_count <= 0) {
session_plan_fp().clear();
return mod_runtime_clear_for_netplay(error);
}
if (!caps->mod_plan_fp[0]) {
if (error) *error = "Netplay mod plan is missing its fingerprint.";
return false;
}
ModPackageManager prior_manager = s.manager;
const std::string prior_fp = session_plan_fp();
mod_runtime_set_session_plan_fp(caps->mod_plan_fp);
if (!apply_host_mod_plan(*caps, error) ||
!mod_runtime_commit(disc_path, error, false)) {
s.manager = std::move(prior_manager);
mod_runtime_set_session_plan_fp(prior_fp);
return false;
}
if (!offline_manager_before_netplay)
offline_manager_before_netplay = std::move(prior_manager);
return true;
}
bool mod_runtime_commit_for_direct_netplay(const std::filesystem::path& disc_path,
std::string* error) {
RuntimeMods& s = state();
/* No mod runtime (no mods root): vanilla on every peer, no content gate. */
if (!s.initialized) {
session_plan_fp().clear();
return true;
}
ModPackageManager prior_manager = s.manager;
const std::string prior_fp = session_plan_fp();
session_plan_fp().clear();
// Resource preparation and immutable-byte verification are shared with
// online sessions. No host selection or generated paths are saved offline.
if (!mod_runtime_commit(disc_path, error, false)) {
s.manager = std::move(prior_manager);
session_plan_fp() = prior_fp;
return false;
}
const std::string fp = mod_runtime_plan_fingerprint_portable();
if (fp.size() != 64) {
if (error) *error = "LAN mods did not produce a verified content fingerprint.";
s.manager = std::move(prior_manager);
session_plan_fp() = prior_fp;
return false;
}
session_plan_fp() = fp;
if (!offline_manager_before_netplay)
offline_manager_before_netplay = std::move(prior_manager);
return true;
}
void mod_runtime_end_netplay() {
if (!offline_manager_before_netplay) return;
RuntimeMods& s = state();
mod_runtime_clear_for_netplay();
s.manager = std::move(*offline_manager_before_netplay);
offline_manager_before_netplay.reset();
session_plan_fp().clear();
s.validation = s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256, true);
}
const std::string& mod_runtime_fingerprint() {
return state().plan.fingerprint;
}
+1
View File
@@ -29,6 +29,7 @@ extern "C" PSXPgxpSessionArm psx_pgxp_session_arm(
in.video_texture = config->video_texture ? 1 : 0;
in.video_cpu_mode = config->video_cpu_mode ? 1 : 0;
in.mod_only = config->mod_only ? 1 : 0;
in.netplay = config->netplay ? 1 : 0;
/* Take, not peek: the request belongs to this session alone. */
in.mod_enabled = pgxp_mod_request_take(&in.mod_cpu_mode, &in.mod_culling);
+322 -38
View File
@@ -77,7 +77,25 @@ const PsxLobbyMatchCaps *psx_lobby_match_caps(void)
static PsxLobbyMatchCaps z;
return &z;
}
void psx_lobby_set_mod_offer(const PsxLobbyModOffer *offer) { (void)offer; }
const PsxLobbyModOffer *psx_lobby_mod_offer(void)
{
static PsxLobbyModOffer z;
return &z;
}
int psx_lobby_need_mods_count(void) { return 0; }
int psx_lobby_need_mods_get(int index, PsxLobbyModPkg *out) { (void)index; (void)out; return 0; }
int psx_lobby_need_mods_can_transfer(void) { return 0; }
int psx_lobby_mod_xfer_start(void) { return -1; }
void psx_lobby_mod_xfer_cancel(void) {}
int psx_lobby_mod_xfer_progress(void) { return -1; }
int psx_lobby_mod_xfer_failed(char *err, size_t err_cap) { if (err && err_cap) err[0] = '\0'; return 0; }
int psx_lobby_set_match_caps(const PsxLobbyMatchCaps *c) { (void)c; return -1; }
int psx_lobby_encode_match_caps(char *o, size_t n, const PsxLobbyMatchCaps *c)
{ (void)o; (void)n; (void)c; return 0; }
int psx_lobby_decode_match_caps(const char *j, PsxLobbyMatchCaps *c)
{ (void)j; (void)c; return 0; }
void psx_lobby_set_local_match_caps(const PsxLobbyMatchCaps *c) { (void)c; }
int psx_lobby_member_count(void) { return 0; }
int psx_lobby_member_get(int index, PsxLobbyMember *out) { (void)index; (void)out; return 0; }
int psx_lobby_member_latency_ms(int slot) { (void)slot; return -1; }
@@ -271,6 +289,10 @@ typedef struct {
int all_ready;
int launch_pending;
PsxLobbyMatchCaps match_caps;
PsxLobbyModOffer mod_offer;
PsxLobbyModPkg need_mods[PSX_LOBBY_MAX_MODS];
int need_mod_count;
int need_mods_can_transfer;
PsxLobbyBiosOffer bios_offer;
PsxLobbyMemcardOffer memcard_offer;
/* Seat swap: one pending ask aimed at us, one outgoing result. */
@@ -289,7 +311,7 @@ typedef struct {
int schat_head;
int schat_count;
uint32_t schat_seq;
char pending_tx[8][2048];
char pending_tx[8][8192];
int pending_n;
/* Inbound ICE signals (WS op:signal). */
struct {
@@ -1511,6 +1533,153 @@ static int json_get_bool(const char *json, const char *key, int def)
return def;
}
static int json_token_ok(const char *s)
{
size_t i;
if (!s || !s[0]) return 0;
for (i = 0; s[i]; ++i) {
unsigned char c = (unsigned char)s[i];
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' ||
c == '.' || c == ':' || c == '/') continue;
return 0;
}
return 1;
}
static int json_hex_ok(const char *s)
{
size_t i;
if (!s || !s[0]) return 0;
for (i = 0; s[i]; ++i) {
unsigned char c = (unsigned char)s[i];
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F')) continue;
return 0;
}
return 1;
}
static int json_feats_ok(const char *s)
{
size_t i;
if (!s) return 1;
for (i = 0; s[i]; ++i) {
unsigned char c = (unsigned char)s[i];
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' ||
c == '.' || c == ':' || c == '/' || c == ',' || c == '=' ||
c == '+' || c == '~') continue;
return 0;
}
return 1;
}
static int json_extract_array(const char *json, const char *key, char *out, size_t out_cap)
{
char pat[80];
const char *q;
int depth;
size_t n;
if (!json || !key || !out || out_cap < 3) return 0;
out[0] = '\0';
snprintf(pat, sizeof(pat), "\"%s\"", key);
q = strstr(json, pat);
if (!q) return 0;
q = strchr(q + strlen(pat), ':');
if (!q) return 0;
++q;
while (*q && isspace((unsigned char)*q)) ++q;
if (*q != '[') return 0;
depth = 0;
n = 0;
do {
if (*q == '[') ++depth;
else if (*q == ']') --depth;
if (n + 1 >= out_cap) return 0;
out[n++] = *q++;
} while (*q && depth > 0);
out[n] = '\0';
return depth == 0 && n > 1;
}
static int parse_one_mod_pkg(const char *obj, PsxLobbyModPkg *out)
{
if (!obj || !out || obj[0] != '{') return 0;
memset(out, 0, sizeof(*out));
json_get_str(obj, "id", out->id, sizeof(out->id));
json_get_str(obj, "ver", out->ver, sizeof(out->ver));
json_get_str(obj, "name", out->name, sizeof(out->name));
json_get_str(obj, "feats", out->feats, sizeof(out->feats));
out->builtin = json_get_bool(obj, "builtin", 0);
out->size = (uint32_t)json_get_int(obj, "size", 0);
if (!json_token_ok(out->id)) out->id[0] = '\0';
if (out->ver[0] && !json_token_ok(out->ver)) out->ver[0] = '\0';
if (out->feats[0] && !json_feats_ok(out->feats)) out->feats[0] = '\0';
return out->id[0] ? 1 : 0;
}
static int parse_mod_pkg_array(const char *json, const char *key,
PsxLobbyModPkg *out, int cap)
{
char arr[4096];
const char *q;
int n = 0;
if (!out || cap <= 0) return 0;
memset(out, 0, sizeof(PsxLobbyModPkg) * (size_t)cap);
if (!json_extract_array(json, key, arr, sizeof(arr))) return 0;
q = arr;
while (*q && n < cap) {
const char *obj, *end;
int depth = 0;
char chunk[768];
size_t len;
while (*q && *q != '{' && *q != ']') ++q;
if (*q != '{') break;
obj = end = q;
do {
if (*end == '{') ++depth;
else if (*end == '}') --depth;
++end;
} while (*end && depth > 0);
len = (size_t)(end - obj);
if (len >= sizeof(chunk)) len = sizeof(chunk) - 1;
memcpy(chunk, obj, len);
chunk[len] = '\0';
if (parse_one_mod_pkg(chunk, &out[n])) ++n;
q = end;
}
return n;
}
static int append_mod_pkg_array(char *dst, size_t dst_cap,
const PsxLobbyModPkg *pkgs, int count)
{
size_t o = 0;
int i;
if (!dst || dst_cap < 3) return 0;
dst[o++] = '[';
for (i = 0; pkgs && i < count && i < PSX_LOBBY_MAX_MODS; ++i) {
const PsxLobbyModPkg *pkg = &pkgs[i];
int n;
if (!pkg->id[0] || !json_token_ok(pkg->id)) continue;
if (pkg->ver[0] && !json_token_ok(pkg->ver)) continue;
if (pkg->feats[0] && !json_feats_ok(pkg->feats)) continue;
n = snprintf(dst + o, dst_cap - o,
"%s{\"id\":\"%s\",\"ver\":\"%s\",\"name\":\"%s\","
"\"feats\":\"%s\",\"builtin\":%s,\"size\":%u}",
o > 1 ? "," : "", pkg->id, pkg->ver, pkg->name,
pkg->feats, pkg->builtin ? "true" : "false", (unsigned)pkg->size);
if (n < 0 || (size_t)n >= dst_cap - o) return 0;
o += (size_t)n;
}
if (o + 2 > dst_cap) return 0;
dst[o++] = ']';
dst[o] = '\0';
return 1;
}
static int json_extract_object(const char *json, const char *key, char *out, size_t out_cap)
{
char pat[80];
@@ -1571,6 +1740,10 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out)
out->guest_memcard_active = json_get_bool(obj, "guest_memcard_active", 0);
json_get_str(obj, "language", out->language, sizeof(out->language));
json_get_str(obj, "session_bios", out->session_bios, sizeof(out->session_bios));
json_get_str(obj, "mod_plan_fp", out->mod_plan_fp, sizeof(out->mod_plan_fp));
if (out->mod_plan_fp[0] && !json_hex_ok(out->mod_plan_fp))
out->mod_plan_fp[0] = '\0';
out->mod_count = parse_mod_pkg_array(obj, "mods", out->mods, PSX_LOBBY_MAX_MODS);
/* Normalize settled BIOS id. */
if (out->session_bios[0] &&
strcmp(out->session_bios, "openbios") != 0 &&
@@ -1590,7 +1763,7 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out)
static void ingest_match_caps_from_json(const char *json)
{
char obj[1024];
char obj[4096];
if (json_extract_object(json, "match_caps", obj, sizeof(obj)))
parse_match_caps_object(obj, &g_lc.match_caps);
}
@@ -1598,7 +1771,10 @@ static void ingest_match_caps_from_json(const char *json)
static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatchCaps *caps)
{
char lang[PSX_LOBBY_LANG_LEN];
char mods_json[4096];
char plan_json[4096 + 96];
size_t i, o = 0;
int n;
if (!dst || dst_cap < 8 || !caps || !caps->valid) return 0;
/* Sanitize language for JSON string (alnum / _ / - only). */
for (i = 0; caps->language[i] && o + 1 < sizeof(lang); ++i) {
@@ -1609,40 +1785,77 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch
}
lang[o] = '\0';
if (!lang[0]) strncpy(lang, "en", sizeof(lang) - 1);
if (!append_mod_pkg_array(mods_json, sizeof(mods_json), caps->mods, caps->mod_count))
return 0;
{
const char *sb = caps->session_bios;
char crc[16];
netplay_bios_format_crc(strcmp(sb, "scph1001") == 0 ? caps->session_bios_crc : 0, crc, sizeof(crc));
const char *fp = caps->mod_plan_fp;
if (!sb[0] || (strcmp(sb, "openbios") != 0 && strcmp(sb, "scph1001") != 0))
sb = "";
netplay_bios_format_crc(strcmp(sb, "scph1001") == 0 ? caps->session_bios_crc : 0,
crc, sizeof(crc));
return snprintf(dst, dst_cap,
",\"match_caps\":{\"v\":1,\"aspect_num\":%d,\"aspect_den\":%d,"
"\"turbo_loads\":%s,\"bios_hle\":%s,\"fast_boot\":%s,"
"\"auto_skip_fmv\":%s,\"input_delay\":%d,\"input_prediction\":%d,%s"
"\"force_input_relay\":%s,\"force_turn\":%s,\"rollback\":%s,"
"\"multitap_analog\":%s,\"guest_memcard\":%s,"
"\"guest_memcard_active\":%s,"
"\"language\":\"%s\",\"session_bios\":\"%s\","
"\"session_bios_crc\":\"%s\"}",
caps->aspect_num, caps->aspect_den,
caps->turbo_loads ? "true" : "false",
caps->bios_hle ? "true" : "false",
caps->fast_boot ? "true" : "false",
caps->auto_skip_fmv ? "true" : "false",
caps->input_delay,
caps->input_prediction,
/* only when asked: absent reads as "the SFU, as always" */
caps->relay_host ? "\"relay\":\"host\"," : "",
caps->force_input_relay ? "true" : "false",
caps->force_turn ? "true" : "false",
caps->rollback ? "true" : "false",
caps->multitap_analog ? "true" : "false",
caps->guest_memcard ? "true" : "false",
caps->guest_memcard_active ? "true" : "false",
lang, sb, crc);
if (fp[0] && !json_hex_ok(fp)) fp = "";
/* Only a published mod plan (content negotiation) adds plan fields;
* a vanilla host's caps stay byte-identical to the pre-plan wire. */
plan_json[0] = '\0';
if (fp[0] || caps->mod_count > 0)
snprintf(plan_json, sizeof(plan_json),
",\"mod_plan_fp\":\"%s\",\"mods\":%s", fp, mods_json);
n = snprintf(dst, dst_cap,
",\"match_caps\":{\"v\":1,\"aspect_num\":%d,\"aspect_den\":%d,"
"\"turbo_loads\":%s,\"bios_hle\":%s,\"fast_boot\":%s,"
"\"auto_skip_fmv\":%s,\"input_delay\":%d,\"input_prediction\":%d,%s"
"\"force_input_relay\":%s,\"force_turn\":%s,\"rollback\":%s,"
"\"multitap_analog\":%s,\"guest_memcard\":%s,"
"\"guest_memcard_active\":%s,"
"\"language\":\"%s\",\"session_bios\":\"%s\","
"\"session_bios_crc\":\"%s\"%s}",
caps->aspect_num, caps->aspect_den,
caps->turbo_loads ? "true" : "false",
caps->bios_hle ? "true" : "false",
caps->fast_boot ? "true" : "false",
caps->auto_skip_fmv ? "true" : "false",
caps->input_delay,
caps->input_prediction,
caps->relay_host ? "\"relay\":\"host\"," : "",
caps->force_input_relay ? "true" : "false",
caps->force_turn ? "true" : "false",
caps->rollback ? "true" : "false",
caps->multitap_analog ? "true" : "false",
caps->guest_memcard ? "true" : "false",
caps->guest_memcard_active ? "true" : "false",
lang, sb, crc, plan_json);
return n;
}
}
int psx_lobby_encode_match_caps(char *out, size_t cap, const PsxLobbyMatchCaps *caps)
{
int n;
if (!out || cap < 16) return 0;
strcpy(out, "{\"v\":1");
n = append_match_caps_json(out + 6, cap - 7, caps);
if (n <= 0 || (size_t)n >= cap - 7) { out[0] = '\0'; return 0; }
out[6 + n] = '}'; out[7 + n] = '\0';
return 1;
}
int psx_lobby_decode_match_caps(const char *json, PsxLobbyMatchCaps *out)
{
char obj[4096];
PsxLobbyMatchCaps caps = {0};
if (!out || !json || !json_extract_object(json, "match_caps", obj, sizeof(obj)))
return 0;
parse_match_caps_object(obj, &caps);
if (!caps.valid || (caps.mod_count > 0 && strlen(caps.mod_plan_fp) != 64)) return 0;
*out = caps;
return 1;
}
void psx_lobby_set_local_match_caps(const PsxLobbyMatchCaps *caps)
{
if (caps) g_lc.match_caps = *caps;
else match_caps_clear(&g_lc.match_caps);
}
static void queue_send(const char *json)
{
@@ -3244,6 +3457,15 @@ static void handle_server_json(const char *json)
if (automatch_claim_error(code, json)) {
return;
}
if (strcmp(code, "need_mods") == 0) {
g_lc.need_mod_count = parse_mod_pkg_array(json, "need_mods",
g_lc.need_mods,
PSX_LOBBY_MAX_MODS);
g_lc.need_mods_can_transfer = json_get_bool(json, "can_transfer", 0);
} else {
g_lc.need_mod_count = 0;
g_lc.need_mods_can_transfer = 0;
}
/* Create/join failures are fatal to the seat. In-lobby ops (kick/move
* on an older server, not_host, …) must not clear join.ok or the room
* looks abandoned after a rejected host action. */
@@ -3255,7 +3477,8 @@ static void handle_server_json(const char *json)
strcmp(code, "lobby_limit") == 0 ||
strcmp(code, "version_mismatch") == 0 ||
strcmp(code, "game_mismatch") == 0 ||
strcmp(code, "disc_mismatch") == 0) {
strcmp(code, "disc_mismatch") == 0 ||
strcmp(code, "need_mods") == 0) {
g_lc.join.ok = 0;
}
return;
@@ -4206,8 +4429,8 @@ int psx_lobby_create(const char *name, const char *game_name, const char *game_v
const char *password, const char *host_bind,
const PsxLobbyMatchCaps *match_caps)
{
char msg[2304];
char caps_json[512];
char msg[8192];
char caps_json[4096];
char name_esc[JSON_ESC_CAP(128)];
char gn_esc[JSON_ESC_CAP(PSX_LOBBY_NAME_LEN)];
char gv_esc[JSON_ESC_CAP(PSX_LOBBY_VERSION_LEN)];
@@ -4230,6 +4453,8 @@ int psx_lobby_create(const char *name, const char *game_name, const char *game_v
strncpy(g_lc.my_bind, host_bind && host_bind[0] ? host_bind : "0.0.0.0:7777",
sizeof(g_lc.my_bind) - 1);
g_lc.join.last_error[0] = '\0';
g_lc.need_mod_count = 0;
g_lc.need_mods_can_transfer = 0;
caps_json[0] = '\0';
if (match_caps && match_caps->valid) {
g_lc.match_caps = *match_caps;
@@ -4260,7 +4485,7 @@ int psx_lobby_create(const char *name, const char *game_name, const char *game_v
int psx_lobby_join(const char *lobby_id, const char *password, const char *guest_bind)
{
char msg[1536];
char msg[8192];
char lid_esc[JSON_ESC_CAP(PSX_LOBBY_ID_LEN)];
char pw_esc[JSON_ESC_CAP(128)];
char bind_esc[JSON_ESC_CAP(PSX_LOBBY_ENDPOINT_LEN)];
@@ -4268,6 +4493,7 @@ int psx_lobby_join(const char *lobby_id, const char *password, const char *guest
char gn_esc[JSON_ESC_CAP(PSX_LOBBY_NAME_LEN)];
char gv_esc[JSON_ESC_CAP(PSX_LOBBY_VERSION_LEN)];
char fp_esc[JSON_ESC_CAP(72)];
char offer_json[4096];
const char *gn;
const char *gv;
int n;
@@ -4279,6 +4505,16 @@ int psx_lobby_join(const char *lobby_id, const char *password, const char *guest
strncpy(g_lc.my_bind, guest_bind && guest_bind[0] ? guest_bind : "0.0.0.0:7778",
sizeof(g_lc.my_bind) - 1);
g_lc.join.last_error[0] = '\0';
g_lc.need_mod_count = 0;
g_lc.need_mods_can_transfer = 0;
offer_json[0] = '\0';
if (g_lc.mod_offer.valid && g_lc.mod_offer.count > 0) {
char pkgs[4096];
if (append_mod_pkg_array(pkgs, sizeof(pkgs), g_lc.mod_offer.pkgs,
g_lc.mod_offer.count))
snprintf(offer_json, sizeof(offer_json),
",\"mod_offer\":{\"pkgs\":%s}", pkgs);
}
json_escape(lobby_id, lid_esc, sizeof(lid_esc));
json_escape(password ? password : "", pw_esc, sizeof(pw_esc));
json_escape(g_lc.my_bind, bind_esc, sizeof(bind_esc));
@@ -4290,8 +4526,8 @@ int psx_lobby_join(const char *lobby_id, const char *password, const char *guest
n = snprintf(msg, sizeof(msg),
"{\"op\":\"join\",\"lobby_id\":\"%s\",\"password\":\"%s\",\"guest_bind\":\"%s\","
"\"display_name\":\"%s\",\"game_name\":\"%s\",\"game_version\":\"%s\","
"\"disc_fp\":\"%s\"}",
lid_esc, pw_esc, bind_esc, dn_esc, gn_esc, gv_esc, fp_esc);
"\"disc_fp\":\"%s\"%s}",
lid_esc, pw_esc, bind_esc, dn_esc, gn_esc, gv_esc, fp_esc, offer_json);
/* A truncated frame is a frame the server drops whole, so the join would
* silently never happen. create checked this; join did not. */
if (n < 0 || (size_t)n >= sizeof(msg)) return -1;
@@ -4447,10 +4683,58 @@ const PsxLobbyMatchCaps *psx_lobby_match_caps(void)
return &g_lc.match_caps;
}
void psx_lobby_set_mod_offer(const PsxLobbyModOffer *offer)
{
if (!offer) {
memset(&g_lc.mod_offer, 0, sizeof(g_lc.mod_offer));
return;
}
g_lc.mod_offer = *offer;
if (g_lc.mod_offer.count < 0) g_lc.mod_offer.count = 0;
if (g_lc.mod_offer.count > PSX_LOBBY_MAX_MODS)
g_lc.mod_offer.count = PSX_LOBBY_MAX_MODS;
}
const PsxLobbyModOffer *psx_lobby_mod_offer(void)
{
return &g_lc.mod_offer;
}
int psx_lobby_need_mods_count(void)
{
return g_lc.need_mod_count;
}
int psx_lobby_need_mods_get(int index, PsxLobbyModPkg *out)
{
if (!out || index < 0 || index >= g_lc.need_mod_count) return 0;
*out = g_lc.need_mods[index];
return 1;
}
int psx_lobby_need_mods_can_transfer(void)
{
/* This focused online-mod port advertises and applies bundled packages, but
* does not implement lobby byte transfer. Keep the UI from offering a dead
* Download action even if the server reports that a transfer channel exists. */
(void)g_lc.need_mods_can_transfer;
return 0;
}
int psx_lobby_mod_xfer_start(void) { return -1; }
void psx_lobby_mod_xfer_cancel(void) {}
int psx_lobby_mod_xfer_progress(void) { return -1; }
int psx_lobby_mod_xfer_failed(char *err, size_t err_cap)
{
if (err && err_cap) err[0] = '\0';
return 0;
}
int psx_lobby_set_match_caps(const PsxLobbyMatchCaps *caps)
{
char msg[896];
char caps_json[640];
char msg[6144];
char caps_json[4096];
int n;
if (!psx_lobby_connected() || !g_lc.in_lobby || !g_lc.is_host || !caps || !caps->valid)
return -1;
@@ -4831,7 +5115,7 @@ int psx_lobby_start_message(const PsxLobbyMatchCaps *caps, char *msg, size_t cap
int psx_lobby_request_start(const PsxLobbyMatchCaps *match_caps)
{
char msg[896];
char msg[6144];
PsxLobbyMatchCaps caps_local;
const PsxLobbyMatchCaps *send = NULL;
if (!psx_lobby_connected() || !g_lc.in_lobby || !g_lc.is_host) {
+62 -5
View File
@@ -34,6 +34,7 @@
#include "recomp_net/input_contract.h"
#include "crc32.h"
#include "netplay_hash_confirm.h"
#include "netplay_content_gate.h"
#include "netplay_input_hist.h"
#include "netplay_state_digest.h"
#include "psx_netplay_rb.h"
@@ -448,13 +449,13 @@ void psx_netplay_diag_tick(void) {}
int psx_netplay_is_spectator(void) { return 0; }
int psx_netplay_host_spectates(void) { return 0; }
int psx_netplay_local_slot(void) { return -1; }
int psx_netplay_local_port(void) { return -1; }
int psx_netplay_input_player(void) { return 0; }
uint32_t psx_netplay_sim_tick(void) { return 0; }
int psx_netplay_start(const PsxNetplayConfig *cfg)
{
(void)cfg;
return -1;
}
int psx_netplay_content_note(uint32_t e, uint32_t a, uint32_t b, uint32_t seen,
uint8_t slot, uint8_t op, uint8_t flags)
{ (void)e;(void)a;(void)b;(void)seen;(void)slot;(void)op;(void)flags;return 0; }
int psx_netplay_start(const PsxNetplayConfig *cfg) { (void)cfg; return -1; }
void psx_netplay_shutdown(void) {}
void psx_netplay_cold_reset(void) {}
void psx_netplay_stage_local(const PsxNetPad *pad) { (void)pad; }
@@ -1120,6 +1121,19 @@ static void np_sleep_ms(unsigned ms)
psx_host_sleep_ms(ms);
}
static NpContentGate s_content;
static uint32_t s_content_send_ms;
int psx_netplay_content_note(uint32_t epoch, uint32_t a, uint32_t b, uint32_t seen,
uint8_t slot, uint8_t op, uint8_t flags) {
int was_mismatch=s_content.mismatch;
/* Only a session with a content fingerprint consumes identity packets;
* otherwise every RB_SYNC message keeps its existing meaning. */
if(!s_content.enabled || op!=RNET_RB_SYNC_OP_IDENT || epoch!=NP_CONTENT_MAGIC) return 0;
np_content_note(&s_content,slot,flags,a,b,seen);
if(!was_mismatch && s_content.mismatch)
fprintf(stderr,"psxrecomp: LAN content differs between peers; choose matching mods, options and media before restarting.\n");
return 1;
}
static uint32_t np_mono_ms(void)
{
return (uint32_t)psx_host_mono_ms();
@@ -3315,6 +3329,11 @@ int psx_netplay_local_slot(void)
return psx_netplay_active() ? g_np.local_slot : -1;
}
int psx_netplay_local_port(void)
{
return psx_netplay_active() && !psx_netplay_is_spectator() ? np_slot_to_port(g_np.local_slot) : -1;
}
int psx_netplay_input_player(void)
{
return psx_netplay_active() ? g_np.input_player : 0;
@@ -3729,6 +3748,9 @@ int psx_netplay_start(const PsxNetplayConfig *cfg)
}
}
if (!np_content_init(&s_content, cfg->content_fingerprint, rcfg.occupied_mask, local))
return -5;
s_content_send_ms = 0;
/* Host resolves auto (-1) before start; accept 0..PSX_MAX_PLAYERS-1. */
in_player = cfg->input_player;
if (in_player < 0 || in_player >= PSX_MAX_PLAYERS) in_player = 0;
@@ -4644,6 +4666,33 @@ static void np_note_save_complete(void)
psx_netplay_rb_clear_fmv_desync_hold("netplay save complete");
}
static void np_content_pump(void) {
uint32_t now=np_mono_ms(); unsigned i;
if(!s_content.enabled || !rnet_session_is_running(g_np.session)) return;
if(!g_np.rollback) {
rnet_u32 e,a,b,seen; rnet_u8 slot,op,flags;
while(rnet_session_take_rb_sync(g_np.session,&e,&a,&b,&seen,&slot,&op,&flags))
(void)psx_netplay_content_note(e,a,b,seen,slot,op,flags);
}
// Keep answering during early gameplay so a dropped final ACK cannot
// strand the peer. No guest-derived or local presentation data is sent.
if(np_content_ready(&s_content) && rnet_session_sim_tick(g_np.session)>90) {
int all_advanced=1;
for(i=0;i<9;++i) {
rnet_u32 tip=0;
if(i==s_content.local || !(s_content.occupied&(1u<<i))) continue;
if(!rnet_session_remote_tip(g_np.session,(int)i,&tip) || tip<=90) all_advanced=0;
}
if(all_advanced) return;
}
if(s_content_send_ms && now-s_content_send_ms<100) return;
s_content_send_ms=now;
for(i=0;i<4;++i)
rnet_session_send_rb_sync(g_np.session,NP_CONTENT_MAGIC,s_content.words[i*2],
s_content.words[i*2+1],s_content.matched,(rnet_u8)s_content.local,
RNET_RB_SYNC_OP_IDENT,(rnet_u8)(i|(s_content.mismatch?0x80:0)));
}
static void np_pump_session(void)
{
#if defined(PSX_HAS_LOBBY_CLIENT)
@@ -4664,6 +4713,8 @@ static void np_pump_session(void)
np_rollback_reconcile_wire();
psx_netplay_rb_pump();
}
np_content_pump();
if(!np_content_ready(&s_content)) return;
np_guest_handle_probe();
np_maybe_stage_target_save();
np_apply_ready_state();
@@ -4788,6 +4839,10 @@ int psx_netplay_poll_admit(void)
return 0;
}
if (!np_content_ready(&s_content)) {
np_sched_set_admit_stall(s_content.mismatch ? "content_mismatch" : "content_agreement");
return 0;
}
/* Both peers stall until initial memcard hash-agree / transfer finishes. */
if (!g_np.mc_sync_done)
return 0;
@@ -5227,6 +5282,8 @@ void psx_netplay_admit_wait_info(char *stall_out, size_t stall_cap,
}
break;
}
if (!np_content_ready(&s_content))
snprintf(phase,sizeof(phase),"%s",s_content.mismatch ? "content_mismatch" : "content_agreement");
/* Rollback episode: try_admit is skipped so last_stall stays "ok" —
* surface the RB FSM phase instead. */
if (!phase[0] && g_np.rollback && psx_netplay_rb_active()) {
+2
View File
@@ -8806,6 +8806,8 @@ void psx_netplay_rb_pump(void)
while (rnet_session_take_rb_sync(s, &epoch, &mismatch, &load, &target, &cslot, &op,
&wire_flags)) {
if (psx_netplay_content_note(epoch, mismatch, load, target, cslot, op, wire_flags))
continue;
if (op == RNET_RB_SYNC_OP_COMMIT) {
/* §42c: peer committed epoch at tick (load field) and left it.
* Record for the tip-extend abandon; also fold into the RESOLVED
+48
View File
@@ -147,12 +147,60 @@ static void case_long_line_is_truncated_not_dropped(void)
"and still NUL-terminated");
}
static void case_mod_caps_roundtrip(void)
{
PsxLobbyMatchCaps caps = {0};
char wire[4096];
caps.valid = 1;
caps.aspect_num = 4;
caps.aspect_den = 3;
caps.rollback = 1;
caps.mod_count = 2;
memset(caps.mod_plan_fp, 'a', 64);
strcpy(caps.mods[0].id, "content.cars");
strcpy(caps.mods[0].ver, "1.0.0");
strcpy(caps.mods[0].name, "Cars \"Original\"");
strcpy(caps.mods[0].feats, "roster,view=mode~fit");
strcpy(caps.mods[1].id, "content.arena");
strcpy(caps.mods[1].ver, "0.1.0");
strcpy(caps.mods[1].feats, "arena");
ck(append_match_caps_json(wire, sizeof(wire), &caps), "serialize host mod caps");
ingest_match_caps_from_json(wire);
ck(g_lc.match_caps.valid && g_lc.match_caps.rollback &&
g_lc.match_caps.mod_count == 2, "roundtrip two required packages");
ck(!strcmp(g_lc.match_caps.mod_plan_fp, caps.mod_plan_fp) &&
!strcmp(g_lc.match_caps.mods[0].id, "content.cars") &&
!strcmp(g_lc.match_caps.mods[0].feats, "roster,view=mode~fit") &&
!strcmp(g_lc.match_caps.mods[1].ver, "0.1.0"),
"roundtrip fingerprint, versions, features and options");
ck(strstr(wire, "resources") == NULL && strstr(wire, "path") == NULL,
"lobby plan carries no donor paths or asset bytes");
{
PsxLobbyMatchCaps received = {0};
char tiny[32];
ck(psx_lobby_encode_match_caps(wire, sizeof(wire), &caps), "LAN uses same host caps serializer");
ck(psx_lobby_decode_match_caps(wire, &received) && received.mod_count == 2 &&
!strcmp(received.mod_plan_fp, caps.mod_plan_fp) &&
!strcmp(received.mods[0].feats, caps.mods[0].feats), "LAN preserves host selections and options");
ck(!psx_lobby_encode_match_caps(tiny, sizeof(tiny), &caps), "truncated LAN plans fail closed");
wire[strlen(wire) - 2] = '\0';
ck(!psx_lobby_decode_match_caps(wire, &received), "incomplete LAN caps rejected");
psx_lobby_set_local_match_caps(&caps);
ck(psx_lobby_match_caps()->mod_count == 2, "LAN plan supplied to launch provider");
psx_lobby_set_local_match_caps(NULL);
ck(!psx_lobby_match_caps()->valid, "leaving LAN retires host plan");
}
parse_match_caps_object("{\"mod_plan_fp\":\"invalid!\",\"mods\":[]}", &caps);
ck(!caps.mod_plan_fp[0], "invalid fingerprint rejected");
}
int main(void)
{
case_room_order();
case_wrap_drops_oldest();
case_empty_and_clear();
case_long_line_is_truncated_not_dropped();
case_mod_caps_roundtrip();
if (g_failures == 0) {
printf("lobby_chat_ring_test: ok\n");
return 0;
+5
View File
@@ -5,6 +5,7 @@
#include "mod_runtime.h"
#include "mod_plugins.h"
#include "psx_sha256.h"
#include "psx_lobby_client.h"
#include "gpu.h"
#include <array>
@@ -56,6 +57,10 @@ extern "C" int gpu_ws_configured_x_reveal(void) { return 0; }
extern "C" void gpu_ws_tag_hud_prim(uint32_t, int) {}
extern "C" void gpu_ws_tag_screen_mask_quad(uint32_t) {}
extern "C" void gpu_ws_tag_radial_screen_mask_quad(uint32_t, float) {}
/* mod_runtime.cpp's lobby netplay commit reads the negotiated match caps.
* No lobby match is negotiated in this test. */
static PsxLobbyMatchCaps no_match_caps;
extern "C" const PsxLobbyMatchCaps* psx_lobby_match_caps(void) { return &no_match_caps; }
static void check(bool ok, const char* what) {
if (!ok) { std::cerr << "FAIL: " << what << "\n"; failures++; }
+112 -1
View File
@@ -2,6 +2,7 @@
#include "mod_packages.h"
#include "mod_plugins.h"
#include "psx_sha256.h"
#include "psx_lobby_client.h"
#include "gpu.h"
#include "cpu_state.h"
@@ -23,6 +24,10 @@ static int failures;
static int activation_calls;
static int plugin_calls;
static int restore_calls;
static PsxLobbyMatchCaps test_match_caps;
extern "C" const PsxLobbyMatchCaps* psx_lobby_match_caps(void) {
return &test_match_caps;
}
extern "C" uint8_t psx_read_byte(uint32_t address) {
return ram[address & 0x1fffffu];
@@ -1039,6 +1044,46 @@ int main() {
check(psx_mod_register_activation_plugin("media.test.plugin", test_media_plugin), "register media consumer");
check(PSXRecompV4::mod_runtime_initialize(media_root, "READER", 0, {}, &error), "media initialize");
check(PSXRecompV4::mod_runtime_commit(iso_path, &error), "verify and commit owner media");
const std::string host_media_fp = PSXRecompV4::mod_runtime_plan_fingerprint_portable();
check(host_media_fp.size() == 64, "verified external media has a portable netplay identity");
test_match_caps = {};
check(PSXRecompV4::mod_runtime_commit_for_direct_netplay(iso_path, &error), "LAN retains verified selected content");
check(PSXRecompV4::mod_runtime_session_plan_fp() == host_media_fp, "LAN announces full portable plan identity");
PSXRecompV4::mod_runtime_end_netplay();
check(PSXRecompV4::mod_runtime_session_plan_fp().empty(), "LAN return retires session identity");
const auto guest_rom_path = media_root / "different-machine" / "guest.z64";
write_bytes(guest_rom_path, rom);
const std::string guest_state =
"format_version = 2\n[[feature]]\npackage_id = \"media.test\"\nid = \"active\"\nenabled = false\n"
"[feature.resources]\nrom = \"" + guest_rom_path.generic_string() + "\"\nxa = \"" + xa_path.generic_string() + "\"\n";
write_text(media_root / "state.toml", guest_state);
auto read_guest_state = [&]() {
std::ifstream in(media_root / "state.toml", std::ios::binary);
return std::string(std::istreambuf_iterator<char>(in), {});
};
const std::string guest_state_bytes = read_guest_state();
check(PSXRecompV4::mod_runtime_initialize(media_root, "READER", 0, {}, &error), "guest media initialize");
test_match_caps.valid = 1;
test_match_caps.mod_count = 1;
std::snprintf(test_match_caps.mods[0].id, sizeof(test_match_caps.mods[0].id), "media.test");
std::snprintf(test_match_caps.mods[0].ver, sizeof(test_match_caps.mods[0].ver), "1.0.0");
std::snprintf(test_match_caps.mods[0].feats, sizeof(test_match_caps.mods[0].feats), "active");
std::snprintf(test_match_caps.mod_plan_fp, sizeof(test_match_caps.mod_plan_fp), "%s", host_media_fp.c_str());
check(PSXRecompV4::mod_runtime_commit_for_netplay(iso_path, &error), "host plan enables guest media with different local paths");
check(PSXRecompV4::mod_runtime_plan_fingerprint_portable() == host_media_fp, "guest content identity matches host");
check(read_guest_state() == guest_state_bytes, "online plan preserves offline selection and resource paths byte for byte");
const std::string committed_fp = PSXRecompV4::mod_runtime_fingerprint();
test_match_caps.mod_plan_fp[0] = host_media_fp[0] == 'a' ? 'b' : 'a';
check(!PSXRecompV4::mod_runtime_commit_for_netplay(iso_path, &error), "mismatched host content fingerprint refused");
check(PSXRecompV4::mod_runtime_fingerprint() == committed_fp, "mismatch leaves previous committed plan intact");
test_match_caps.mod_plan_fp[0] = '\0';
check(!PSXRecompV4::mod_runtime_commit_for_netplay(iso_path, &error), "missing host fingerprint refused");
std::snprintf(test_match_caps.mod_plan_fp, sizeof(test_match_caps.mod_plan_fp), "%s", host_media_fp.c_str());
auto bad_rom = rom; bad_rom[33] ^= 1; write_bytes(guest_rom_path, bad_rom);
check(!PSXRecompV4::mod_runtime_commit_for_netplay(iso_path, &error), "changed donor media refused before online launch");
check(read_guest_state() == guest_state_bytes, "failed online launch leaves offline state untouched");
write_bytes(guest_rom_path, rom);
check(PSXRecompV4::mod_runtime_commit_for_netplay(iso_path, &error), "valid donor media recovers after refused launch");
const uint8_t* unavailable = nullptr;
uint64_t unavailable_size = 0;
check(!psx_mod_current_resource_bytes("rom", &unavailable, &unavailable_size), "bytes unavailable outside plugin callback");
@@ -1084,7 +1129,7 @@ int main() {
iso_read_cdda_sector(mounted,150,sector.data(),sector.size()) && sector[8]==0x52,
"reactivation retains audio identity and immutable snapshot");
rom[32] ^= 1;
write_bytes(rom_path, rom);
write_bytes(guest_rom_path, rom);
check(media_snapshot && media_snapshot[32] == 0x5a, "committed bytes survive owner file changes");
check(!PSXRecompV4::mod_runtime_commit({}, &error), "new commit rejects modified media");
check(PSXRecompV4::mod_runtime_clear_for_netplay(&error), "netplay clears donor plan");
@@ -1093,6 +1138,71 @@ int main() {
check(iso_cdda_track_count(mounted)==1 && iso_cdda_sector_count(mounted)==24,
"clearing plan also restores original audio TOC");
iso_close(mounted);
PSXRecompV4::mod_runtime_end_netplay();
check(PSXRecompV4::mod_runtime_session_plan_fp().empty(), "leaving online clears host fingerprint");
check(PSXRecompV4::mod_runtime_commit(iso_path, &error), "offline commit after netplay uses original disabled media selection");
mod_runtime_activate_plugins();
const uint8_t* ended_bytes = nullptr; uint64_t ended_size = 0;
check(!psx_mod_current_resource_bytes("rom", &ended_bytes, &ended_size), "online media is retired after session");
{
PSXRecompV4::ModPackageManager offline;
offline.set_root(media_root);
check(offline.scan(&error) && offline.load_state(&error) && !offline.feature_enabled("media.test", "active"),
"later offline save keeps original disabled feature rather than host selection");
}
/* Path-only files are still usable offline, but never portable online. */
const auto loose_root = root / "unverified-media";
write_text(loose_root / "packages/loose.test/1.0.0/manifest.toml",
"format_version = 8\nid = \"loose.test\"\nversion = \"1.0.0\"\nname = \"Loose\"\n"
"[[target]]\ngame_id = \"READER\"\n[[feature]]\nid = \"active\"\nname = \"Active\"\ndefault_enabled = true\n"
"[[resource]]\nfeature = \"active\"\nid = \"file\"\nlabel = \"File\"\nformat = \"file\"\nrequired = true\n");
write_text(loose_root / "state.toml",
"format_version = 2\n[[feature]]\npackage_id = \"loose.test\"\nid = \"active\"\nenabled = true\n"
"[feature.resources]\nfile = \"" + rom_path.generic_string() + "\"\n");
check(PSXRecompV4::mod_runtime_initialize(loose_root, "READER", 0, {}, &error), "loose resource initialize");
check(PSXRecompV4::mod_runtime_commit(iso_path, &error), "unverified file remains usable offline");
check(PSXRecompV4::mod_runtime_plan_fingerprint_portable().empty(), "unverified resource has no online fingerprint");
check(!PSXRecompV4::mod_runtime_commit(iso_path, &error, false), "online commit rejects unverified resource");
check(!PSXRecompV4::mod_runtime_commit_for_direct_netplay(iso_path, &error), "LAN also refuses unverified resources");
test_match_caps = {};
test_match_caps.valid = 1;
check(PSXRecompV4::mod_runtime_commit_for_netplay(iso_path, &error), "explicit empty host plan remains vanilla");
// A guest may own the source without ever enabling/preparing the host's
// feature offline. Preparation must precede the portable hash comparison.
{
const auto prepared_root = root / "prepared-netplay";
const auto prepared_asset = prepared_root / "generated" / "rom.z64";
int prepare_calls = 0;
check(PSXRecompV4::mod_register_media_preparer("test.netplay.prepare",
[&](const PSXRecompV4::ModPrepareContext&,
std::map<std::string, fs::path>& output, std::string&) {
++prepare_calls; write_bytes(prepared_asset, rom);
output["rom"] = prepared_asset; return true;
}), "register netplay preparation fixture");
write_text(prepared_root / "packages/prepared.test/1.0.0/manifest.toml",
"format_version = 9\nid = \"prepared.test\"\nversion = \"1.0.0\"\nname = \"Prepared\"\nprepare = \"test.netplay.prepare\"\n"
"[[target]]\ngame_id = \"READER\"\n[[feature]]\nid = \"active\"\nname = \"Active\"\ndefault_enabled = true\n"
"[[resource]]\nfeature = \"active\"\nid = \"rom\"\nlabel = \"ROM\"\nformat = \"n64-rom\"\nhidden = true\nrequired = true\nsize = 64\nsha256 = \"" + sha256_hex(rom) + "\"\n");
PSXRecompV4::mod_runtime_end_netplay(); test_match_caps = {};
check(PSXRecompV4::mod_runtime_initialize(prepared_root, "READER", 0, {}, &error) &&
PSXRecompV4::mod_runtime_commit_for_netplay(iso_path, &error), "direct launch prepares its selected resources");
const std::string prepared_fp = PSXRecompV4::mod_runtime_session_plan_fp();
PSXRecompV4::mod_runtime_end_netplay();
const std::string disabled = "format_version = 2\n[[feature]]\npackage_id = \"prepared.test\"\nid = \"active\"\nenabled = false\n";
write_text(prepared_root / "state.toml", disabled);
check(PSXRecompV4::mod_runtime_initialize(prepared_root, "READER", 0, {}, &error), "unprepared guest initialize");
test_match_caps.valid = 1; test_match_caps.mod_count = 1;
std::snprintf(test_match_caps.mod_plan_fp, sizeof(test_match_caps.mod_plan_fp), "%s", prepared_fp.c_str());
std::snprintf(test_match_caps.mods[0].id, sizeof(test_match_caps.mods[0].id), "prepared.test");
std::snprintf(test_match_caps.mods[0].ver, sizeof(test_match_caps.mods[0].ver), "1.0.0");
std::snprintf(test_match_caps.mods[0].feats, sizeof(test_match_caps.mods[0].feats), "active");
check(PSXRecompV4::mod_runtime_commit_for_netplay(iso_path, &error), "host enables and prepares guest feature before verification");
check(prepare_calls == 2 && PSXRecompV4::mod_runtime_plan_fingerprint_portable() == prepared_fp,
"prepared guest resolves identical verified bytes");
std::ifstream saved(prepared_root / "state.toml");
check(std::string(std::istreambuf_iterator<char>(saved), {}) == disabled, "prepared online launch preserves disabled offline choice");
PSXRecompV4::mod_runtime_end_netplay(); test_match_caps = {};
}
// The audio timeline skips INDEX00 pregaps and maps into the unchanged
// mounted BIN, including a second audio track sharing the same file.
const auto audio_root=root/"disc-audio";
@@ -1109,6 +1219,7 @@ int main() {
"format_version = 8\nid = \"audio.test\"\nversion = \"1.0.0\"\nname = \"Audio\"\n"
"[[target]]\ngame_id = \"READER\"\n[[feature]]\nid = \"active\"\nname = \"Active\"\ndefault_enabled = true\n"
"[[plugin]]\nfeature = \"active\"\nid = \"audio.test.plugin\"\n");
PSXRecompV4::mod_runtime_end_netplay();
check(psx_mod_register_activation_plugin("audio.test.plugin",test_disc_audio_plugin),"register mounted audio fixture");
check(PSXRecompV4::mod_runtime_initialize(audio_root,"READER",0,{},&error) &&
PSXRecompV4::mod_runtime_commit(audio_cue_path,&error),"mount audio fixture");
+38
View File
@@ -0,0 +1,38 @@
#ifdef NDEBUG
#undef NDEBUG
#endif
#include "netplay_content_gate.h"
#include <assert.h>
static const char *fp="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
static void send(NpContentGate *a,NpContentGate *b,int chunk) {
np_content_note(b,a->local,chunk|(a->mismatch?0x80:0),a->words[chunk*2],a->words[chunk*2+1],a->matched);
}
int main(void) {
NpContentGate a,b,c;int i;
assert(np_content_init(&a,fp,3,0));assert(np_content_init(&b,fp,3,1));
assert(!np_content_ready(&a));
// Reordering, duplicate and missing fragment: never boot on a partial hash.
send(&a,&b,3);send(&a,&b,3);send(&a,&b,1);send(&a,&b,0);
assert(!np_content_ready(&b));send(&a,&b,2);assert(!np_content_ready(&b));
for(i=0;i<4;i++)send(&b,&a,i);
assert(np_content_ready(&a));assert(!np_content_ready(&b));
// Final acknowledgement can be lost; retransmission unblocks the peer.
send(&a,&b,0);assert(np_content_ready(&b));
// A different byte in every quarter is detected and propagates to the host.
for(i=0;i<8;i++) {
assert(np_content_init(&a,fp,3,0));assert(np_content_init(&b,fp,3,1));
b.words[i]^=1;send(&b,&a,i/2);assert(a.mismatch && !np_content_ready(&a));
send(&a,&b,0);assert(b.mismatch && !np_content_ready(&b));
}
// Sparse occupied seats wait for all participants, but ignore absent seats.
assert(np_content_init(&a,fp,0x105,0));assert(np_content_init(&b,fp,0x105,2));
assert(np_content_init(&c,fp,0x105,8));
for(i=0;i<4;i++){send(&a,&b,i);send(&b,&a,i);}
assert(!np_content_ready(&a));
for(i=0;i<4;i++){send(&a,&c,i);send(&b,&c,i);send(&c,&a,i);send(&c,&b,i);}
send(&a,&b,0);send(&b,&a,0);send(&a,&c,0);send(&b,&c,0);
assert(np_content_ready(&a)&&np_content_ready(&b)&&np_content_ready(&c));
assert(!np_content_init(&a,"wrong",3,0));assert(!np_content_init(&a,fp,3,9));
assert(np_content_init(&a,"",3,0)&&np_content_ready(&a));
return 0;
}
+19
View File
@@ -25,6 +25,7 @@
#include "mod_plugins.h"
#include "pgxp.h"
#include "pgxp_session.h"
#include "psx_lobby_client.h"
#include "cpu_state.h"
#include "gpu.h"
@@ -128,6 +129,11 @@ extern "C" int gpu_ws_configured_x_reveal(void) { return 0; }
extern "C" void gpu_ws_tag_hud_prim(uint32_t, int) {}
extern "C" void gpu_ws_tag_screen_mask_quad(uint32_t) {}
extern "C" void gpu_ws_tag_radial_screen_mask_quad(uint32_t, float) {}
/* mod_runtime.cpp's lobby netplay commit reads the negotiated match caps.
* This test drives netplay through mod_runtime_clear_for_netplay, so no
* lobby match is ever negotiated. */
static PsxLobbyMatchCaps no_match_caps;
extern "C" const PsxLobbyMatchCaps* psx_lobby_match_caps(void) { return &no_match_caps; }
/* ---- staging ------------------------------------------------------------- */
@@ -262,6 +268,19 @@ int main(void) {
CHECK(commit(title_root));
CHECK(armed(session(), 1, 1, 0, 1));
/* A netplay session whose published plan carries the mod (content
* negotiation, mod_runtime_commit_for_netplay): geometry and texture as
* planned, precise culling never -- not even from the env override --
* because its guest-visible NCLIP reads host-only shadows that a rollback
* load drops on one peer only. */
g_cfg.netplay = 1;
CHECK(armed(session(), 1, 1, 0, 0));
test_setenv("PSX_PGXP_CULLING", "1");
CHECK(armed(session(), 1, 1, 0, 0));
test_unsetenv("PSX_PGXP_CULLING");
g_cfg.netplay = 0;
CHECK(armed(session(), 1, 1, 0, 1));
/* The player's off switch on the Mods page. */
const std::string disabled =
"format_version = 2\n"
@@ -0,0 +1,94 @@
/* test_sw_faithful_authority.c -- the netplay CPU-authoritative rasterizer
* draws the hardware image whatever presentation enhancements are armed.
*
* Under netplay the software rasterizer (dual-raster SW@1x, or the software
* GPU) owns the guest-visible VRAM: GP0 C0 readback and savestates come from
* it. PGXP's perspective/precise overrides depend on host-only shadows (a
* rollback load drops them on one peer only) and the texture filter is each
* player's Display setting, so neither may reach those pixels: V8:2 reads its
* own frame back pixel by pixel. sw_set_faithful_authority(1) must make an
* enhanced draw byte-identical to the plain one, and turning it off must
* restore the player's filter. */
#ifdef NDEBUG
#undef NDEBUG
#endif
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "../src/gpu_sw_renderer.c"
int g_ws_bd_stretch_on, g_ws_bd_stretch_pct;
int psx_ws_prim_in_backdrop(void) { return 0; }
int g_psx_vram_dirty_tracking;
void gpu_vram_dirty_mark_row_impl(uint32_t y) { (void)y; }
void gpu_vram_dirty_mark_rect(int x, int y, int w, int h) {
(void)x; (void)y; (void)w; (void)h;
}
void gpu_vram_dirty_mark_all(void) {}
static uint16_t vram[1024 * 512];
static uint16_t plain[256 * 200], out[256 * 200];
/* 15-bit direct texture page at x=512: every texel opaque and distinct. */
#define TEXPAGE ((uint16_t)((2u << 7) | 8u))
static void reset_vram(void) {
memset(vram, 0, sizeof vram);
for (int v = 0; v < 256; v++)
for (int u = 0; u < 256; u++)
vram[v * 1024 + 512 + u] =
(uint16_t)(((u * 7) & 31) | (((v * 5) & 31) << 5) |
((((u ^ v) * 3) & 31) << 10) | 0x8000u);
}
static void draw(int perspective, int precise, int filter, uint16_t *dst) {
reset_vram();
sw_set_texture_filter(filter);
sw_set_color_modulation(16, 16, 16, 1);
if (precise)
sw_set_precise_triangle(1, 10 << 16 | 0x8000, 260 << 16 | 0x4000,
230 << 16 | 0x8000, 280 << 16,
40 << 16, 455 << 16 | 0xC000);
if (perspective) sw_set_perspective_triangle(1, 1.0f, 0.2f, 0.45f);
sw_draw_textured_triangle(10, 260, 0, 0,
230, 280, 250, 10,
40, 455, 20, 240,
0, 0, TEXPAGE);
for (int y = 0; y < 200; y++)
memcpy(dst + y * 256, vram + (260 + y) * 1024, 256 * sizeof(uint16_t));
}
int main(void) {
sw_renderer_init(vram);
assert(!sw_faithful_authority());
draw(0, 0, 0, plain);
int lit = 0;
for (int i = 0; i < 256 * 200; i++) lit += plain[i] != 0;
assert(lit > 10000);
/* Offline: each enhancement changes the native pixels. */
draw(1, 0, 0, out);
assert(memcmp(out, plain, sizeof plain) != 0);
draw(0, 0, 1, out);
assert(memcmp(out, plain, sizeof plain) != 0);
/* Faithful authority: the same requests draw the hardware image. */
sw_set_texture_filter(1);
sw_set_faithful_authority(1);
assert(sw_faithful_authority() && sw_texture_filter() == 0);
draw(1, 1, 1, out);
assert(memcmp(out, plain, sizeof plain) == 0);
/* A filter change while armed is remembered, not applied. */
sw_set_texture_filter(1);
assert(sw_texture_filter() == 0);
/* Off again: the player's filter is back and enhancements apply. */
sw_set_faithful_authority(0);
assert(sw_texture_filter() == 1);
draw(1, 0, 1, out);
assert(memcmp(out, plain, sizeof plain) != 0);
printf("sw_faithful_authority_test: all checks passed\n");
return 0;
}