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