diff --git a/docs/ENHANCEMENTS.md b/docs/ENHANCEMENTS.md index 6b85651c..d33d4e48 100644 --- a/docs/ENHANCEMENTS.md +++ b/docs/ENHANCEMENTS.md @@ -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 diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index 89b5923c..0b191872 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -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. --- diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index 90b17471..30371383 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -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. diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 3763d602..905ac3c9 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -9c92d6060348bc2478abb2370e9c9142ee9c0d1d0ec8dc05d9be98177545e6e2 +13265e961396ac707b39701fc5787ae62c34e9c92c4b8b7fe3ea69e1cc86791e diff --git a/generated/SCPH1001.emitter.sha b/generated/SCPH1001.emitter.sha index 8d0cbb2e..27a95c4f 100644 --- a/generated/SCPH1001.emitter.sha +++ b/generated/SCPH1001.emitter.sha @@ -1 +1 @@ -26c707e2203f6886cc6c73098d2072fc811b278f55521fa0e945fa30b2def17f +2b30598a6f3db8c1a0c03eaa1f121d241fa53bd69e789ae202caf4d51ebe7ab6 diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 17d433f0..438cc04b 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1447,8 +1447,11 @@ GameConfig load_game_config(const fs::path& config_path_in) { std::vector netplay_local_viewport_width_sites; uint32_t netplay_local_viewport_state_addr = 0; std::vector 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(np, "content_negotiation"); if (np.contains("require_cue")) netplay_require_cue = toml::find(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, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 12a217aa..8d20f96e 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -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 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) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 7f742aca..44683e0f 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -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) diff --git a/runtime/include/gpu_sw_renderer.h b/runtime/include/gpu_sw_renderer.h index 211c2e5c..c549a585 100644 --- a/runtime/include/gpu_sw_renderer.h +++ b/runtime/include/gpu_sw_renderer.h @@ -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); diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 05d7a21e..8c82a630 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -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 diff --git a/runtime/include/netplay_content_gate.h b/runtime/include/netplay_content_gate.h new file mode 100644 index 00000000..9bb17d67 --- /dev/null +++ b/runtime/include/netplay_content_gate.h @@ -0,0 +1,44 @@ +#ifndef PSX_NETPLAY_CONTENT_GATE_H +#define PSX_NETPLAY_CONTENT_GATE_H +#include +#include +#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<matched=1u<enabled || slot>=9 || slot==g->local) return; + bit=1u<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<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<local))&g->occupied)==g->occupied); +} +#endif diff --git a/runtime/include/pgxp_session.h b/runtime/include/pgxp_session.h index 7d72f6b7..9cf58790 100644 --- a/runtime/include/pgxp_session.h +++ b/runtime/include/pgxp_session.h @@ -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. */ diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 0831dbb3..4302990d 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -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); diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 124d4542..23235fa8 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -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. */ diff --git a/runtime/src/gpu_sw_renderer.c b/runtime/src/gpu_sw_renderer.c index c33f6189..a00a5e51 100644 --- a/runtime/src/gpu_sw_renderer.c +++ b/runtime/src/gpu_sw_renderer.c @@ -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; } /* ------------------------------------------------------------------ */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 1ebcf448..77907729 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -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\n[\n\n\n * [\n[\n[\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; diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index c1d94a01..3e4fa78d 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -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 #include #include +#include #include #include #include @@ -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 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 "" or "=~[+~]". */ +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; } diff --git a/runtime/src/pgxp_session.cpp b/runtime/src/pgxp_session.cpp index eed7ac0d..752ce469 100644 --- a/runtime/src/pgxp_session.cpp +++ b/runtime/src/pgxp_session.cpp @@ -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); diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index b959171c..64fc252c 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -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) { diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index acbd8f45..f72c7e7d 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -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<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; diff --git a/runtime/tests/test_mod_resident.cpp b/runtime/tests/test_mod_resident.cpp index 7a1d5729..b7a5d3cf 100644 --- a/runtime/tests/test_mod_resident.cpp +++ b/runtime/tests/test_mod_resident.cpp @@ -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 @@ -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++; } diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index e63c1400..a3f941a6 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -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(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& 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(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"); diff --git a/runtime/tests/test_netplay_content_gate.c b/runtime/tests/test_netplay_content_gate.c new file mode 100644 index 00000000..e76e5bed --- /dev/null +++ b/runtime/tests/test_netplay_content_gate.c @@ -0,0 +1,38 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif +#include "netplay_content_gate.h" +#include +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; +} diff --git a/runtime/tests/test_pgxp_session.cpp b/runtime/tests/test_pgxp_session.cpp index 44c2e6b5..2d3e166a 100644 --- a/runtime/tests/test_pgxp_session.cpp +++ b/runtime/tests/test_pgxp_session.cpp @@ -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" diff --git a/runtime/tests/test_sw_faithful_authority.c b/runtime/tests/test_sw_faithful_authority.c new file mode 100644 index 00000000..442c4d05 --- /dev/null +++ b/runtime/tests/test_sw_faithful_authority.c @@ -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 +#include +#include +#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; +}