diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 19e4cdbc..8eddef20 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -464,6 +464,10 @@ public: developer_channel_ = visible; } bool developer_channel_visible() const { return developer_channel_; } + /* Trusted title policy: hide and deactivate these ids even when an old + * bundle or an installed copy survives an update. Saved choices stay + * dormant and files remain untouched. Set before scan(). */ + void set_disabled_packages(std::vector ids) { disabled_packages_ = std::move(ids); } /* Build-owned catalog: wiped and re-staged by every build. */ std::filesystem::path bundled_root() const { return root_ / "bundled"; } @@ -588,6 +592,7 @@ private: bool developer_channel_ = kDeveloperChannelDefault; std::vector scan_errors_; std::map> packages_; + std::vector disabled_packages_; std::map selections_; std::map sources_; }; diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index d9fc3954..3e58f59c 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -1023,7 +1023,8 @@ int psx_mod_set_controller_source_connected(uint32_t player, int connected); * host_flags bit 0 = a * gamepad is assigned, bit 1 / bit 2 = it has a left / right trigger axis; * host_lt / host_rt are those triggers, 0 released .. 255 fully pressed - * (0 when absent or while the savestate input guard is armed). + * (0 when absent or while the savestate input guard is armed). Bit 3 marks + * axes/type supplied by an offline input source (PSX_MOD_PAD_HOST_SOURCE). * * The output arrives pre-filled with the stock pad (what the player gets * without a transform, so a digital pad keeps its fold and centred sticks; @@ -1050,7 +1051,10 @@ enum { enum { PSX_MOD_PAD_HOST_GAMEPAD = 1u << 0, PSX_MOD_PAD_HOST_LT = 1u << 1, - PSX_MOD_PAD_HOST_RT = 1u << 2 + PSX_MOD_PAD_HOST_RT = 1u << 2, + /* Axes/type came from an offline input source, rather than the host. + * Transforms can preserve already transformed diagnostic recordings. */ + PSX_MOD_PAD_HOST_SOURCE = 1u << 3 }; typedef struct PSXModPadFrame { uint32_t struct_size, player, buttons, lx, ly, rx, ry, type; @@ -1147,6 +1151,11 @@ int psx_mod_set_local_mouse_policy(const PSXModMousePolicy* policy); /* Optional action-game capture: click once to capture, Escape to release. * Later clicks reach the normal bindings. Existing hold-to-look is default. */ int psx_mod_set_local_mouse_persistent_capture(int enabled); +/* Optional persistent-capture companion: keep existing capture through title + * eligibility changes (menus/cutscenes/loading), Start and host stalls, while + * clearing mouse motion and suspending output. Focus loss, host UI, disconnect, + * native right-stick takeover and Escape still release. Never auto-acquires. */ +int psx_mod_set_local_mouse_capture_across_transitions(int enabled); /* * Register a C plugin before main() on the compilers supported by the runtime. diff --git a/runtime/src/local_mouse_policy.cpp b/runtime/src/local_mouse_policy.cpp index 93171fd1..e96fa75a 100644 --- a/runtime/src/local_mouse_policy.cpp +++ b/runtime/src/local_mouse_policy.cpp @@ -23,7 +23,7 @@ void LocalMousePolicy::clear() { clock_valid_ = false; clock_ = event_clock_ = release_clock_ = 0; conflict_noticed_ = capture_failure_noticed_ = false; - persistent_ = false; + persistent_ = transitions_ = suspended_ = false; } bool LocalMousePolicy::persistent_capture(bool enabled) { if (!installed()) return false; @@ -31,6 +31,12 @@ bool LocalMousePolicy::persistent_capture(bool enabled) { persistent_ = enabled; return true; } +bool LocalMousePolicy::capture_across_transitions(bool enabled) { + if (!installed()) return false; + reset(); + transitions_ = enabled; + return true; +} void LocalMousePolicy::dispatch(uint32_t type, uint64_t t, double dx, double dy) { if (!installed()) return; const PSXModMouseEvent event{sizeof(PSXModMouseEvent), type, t, dx, dy}; @@ -39,6 +45,7 @@ void LocalMousePolicy::dispatch(uint32_t type, uint64_t t, double dx, double dy) void LocalMousePolicy::reset() { if (captured_) backend_.capture(backend_.context, false); captured_ = false; + suspended_ = false; release_clock_ = clock_; event_valid_ = false; hold_blocked_ = hold_down_; @@ -54,12 +61,33 @@ bool LocalMousePolicy::allowed() const { return installed() && host_.live && host_.focused && host_.connected && host_.analog && !host_.native_right && !host_.start && policy_.eligible(host_.buttons); } +bool LocalMousePolicy::capture_allowed() const { + return installed() && host_.live && host_.focused && host_.connected && + host_.analog && !host_.native_right; +} +void LocalMousePolicy::suspend() { + release_clock_ = clock_; + event_valid_ = false; + hold_blocked_ = hold_down_; + hold_active_ = false; + if (!suspended_) dispatch(PSX_MOD_MOUSE_RESET); + suspended_ = true; +} void LocalMousePolicy::update(uint64_t now, LocalMouseHost host) { const bool stalled = clock_valid_ && (now < clock_ || now - clock_ > StallNs); clock_ = now; clock_valid_ = true; host_ = host; - if (stalled || !allowed()) reset(); + if (transitions_ && persistent_ && captured_ && capture_allowed()) { + if (stalled || !allowed()) suspend(); + else if (suspended_) { + // Reject motion queued during the transition; resuming never + // replays an old camera target or requires another click. + release_clock_ = now; + event_valid_ = false; + suspended_ = false; + } + } else if (stalled || !allowed()) reset(); if (host.hold_conflict && !conflict_noticed_) { backend_.notice(backend_.context, "Mouse hold disabled: control is bound to P1"); conflict_noticed_ = true; @@ -91,12 +119,13 @@ bool LocalMousePolicy::control(uint64_t t, uint64_t now, MouseControl control, const bool fresh = down && !activation_down_ && !repeat; activation_down_ = down; if (!down) { - if (persistent_ && captured_ && allowed()) { + if (persistent_ && captured_ && (allowed() || + (transitions_ && capture_allowed()))) { const bool owned = activation_owned_; activation_owned_ = false; // t=0 is the backend's release reconciliation, not a queued // event. Malformed real event times still release capture. - if (t) (void)valid_time(t, now); + if (t && !(transitions_ && t < release_clock_)) (void)valid_time(t, now); return owned; } // Release is unconditional: a future/backwards event time cannot @@ -150,7 +179,7 @@ bool LocalMousePolicy::control(uint64_t t, uint64_t now, MouseControl control, event_clock_ = std::max(event_clock_, release_time); event_valid_ = true; } - } else if (captured_ && fresh && !host_.hold_conflict && !hold_blocked_) { + } else if (captured_ && allowed() && fresh && !host_.hold_conflict && !hold_blocked_) { if (valid_time(t, now)) { hold_active_ = true; dispatch(PSX_MOD_MOUSE_HOLD_PRESS, t); @@ -170,14 +199,24 @@ void LocalMousePolicy::sync_left(uint64_t now, bool physically_down) { } void LocalMousePolicy::motion(uint64_t t, uint64_t now, double dx, double dy) { if (!captured_) return; - if (!allowed() || !std::isfinite(dx) || !std::isfinite(dy) || + if (!allowed()) { + if (transitions_ && persistent_ && capture_allowed()) suspend(); + else reset(); + return; + } + if (transitions_ && t < release_clock_) return; + if (!std::isfinite(dx) || !std::isfinite(dy) || std::abs(dx) > 1e9 || std::abs(dy) > 1e9) { reset(); return; } if (!valid_time(t, now) || (dx == 0.0 && dy == 0.0)) return; dispatch(PSX_MOD_MOUSE_MOTION, t, dx, dy); } void LocalMousePolicy::sample(uint64_t now, uint8_t& rx, uint8_t& ry) { if (!captured_) return; - if (!allowed()) { reset(); return; } + if (!allowed()) { + if (transitions_ && persistent_ && capture_allowed()) suspend(); + else reset(); + return; + } PSXModMouseOutput out{sizeof(PSXModMouseOutput), 0, 128, 128}; policy_.sample(now, &out); if (out.struct_size != sizeof(out) || out.override_right > 1 || out.rx > 255 || out.ry > 255) { diff --git a/runtime/src/local_mouse_policy.h b/runtime/src/local_mouse_policy.h index cf3febd4..4eead5d6 100644 --- a/runtime/src/local_mouse_policy.h +++ b/runtime/src/local_mouse_policy.h @@ -22,6 +22,7 @@ public: explicit LocalMousePolicy(LocalMouseBackend backend) : backend_(backend) {} bool install(const PSXModMousePolicy* policy); bool persistent_capture(bool enabled); + bool capture_across_transitions(bool enabled); void clear(); void reset(); void update(uint64_t now, LocalMouseHost host); @@ -35,6 +36,8 @@ public: MouseControl hold_control() const; private: bool allowed() const; + bool capture_allowed() const; + void suspend(); bool valid_time(uint64_t t, uint64_t now); void dispatch(uint32_t type, uint64_t t = 0, double dx = 0, double dy = 0); LocalMouseBackend backend_; @@ -47,5 +50,6 @@ private: bool hold_active_ = false; // accepted interpretation, separate from physical state bool conflict_noticed_ = false, capture_failure_noticed_ = false; bool persistent_ = false; + bool transitions_ = false, suspended_ = false; }; } // namespace psx diff --git a/runtime/src/local_mouse_sdl.cpp b/runtime/src/local_mouse_sdl.cpp index 1548b0a1..1889883d 100644 --- a/runtime/src/local_mouse_sdl.cpp +++ b/runtime/src/local_mouse_sdl.cpp @@ -97,6 +97,9 @@ extern "C" int psx_mod_set_local_mouse_policy(const PSXModMousePolicy* policy) { extern "C" int psx_mod_set_local_mouse_persistent_capture(int enabled) { return (enabled == 0 || enabled == 1) && state().persistent_capture(enabled != 0); } +extern "C" int psx_mod_set_local_mouse_capture_across_transitions(int enabled) { + return (enabled == 0 || enabled == 1) && state().capture_across_transitions(enabled != 0); +} void psx_local_mouse_clear() { state().clear(); host_ = {}; window_ = nullptr; } bool psx_local_mouse_installed() { return state().installed(); } void psx_local_mouse_reset() { diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 7f8b57c0..a04e746b 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -2765,6 +2765,8 @@ bool ModPackageManager::scan_root(const fs::path& packages_root, ": package path does not match manifest id/version"); continue; } + if (std::find(disabled_packages_.begin(), disabled_packages_.end(), package.id) != disabled_packages_.end()) + continue; if (!developer_channel_ && !strip_developer_features(package)) { /* Every feature was developer-channel, so on this build the * package has nothing to offer. Not an error: it is the diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index d7c0cdb7..6b990419 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1524,6 +1524,17 @@ bool mod_runtime_initialize(const std::filesystem::path& root, s.disc_enabled = false; s.disc_guard_failed = false; s.manager.set_root(root); + // Optional comma-separated trusted title policy, stronger than omitting + // bundled manifests: stale/installed copies cannot reactivate these ids. +#ifdef PSX_MOD_DISABLED_PACKAGES + std::vector disabled_packages; + std::istringstream disabled_stream(PSX_MOD_DISABLED_PACKAGES); + for (std::string id; std::getline(disabled_stream, id, ','); ) + if (!id.empty()) disabled_packages.push_back(id); + s.manager.set_disabled_packages(std::move(disabled_packages)); +#else + s.manager.set_disabled_packages({}); +#endif s.game_id = game_id; s.entry_phys = game_entry_pc & 0x1FFFFFFFu; #if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) diff --git a/runtime/src/pad_external_input.c b/runtime/src/pad_external_input.c index e0ebec74..d0e30edc 100644 --- a/runtime/src/pad_external_input.c +++ b/runtime/src/pad_external_input.c @@ -39,6 +39,7 @@ static void pad_ext_transform(const PadExtHooks *h, int s, int guarded, } } if (h->host_extras) h->host_extras(h->ctx, s, &f.host_flags, &f.host_lt, &f.host_rt); + if (from_source) f.host_flags |= PSX_MOD_PAD_HOST_SOURCE; if (guarded) f.host_lt = f.host_rt = 0; if (!h->pad_transform(h->ctx, s, &f, &stock, &o)) return; if (guarded) { diff --git a/runtime/tests/test_local_mouse_policy.cpp b/runtime/tests/test_local_mouse_policy.cpp index e483a9b5..817f538a 100644 --- a/runtime/tests/test_local_mouse_policy.cpp +++ b/runtime/tests/test_local_mouse_policy.cpp @@ -278,6 +278,49 @@ int main() { lost.clear(); } + // Action games retain relative capture across native menus, cutscenes, + // Start and loading stalls, but discard all motion from those intervals. + { + psx::LocalMousePolicy game({nullptr,capture,suppress,notice}); + CHECK(game.install(&callbacks)); + CHECK(game.persistent_capture(true)); + CHECK(game.capture_across_transitions(true)); + uint64_t now=20000*Ms; + game.update(now,host); + CHECK(game.control(now,now,psx::MouseControl::Left,true,false)); + CHECK(game.control(now+Ms,now+Ms,psx::MouseControl::Left,false,false)); + for(int gate=0;gate<3;++gate) { + now+=10*Ms;game.update(now,host);game.motion(now,now,30,0);CHECK(active); + auto off=host; + if(gate==0)guest=false; + if(gate==1)off.start=true; + if(gate==2)now+=300*Ms; + game.update(now+Ms,off);CHECK(game.captured() && !active); + game.control(now-Ms,now+Ms,psx::MouseControl::Left,false,false); + CHECK(game.captured()); // queued release cannot expose the cursor + if(gate!=2)game.motion(now+2*Ms,now+2*Ms,30,0); + CHECK(!active && game.captured()); + game.control(now+2*Ms,now+2*Ms,psx::MouseControl::Left,false,false); + CHECK(game.captured()); + guest=true;game.update(now+3*Ms,host); + game.motion(now+2*Ms,now+3*Ms,30,0);CHECK(!active && game.captured()); + game.sample(now+3*Ms,x,y);CHECK(x==128 && y==128); + game.motion(now+4*Ms,now+4*Ms,30,0);CHECK(active); + } + for(int gate=0;gate<5;++gate) { + now+=10*Ms;auto off=host; + if(gate==0)off.focused=false;if(gate==1)off.live=false; + if(gate==2)off.connected=false;if(gate==3)off.analog=false; + if(gate==4)off.native_right=true; + game.update(now,off);CHECK(!game.captured() && !active); + game.update(now+Ms,host);CHECK(!game.captured()); + CHECK(game.control(now+2*Ms,now+2*Ms,psx::MouseControl::Left,true,false)); + game.control(now+3*Ms,now+3*Ms,psx::MouseControl::Left,false,false); + } + CHECK(game.control(now+4*Ms,now+4*Ms,psx::MouseControl::Escape,true,false)); + game.update(now+5*Ms,host);CHECK(!game.captured()); + game.clear(); + } policy.reset();host.hold_conflict=false;capture_ok=false;CHECK(acquire());CHECK(!policy.captured()); const unsigned failure_notices=notices;CHECK(acquire());CHECK(notices==failure_notices); policy.clear(); CHECK(!policy.installed() && !policy.captured()); diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index cd232153..6ef6b90f 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -1798,6 +1798,21 @@ int main() { "both absent packages must be reported as dormant selections"); check(std::find(dormant.begin(), dormant.end(), "kept.mod") == dormant.end(), "a present package is never dormant"); + // Strong title policy also suppresses stale bundles and installed + // copies. Preserve the user's choice and files for a later re-enable. + write_text(stale_root / "installed/kept.mod/1.0.0/manifest.toml", kept); + stale.set_disabled_packages({"kept.mod"}); + check(stale.scan(&error), error.c_str()); + const auto disabled = stale.resolve("SLUS-TEST"); + check(disabled.ok && disabled.ordered.empty() && disabled.writes.empty(), + "a title-disabled package must not resolve from either catalog"); + check(stale.packages().count("kept.mod") == 0 && stale.dormant_selections().size() == 3, + "title-disabled packages must be hidden and their saved choices dormant"); + check(fs::exists(stale_root / "bundled/kept.mod/1.0.0/manifest.toml") && + fs::exists(stale_root / "installed/kept.mod/1.0.0/manifest.toml"), + "disabling never deletes catalog files"); + stale.set_disabled_packages({}); + check(stale.scan(&error), error.c_str()); /* Saving (every launch commit does) must keep the dormant choices. */ check(stale.save_state(&error), error.c_str()); diff --git a/runtime/tests/test_pad_external_input.c b/runtime/tests/test_pad_external_input.c index 04d9d8c8..03a3c813 100644 --- a/runtime/tests/test_pad_external_input.c +++ b/runtime/tests/test_pad_external_input.c @@ -186,7 +186,7 @@ int main(void) { CHECK(pad_ext_resolve(&hx, 0, &o) == 1); CHECK(T.xf_seen.struct_size == sizeof T.xf_seen && T.xf_seen.player == 0); CHECK(T.xf_seen.buttons == (0xfeff & 0xfffe) && T.xf_seen.lx == 200 && - T.xf_seen.type == 1 && T.xf_seen.host_flags == 7 && + T.xf_seen.type == 1 && T.xf_seen.host_flags == (7 | PSX_MOD_PAD_HOST_SOURCE) && T.xf_seen.host_lt == 60 && T.xf_seen.host_rt == 250); CHECK(o.analog == PSX_MOD_PAD_NEGCON && o.buttons == 0xbfff && o.lx == 0x30 && o.rx == 250 && o.ry == 60 && o.ly == 5);